diff --git a/claudedocs/.DS_Store b/claudedocs/.DS_Store index 11ca4f54..c8745c09 100644 Binary files a/claudedocs/.DS_Store and b/claudedocs/.DS_Store differ diff --git a/claudedocs/_index.md b/claudedocs/_index.md index ccf1cca8..77f102a9 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -152,7 +152,8 @@ claudedocs/ | 파일 | μ„€λͺ… | |------|------| -| `[PLAN-2025-12-23] common-component-extraction-plan.md` | πŸ”΄ **NEW** - 곡톡 μ»΄ν¬λ„ŒνŠΈ μΆ”μΆœ κ³„νšμ„œ (Phase 1-4, 체크리슀트 포함, ~1,900쀄 절감) | +| `[GUIDE-2025-12-29] vercel-deployment.md` | πŸ”΄ **NEW** - Vercel 배포 κ°€μ΄λ“œ (ν™˜κ²½λ³€μˆ˜, CORS, ν…ŒμŠ€νŠΈ 체크리슀트) | +| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 곡톡 μ»΄ν¬λ„ŒνŠΈ μΆ”μΆœ κ³„νšμ„œ (Phase 1-4, 체크리슀트 포함, ~1,900쀄 절감) | | `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | πŸ“‹ 곡톡 μ»΄ν¬λ„ŒνŠΈ μΆ”μΆœ 후보 뢄석 (λ‹€μ΄μ–Όλ‘œκ·Έ 102개 쀑볡, ~2,370쀄 절감 μ˜ˆμƒ) | | `[PLAN-2025-12-19] project-health-improvement.md` | βœ… **Phase 1 μ™„λ£Œ** - ν”„λ‘œμ νŠΈ ν—¬μŠ€ κ°œμ„  κ³„νšμ„œ (νƒ€μž…μ—λŸ¬ 0개, APIν‚€ λ³΄μ•ˆ, SSR μˆ˜μ •) | | `[PLAN-2025-12-19] page-layout-standardization.md` | πŸ”΄ **NEW** - νŽ˜μ΄μ§€ λ ˆμ΄μ•„μ›ƒ ν‘œμ€€ν™” κ³„νš | @@ -172,6 +173,7 @@ claudedocs/ | 파일 | μ„€λͺ… | |------|------| +| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | πŸ”΄ **NEW** - 동적 메뉴 κ°±μ‹  μ‹œμŠ€ν…œ (1단계: 폴링, 2단계: SSE) | | `multi-tenancy-implementation.md` | λ©€ν‹°ν…Œλ„Œμ‹œ κ΅¬ν˜„ | | `multi-tenancy-test-guide.md` | λ©€ν‹°ν…Œλ„Œμ‹œ ν…ŒμŠ€νŠΈ | | `architecture-integration-risks.md` | 톡합 리슀크 | diff --git a/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md new file mode 100644 index 00000000..effee8dc --- /dev/null +++ b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md @@ -0,0 +1,308 @@ +# 동적 메뉴 κ°±μ‹  μ‹œμŠ€ν…œ + +## κ°œμš” + +κ΄€λ¦¬μžκ°€ κ²Œμ‹œνŒ/메뉴λ₯Ό μΆ”κ°€ν•˜λ©΄ μ‚¬μš©μžκ°€ **재둜그인 없이** μ¦‰μ‹œ 메뉴λ₯Ό 갱신받을 수 μžˆλŠ” μ‹œμŠ€ν…œ κ΅¬ν˜„. + +## ν˜„μž¬ 문제점 + +``` +ν˜„μž¬ 흐름: + 둜그인 β†’ API μ‘λ‹΅μ—μ„œ 메뉴 μˆ˜μ‹  β†’ localStorage.user.menu μ €μž₯ β†’ μ„Έμ…˜ μ’…λ£ŒκΉŒμ§€ κ³ μ • + +문제: + - κ΄€λ¦¬μžκ°€ κ²Œμ‹œνŒ 좔가해도 μ‚¬μš©μžλŠ” 재둜그인 μ „κΉŒμ§€ μƒˆ 메뉴 μ•ˆ λ³΄μž„ + - 메뉴 μ „μš© κ°±μ‹  API μ—†μŒ + - μ‹€μ‹œκ°„ μ•Œλ¦Ό λ©”μ»€λ‹ˆμ¦˜ μ—†μŒ +``` + +## 데이터 흐름 (ν˜„μž¬) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 둜그인 μ‹œ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ POST /api/v1/login β”‚ +β”‚ ↓ β”‚ +β”‚ 응닡: { user, tenant, roles, menus } β”‚ +β”‚ ↓ β”‚ +β”‚ transformApiMenusToMenuItems(menus) β”‚ +β”‚ ↓ β”‚ +β”‚ localStorage.setItem('user', { ...userData, menu }) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ νŽ˜μ΄μ§€ λ‘œλ“œ μ‹œ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ AuthenticatedLayout.tsx β”‚ +β”‚ ↓ β”‚ +β”‚ localStorage.getItem('user') β†’ userData.menu β”‚ +β”‚ ↓ β”‚ +β”‚ deserializeMenuItems(userData.menu) β”‚ +β”‚ ↓ β”‚ +β”‚ menuStore.setMenuItems(deserializedMenus) β”‚ +β”‚ ↓ β”‚ +β”‚ Sidebar μ»΄ν¬λ„ŒνŠΈ λ Œλ”λ§ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## κ΄€λ ¨ 파일 + +| 파일 | μ—­ν•  | +|------|------| +| `src/store/menuStore.ts` | Zustand 메뉴 μƒνƒœ 관리 | +| `src/lib/utils/menuTransform.ts` | API 메뉴 β†’ UI 메뉴 λ³€ν™˜ | +| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 λ‘œλ“œ 및 μŠ€ν† μ–΄ μ„€μ • | +| `src/components/layout/Sidebar.tsx` | 메뉴 λ Œλ”λ§ | +| `src/contexts/AuthContext.tsx` | μ‚¬μš©μž 인증 μ»¨ν…μŠ€νŠΈ | + +--- + +## κ΅¬ν˜„ κ³„νš + +### 1단계: 폴링 방식 (ν˜„μž¬ κ΅¬ν˜„ λͺ©ν‘œ) + +**방식**: 30μ΄ˆλ§ˆλ‹€ 메뉴 API ν˜ΈμΆœν•˜μ—¬ 변경사항 확인 + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 폴링 방식 흐름 β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ [30μ΄ˆλ§ˆλ‹€] β”‚ +β”‚ ↓ β”‚ +β”‚ GET /api/menus (메뉴 μ „μš© API ν•„μš”) β”‚ +β”‚ ↓ β”‚ +β”‚ ν˜„μž¬ 메뉴와 비ꡐ (ν•΄μ‹œ λ˜λŠ” 버전 비ꡐ) β”‚ +β”‚ ↓ β”‚ +β”‚ λ³€κ²½ 있으면 β†’ refreshMenus() 호좜 β”‚ +β”‚ ↓ β”‚ +β”‚ localStorage.user.menu μ—…λ°μ΄νŠΈ β”‚ +β”‚ menuStore.setMenuItems() 호좜 β”‚ +β”‚ ↓ β”‚ +β”‚ UI μ¦‰μ‹œ 반영 β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**μž₯점**: +- κ΅¬ν˜„ λ‹¨μˆœ +- λ°±μ—”λ“œ μˆ˜μ • μ΅œμ†Œν™” (메뉴 쑰회 API만 μΆ”κ°€) +- κΈ°μ‘΄ 인프라 κ·ΈλŒ€λ‘œ μ‚¬μš© + +**단점**: +- μ΅œλŒ€ 30초 μ§€μ—° +- λΆˆν•„μš”ν•œ API 호좜 λ°œμƒ + +#### ν”„λ‘ νŠΈμ—”λ“œ κ΅¬ν˜„ 사항 + +1. **메뉴 κ°±μ‹  μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜** (`src/lib/utils/menuRefresh.ts`) +2. **폴링 ν›…** (`src/hooks/useMenuPolling.ts`) +3. **AuthenticatedLayout에 ν›… 적용** + +#### λ°±μ—”λ“œ μš”μ²­ 사항 + +| ν•­λͺ© | μ„€λͺ… | +|------|------| +| **μ—”λ“œν¬μΈνŠΈ** | `GET /api/v1/menus` | +| **인증** | Bearer 토큰 ν•„μš” | +| **응닡** | ν˜„μž¬ μ‚¬μš©μžμ˜ 메뉴 λͺ©λ‘ (둜그인 μ‘λ‹΅μ˜ menus와 동일 ꡬ쑰) | +| **선택사항** | `menu_version` λ˜λŠ” `menu_hash` ν•„λ“œ μΆ”κ°€ (λ³€κ²½ 감지 μ΅œμ ν™”μš©) | + +--- + +### 2단계: SSE 고도화 (ν–₯ν›„ κ³„νš) + +**방식**: μ„œλ²„μ—μ„œ 메뉴 λ³€κ²½ μ‹œ SSE둜 ν΄λΌμ΄μ–ΈνŠΈμ— ν‘Έμ‹œ + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ λ°±μ—”λ“œ (Laravel) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ 1. κ΄€λ¦¬μžκ°€ 메뉴 μΆ”κ°€ β†’ DB μ €μž₯ β”‚ +β”‚ 2. MenuUpdatedEvent λ°œμƒ β”‚ +β”‚ 3. ν•΄λ‹Ή ν…Œλ„ŒνŠΈμ˜ SSE μ±„λ„λ‘œ ν‘Έμ‹œ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ↓ SSE +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ν”„λ‘ νŠΈμ—”λ“œ (Next.js) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ 1. EventSource둜 SSE μ—°κ²° μœ μ§€ β”‚ +β”‚ 2. 'menu-updated' 이벀트 μˆ˜μ‹  β”‚ +β”‚ 3. refreshMenus() 호좜 β†’ UI μ¦‰μ‹œ κ°±μ‹  β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**μž₯점**: +- μ‹€μ‹œκ°„ κ°±μ‹  (μ§€μ—° μ—†μŒ) +- 효율적 (λ³€κ²½ μ‹œμ—λ§Œ 톡신) + +**단점**: +- λ°±μ—”λ“œ SSE 인프라 ꡬ좕 ν•„μš” +- λ™μ‹œ μ ‘μ†μž 관리 ν•„μš” +- λ©€ν‹°ν…Œλ„ŒνŠΈ 채널 뢄리 ν•„μš” + +#### λ°±μ—”λ“œ μš”κ΅¬μ‚¬ν•­ (SSE) + +| ν•­λͺ© | μ„€λͺ… | +|------|------| +| **SSE μ—”λ“œν¬μΈνŠΈ** | `GET /api/v1/sse/menu-updates` | +| **인증** | Bearer 토큰 λ˜λŠ” 쿼리 νŒŒλΌλ―Έν„° | +| **이벀트 νƒ€μž…** | `menu-updated` | +| **채널 뢄리** | ν…Œλ„ŒνŠΈλ³„λ‘œ 뢄리 ν•„μš” | +| **κ΅¬ν˜„ μ˜΅μ…˜** | Laravel Broadcasting + Redis, 직접 κ΅¬ν˜„ λ“± | + +--- + +## κ΅¬ν˜„ 체크리슀트 + +### 1단계: 폴링 방식 + +#### ν”„λ‘ νŠΈμ—”λ“œ βœ… κ΅¬ν˜„ μ™„λ£Œ (2025-12-29) +- [x] `src/lib/utils/menuRefresh.ts` 생성 + - [x] `refreshMenus()` ν•¨μˆ˜ κ΅¬ν˜„ + - [x] `forceRefreshMenus()` κ°•μ œ κ°±μ‹  ν•¨μˆ˜ + - [x] localStorage + Zustand λ™μ‹œ μ—…λ°μ΄νŠΈ + - [x] ν•΄μ‹œ 기반 λ³€κ²½ 감지 +- [x] `src/hooks/useMenuPolling.ts` 생성 + - [x] 30초 간격 폴링 둜직 + - [x] νƒ­ κ°€μ‹œμ„± λ³€κ²½ μ‹œ μžλ™ 쀑지/재개 + - [x] pause/resume κΈ°λŠ₯ + - [x] μ»΄ν¬λ„ŒνŠΈ μ–Έλ§ˆμš΄νŠΈ μ‹œ 정리 +- [x] `src/app/api/menus/route.ts` 생성 (Next.js ν”„λ‘μ‹œ) + - [x] λ°±μ—”λ“œ 메뉴 API ν”„λ‘μ‹œ + - [x] HttpOnly μΏ ν‚€ 토큰 처리 + - [x] `{ data: [...] }` 응닡 ꡬ쑰 처리 +- [x] `AuthenticatedLayout.tsx`에 ν›… 적용 +- [ ] ν…ŒμŠ€νŠΈ: κ΄€λ¦¬μž 메뉴 μΆ”κ°€ β†’ 30초 λ‚΄ μ‚¬μš©μž 메뉴 κ°±μ‹  확인 + +#### λ°±μ—”λ“œ (이미 쑴재!) +- [x] `GET /api/v1/menus` API 쑴재 확인 βœ… +- [x] `MenuController::index` β†’ `MenuService::index` (μ‚¬μš©μž κΆŒν•œ 기반 필터링) +- [x] 응닡 ꡬ쑰: `{ data: [...] }` (ApiResponse::handle ν‘œμ€€) + +### 2단계: SSE 고도화 (ν–₯ν›„) + +- [ ] λ°±μ—”λ“œ SSE 인프라 ꡬ좕 +- [ ] ν”„λ‘ νŠΈμ—”λ“œ EventSource ν›… κ΅¬ν˜„ +- [ ] 폴링 β†’ SSE μ „ν™˜ +- [ ] 폴백: SSE μ—°κ²° μ‹€νŒ¨ μ‹œ 폴링으둜 λŒ€μ²΄ + +--- + +## μ½”λ“œ μŠ€λ‹ˆνŽ« + +### refreshMenus ν•¨μˆ˜ + +```typescript +// src/lib/utils/menuRefresh.ts +import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform'; +import { useMenuStore } from '@/store/menuStore'; + +export async function refreshMenus(): Promise { + try { + const response = await fetch('/api/menus'); + if (!response.ok) return false; + + const { menus } = await response.json(); + const transformedMenus = transformApiMenusToMenuItems(menus); + + // 1. localStorage μ—…λ°μ΄νŠΈ (μƒˆλ‘œκ³ μΉ¨ λŒ€μ‘) + const userData = JSON.parse(localStorage.getItem('user') || '{}'); + userData.menu = transformedMenus; + localStorage.setItem('user', JSON.stringify(userData)); + + // 2. Zustand μŠ€ν† μ–΄ μ—…λ°μ΄νŠΈ (UI μ¦‰μ‹œ 반영) + const { setMenuItems } = useMenuStore.getState(); + setMenuItems(deserializeMenuItems(transformedMenus)); + + console.log('[Menu] 메뉴 κ°±μ‹  μ™„λ£Œ'); + return true; + } catch (error) { + console.error('[Menu] 메뉴 κ°±μ‹  μ‹€νŒ¨:', error); + return false; + } +} +``` + +### useMenuPolling ν›… + +```typescript +// src/hooks/useMenuPolling.ts +import { useEffect, useRef } from 'react'; +import { refreshMenus } from '@/lib/utils/menuRefresh'; + +const POLLING_INTERVAL = 30000; // 30초 + +export function useMenuPolling(enabled: boolean = true) { + const intervalRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + // 초기 싀행은 ν•˜μ§€ μ•ŠμŒ (둜그인 μ‹œ 이미 λ°›μ•„μ˜΄) + intervalRef.current = setInterval(() => { + refreshMenus(); + }, POLLING_INTERVAL); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [enabled]); +} +``` + +### Next.js API ν”„λ‘μ‹œ + +```typescript +// src/app/api/menus/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const token = request.cookies.get('access_token')?.value; + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/menus`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + }, + }); + + const data = await response.json(); + return NextResponse.json(data); +} +``` + +--- + +## μ°Έκ³  사항 + +### 메뉴 데이터 μ €μž₯ μœ„μΉ˜ + +| μ €μž₯μ†Œ | ν‚€ | μš©λ„ | +|--------|-----|------| +| localStorage | `user.menu` | μƒˆλ‘œκ³ μΉ¨ μ‹œ 볡ꡬ용 | +| Zustand | `menuStore.menuItems` | UI λ Œλ”λ§μš© | + +### κ°±μ‹  μ‹œ 동기화 ν•„μˆ˜ + +```typescript +// λ°˜λ“œμ‹œ λ‘˜ λ‹€ μ—…λ°μ΄νŠΈ! +localStorage.user.menu = newMenus; // μƒˆλ‘œκ³ μΉ¨ λŒ€μ‘ +menuStore.setMenuItems(newMenus); // UI μ¦‰μ‹œ 반영 +``` + +--- + +## μž‘μ„± 정보 + +- **μž‘μ„±μΌ**: 2025-12-29 +- **μƒνƒœ**: βœ… 1단계 κ΅¬ν˜„ μ™„λ£Œ (ν…ŒμŠ€νŠΈ λŒ€κΈ°) +- **λ‹΄λ‹Ή**: ν”„λ‘ νŠΈμ—”λ“œ νŒ€ +- **λ°±μ—”λ“œ**: `GET /api/v1/menus` API 이미 쑴재 βœ… \ No newline at end of file diff --git a/claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md b/claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md new file mode 100644 index 00000000..c4dc0eb0 --- /dev/null +++ b/claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md @@ -0,0 +1,84 @@ +# ν’ˆμ§ˆμΈμ •μ‹¬μ‚¬ μ‹œμŠ€ν…œ κ΅¬ν˜„ 체크리슀트 + +> **경둜**: `src/app/[locale]/(protected)/dev/quality-inspection/` +> **μž‘μ—…μΌ**: 2025-12-29 +> **λ‹΄λ‹Ή**: 버디 +> **μƒνƒœ**: βœ… μ™„λ£Œ + +--- + +## Phase 1: μƒνƒœ 관리 κ΅¬ν˜„ βœ… + +- [x] 1.1 page.tsx에 ν•„ν„° μƒνƒœ μΆ”κ°€ (년도, λΆ„κΈ°, 검색어) +- [x] 1.2 selectedReport μƒνƒœ μΆ”κ°€ +- [x] 1.3 selectedRoute μƒνƒœ μΆ”κ°€ +- [x] 1.4 필터링 둜직 κ΅¬ν˜„ (useMemo) + +## Phase 2: μ»΄ν¬λ„ŒνŠΈ Props 연동 βœ… + +- [x] 2.1 ReportList.tsx - onSelect 콜백 μΆ”κ°€ +- [x] 2.2 RouteList.tsx - reports 데이터 + onSelect 콜백 μΆ”κ°€ +- [x] 2.3 DocumentList.tsx - route 데이터 연동 +- [x] 2.4 Filters.tsx - μƒνƒœ 콜백 연동 + +## Phase 3: Mock 데이터 톡합 βœ… + +- [x] 3.1 types.ts에 톡합 데이터 ꡬ쑰 μ •μ˜ +- [x] 3.2 mockData.ts 생성 (계측 ꡬ쑰 데이터) +- [x] 3.3 Report β†’ Route β†’ Document μ—°κ²° ꡬ쑰 + +## Phase 4: λ¬Έμ„œ λͺ¨λ‹¬ 연동 βœ… + +### κΈ°μ‘΄ λ¬Έμ„œ μ»΄ν¬λ„ŒνŠΈ (μž¬μ‚¬μš©) + +| λ¬Έμ„œ μ’…λ₯˜ | κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈ | μƒνƒœ | +|----------|--------------|------| +| μˆ˜μ£Όμ„œ | `orders/documents/OrderDocumentModal.tsx` | βœ… 있음 | +| μž‘μ—…μΌμ§€ | `production/WorkerScreen/WorkLogModal.tsx` | βœ… 있음 | +| λ‚©ν’ˆν™•μΈμ„œ | `outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx` | βœ… 있음 | +| 좜고증 | `outbound/ShipmentManagement/documents/ShippingSlip.tsx` | βœ… 있음 | + +### μ‹ κ·œ λ¬Έμ„œ (양식 ν•„μš”) + +| λ¬Έμ„œ μ’…λ₯˜ | μƒνƒœ | λΉ„κ³  | +|----------|------|------| +| μˆ˜μž…κ²€μ‚¬ μ„±μ μ„œ | ❌ 양식 ν•„μš” | λ””μžμΈ 파일 λŒ€κΈ° | +| 쀑간검사 μ„±μ μ„œ | ❌ 양식 ν•„μš” | λ””μžμΈ 파일 λŒ€κΈ° | +| μ œν’ˆκ²€μ‚¬ μ„±μ μ„œ | ❌ 양식 ν•„μš” | λ””μžμΈ 파일 λŒ€κΈ° | +| ν’ˆμ§ˆκ΄€λ¦¬μ„œ | ❌ 양식 ν•„μš” | λ””μžμΈ 파일 λŒ€κΈ° | + +### λͺ¨λ‹¬ 연동 μž‘μ—… + +- [x] 4.1 InspectionModalμ—μ„œ λ¬Έμ„œ νƒ€μž…λ³„ λΆ„κΈ° 처리 +- [x] 4.2 κΈ°μ‘΄ λ¬Έμ„œ μ»΄ν¬λ„ŒνŠΈ Placeholder ν‘œμ‹œ (연동 μ˜ˆμ • μ•ˆλ‚΄) +- [x] 4.3 μ‹ κ·œ λ¬Έμ„œλŠ” Placeholder ν‘œμ‹œ (양식 λŒ€κΈ°) + +## Phase 5: UI κ°œμ„  βœ… + +- [x] 5.1 PageLayout 적용 β†’ N/A (전체 높이 λŒ€μ‹œλ³΄λ“œ λ ˆμ΄μ•„μ›ƒμœΌλ‘œ 별도 처리) +- [x] 5.2 Filters.tsx λ―Έμ‚¬μš© import 정리 β†’ λ―Έμ‚¬μš© import μ—†μŒ 확인 +- [x] 5.3 λ°˜μ‘ν˜• λ ˆμ΄μ•„μ›ƒ 검증 β†’ grid-cols-12 + lg: λ°˜μ‘ν˜• 적용됨 + +--- + +## μ§„ν–‰ ν˜„ν™© + +| Phase | μƒνƒœ | μ™„λ£ŒμΌ | +|-------|------|--------| +| Phase 1 | βœ… μ™„λ£Œ | 2025-12-29 | +| Phase 2 | βœ… μ™„λ£Œ | 2025-12-29 | +| Phase 3 | βœ… μ™„λ£Œ | 2025-12-29 | +| Phase 4 | βœ… μ™„λ£Œ | 2025-12-29 | +| Phase 5 | βœ… μ™„λ£Œ | 2025-12-29 | + +--- + +## μ°Έκ³  파일 + +``` +src/components/orders/documents/OrderDocumentModal.tsx +src/components/production/WorkerScreen/WorkLogModal.tsx +src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx +src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx +src/components/process-management/ProcessWorkLogPreviewModal.tsx +``` diff --git a/claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md b/claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md new file mode 100644 index 00000000..82d4cc87 --- /dev/null +++ b/claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md @@ -0,0 +1,204 @@ +# Vercel 배포 κ°€μ΄λ“œ + +> μž‘μ„±μΌ: 2025-12-29 +> μƒνƒœ: πŸ”„ μ§„ν–‰ 쀑 +> λ‹΄λ‹Ή: + +--- + +## πŸ“‹ 배포 μ „ 체크리슀트 + +### ν”„λ‘œμ νŠΈ μƒνƒœ +- [x] λΉŒλ“œ ν…ŒμŠ€νŠΈ 성곡 +- [x] Node.js v20.x ν˜Έν™˜ 확인 +- [x] Next.js 15 + next-intl μ„€μ • μ™„λ£Œ +- [x] λ‹€κ΅­μ–΄ 지원 (ko/en/ja) + +### 배포 μ€€λΉ„ +- [ ] Vercel 계정 μ€€λΉ„ +- [ ] Git λ ˆν¬μ§€ν† λ¦¬ 연동 +- [ ] ν™˜κ²½ λ³€μˆ˜ μ„€μ • +- [ ] CORS μ„€μ • μš”μ²­ (λ°±μ—”λ“œ) +- [ ] 배포 μ™„λ£Œ +- [ ] ν…ŒμŠ€νŠΈ μ™„λ£Œ + +--- + +## 1단계: Vercel ν”„λ‘œμ νŠΈ 생성 + +### 1.1 Vercel 접속 +1. [vercel.com](https://vercel.com) 접속 +2. GitHub/GitLab κ³„μ •μœΌλ‘œ 둜그인 + +### 1.2 ν”„λ‘œμ νŠΈ 생성 +1. Dashboard β†’ **Add New** β†’ **Project** +2. Git λ ˆν¬μ§€ν† λ¦¬ 선택: `sam-react-prod` +3. Framework Preset: **Next.js** (μžλ™ 감지) +4. Root Directory: `.` (κΈ°λ³Έκ°’) + +--- + +## 2단계: ν™˜κ²½ λ³€μˆ˜ μ„€μ • + +### ν•„μˆ˜ ν™˜κ²½ λ³€μˆ˜ + +| λ³€μˆ˜λͺ… | κ°’ | ν™˜κ²½ | μ„€λͺ… | +|--------|-----|------|------| +| `NEXT_PUBLIC_API_URL` | `https://api.5130.co.kr` | All | λ°±μ—”λ“œ API URL | +| `NEXT_PUBLIC_FRONTEND_URL` | `(배포 ν›„ URL)` | Production | ν”„λ‘ νŠΈμ—”λ“œ URL | +| `NEXT_PUBLIC_AUTH_MODE` | `sanctum` | All | 인증 λͺ¨λ“œ | +| `API_KEY` | `(μ‹€μ œ ν‚€)` | All | μ„œλ²„μ‚¬μ΄λ“œ API ν‚€ | + +### μ„€μ • 방법 +1. Vercel Dashboard β†’ Settings β†’ Environment Variables +2. 각 λ³€μˆ˜ μΆ”κ°€ +3. Environment: Production / Preview / Development 선택 + +### ν™˜κ²½ λ³€μˆ˜ κ°’ λ©”λͺ¨ +``` +NEXT_PUBLIC_API_URL = +NEXT_PUBLIC_FRONTEND_URL = +NEXT_PUBLIC_AUTH_MODE = +API_KEY = +``` + +--- + +## 3단계: λ°±μ—”λ“œ CORS μ„€μ • + +### μš”μ²­ λ‚΄μš© +Vercel 배포 ν›„ 도메인을 λ°±μ—”λ“œνŒ€μ— μ „λ‹¬ν•˜μ—¬ CORS ν—ˆμš© μš”μ²­ + +``` +ν—ˆμš© μš”μ²­ 도메인: +- https://ν”„λ‘œμ νŠΈλͺ….vercel.app +- https://μ»€μŠ€ν…€λ„λ©”μΈ.com (μžˆλŠ” 경우) +``` + +### λ°±μ—”λ“œ μš”μ²­ λ©”λͺ¨ +``` +μš”μ²­μΌ: +μš”μ²­ 도메인: +처리 μƒνƒœ: [ ] λŒ€κΈ° / [ ] μ™„λ£Œ +``` + +--- + +## 4단계: 배포 μ‹€ν–‰ + +### 4.1 첫 배포 +1. ν™˜κ²½ λ³€μˆ˜ μ„€μ • μ™„λ£Œ 확인 +2. **Deploy** λ²„νŠΌ 클릭 +3. λΉŒλ“œ 둜그 λͺ¨λ‹ˆν„°λ§ + +### 4.2 배포 성곡 확인 +- [ ] λΉŒλ“œ 성곡 +- [ ] 배포 URL 생성 +- [ ] νŽ˜μ΄μ§€ λ‘œλ”© 확인 + +### 배포 정보 +``` +배포 URL: +배포 μ‹œκ°„: +λΉŒλ“œ μ‹œκ°„: +``` + +--- + +## 5단계: 배포 ν›„ ν…ŒμŠ€νŠΈ + +### 5.1 κΈ°λ³Έ ν…ŒμŠ€νŠΈ +- [ ] 메인 νŽ˜μ΄μ§€ λ‘œλ”© +- [ ] 둜그인 νŽ˜μ΄μ§€ μ ‘κ·Ό +- [ ] λ‹€κ΅­μ–΄ μ „ν™˜ (ko/en/ja) + +### 5.2 인증 ν…ŒμŠ€νŠΈ +- [ ] 둜그인 μ‹œλ„ +- [ ] 토큰 λ°œκΈ‰ 확인 +- [ ] λ‘œκ·Έμ•„μ›ƒ + +### 5.3 API 연동 ν…ŒμŠ€νŠΈ +- [ ] API 호좜 정상 +- [ ] CORS μ—λŸ¬ μ—†μŒ +- [ ] 데이터 λ‘œλ”© 확인 + +### 5.4 μ£Όμš” νŽ˜μ΄μ§€ ν…ŒμŠ€νŠΈ +- [ ] λŒ€μ‹œλ³΄λ“œ +- [ ] ν’ˆλͺ©κΈ°μ€€κ΄€λ¦¬ +- [ ] μ„€μ • νŽ˜μ΄μ§€ + +### ν…ŒμŠ€νŠΈ κ²°κ³Ό λ©”λͺ¨ +``` +ν…ŒμŠ€νŠΈμΌ: +발견된 이슈: +- + +ν•΄κ²° ν•„μš” 사항: +- +``` + +--- + +## 6단계: μ»€μŠ€ν…€ 도메인 (선택) + +### 6.1 도메인 μ—°κ²° +1. Vercel Dashboard β†’ Settings β†’ Domains +2. 도메인 μΆ”κ°€: `your-domain.com` +3. DNS μ„€μ • μ•ˆλ‚΄ 확인 + +### 6.2 DNS μ„€μ • +``` +Type: CNAME +Name: @ λ˜λŠ” www +Value: cname.vercel-dns.com +``` + +### 도메인 정보 +``` +도메인: +SSL μƒνƒœ: [ ] λŒ€κΈ° / [ ] ν™œμ„±ν™” +``` + +--- + +## νŠΈλŸ¬λΈ”μŠˆνŒ… + +### λΉŒλ“œ μ‹€νŒ¨ μ‹œ +```bash +# λ‘œμ»¬μ—μ„œ λΉŒλ“œ ν…ŒμŠ€νŠΈ +npm run build +``` + +### CORS μ—λŸ¬ μ‹œ +- λ°±μ—”λ“œ CORS μ„€μ • 확인 +- `NEXT_PUBLIC_FRONTEND_URL` κ°’ 확인 + +### ν™˜κ²½ λ³€μˆ˜ 미적용 μ‹œ +- Vercel Dashboardμ—μ„œ κ°’ 확인 +- 재배포 ν•„μš” (ν™˜κ²½ λ³€μˆ˜ λ³€κ²½ ν›„) + +### API μ—°κ²° μ‹€νŒ¨ μ‹œ +- `NEXT_PUBLIC_API_URL` 확인 +- `API_KEY` κ°’ 확인 +- λ„€νŠΈμ›Œν¬ νƒ­μ—μ„œ μš”μ²­/응닡 확인 + +--- + +## μ°Έκ³  자료 + +- [Vercel Next.js 배포 κ°€μ΄λ“œ](https://vercel.com/docs/frameworks/nextjs) +- [Next.js ν™˜κ²½ λ³€μˆ˜](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables) +- [Vercel μ»€μŠ€ν…€ 도메인](https://vercel.com/docs/projects/domains) + +--- + +## μž‘μ—… 둜그 + +### 2025-12-29 +- [ ] κ°€μ΄λ“œ λ¬Έμ„œ 생성 +- [ ] 배포 μ‹œμž‘ + +### μΆ”κ°€ λ©”λͺ¨ +``` +(여기에 μ§„ν–‰ν•˜λ©΄μ„œ λ©”λͺ¨ μΆ”κ°€) +``` \ No newline at end of file diff --git a/src/app/api/menus/route.ts b/src/app/api/menus/route.ts new file mode 100644 index 00000000..7ebbcbf7 --- /dev/null +++ b/src/app/api/menus/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * πŸ”΅ Next.js λ‚΄λΆ€ API - 메뉴 쑰회 ν”„λ‘μ‹œ (PHP λ°±μ—”λ“œλ‘œ 전달) + * + * ⚑ 섀계 λͺ©μ : + * - 동적 메뉴 κ°±μ‹ : 재둜그인 없이 메뉴 λͺ©λ‘ κ°±μ‹  + * - λ³΄μ•ˆ: HttpOnly μΏ ν‚€μ—μ„œ 토큰을 읽어 λ°±μ—”λ“œλ‘œ 전달 + * + * πŸ”„ λ™μž‘ 흐름: + * 1. ν΄λΌμ΄μ–ΈνŠΈ β†’ Next.js /api/menus + * 2. Next.js: HttpOnly μΏ ν‚€μ—μ„œ access_token 읽기 + * 3. Next.js β†’ PHP /api/v1/menus (메뉴 쑰회 μš”μ²­) + * 4. Next.js β†’ ν΄λΌμ΄μ–ΈνŠΈ (메뉴 λͺ©λ‘ 응닡) + * + * πŸ“Œ λ°±μ—”λ“œ API μš”μ²­ 사항: + * - μ—”λ“œν¬μΈνŠΈ: GET /api/v1/menus + * - 인증: Bearer 토큰 ν•„μš” + * - 응닡: { menus: [...] } (둜그인 μ‘λ‹΅μ˜ menus와 동일 ꡬ쑰) + * + * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md + */ +export async function GET(request: NextRequest) { + try { + // HttpOnly μΏ ν‚€μ—μ„œ access_token 읽기 + const accessToken = request.cookies.get('access_token')?.value; + + if (!accessToken) { + return NextResponse.json( + { error: 'Unauthorized', message: '인증 토큰이 μ—†μŠ΅λ‹ˆλ‹€' }, + { status: 401 } + ); + } + + // PHP λ°±μ—”λ“œ 메뉴 API 호좜 + const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/menus`; + + const response = await fetch(backendUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + 'X-API-KEY': process.env.API_KEY || '', + }, + }); + + if (!response.ok) { + // λ°±μ—”λ“œ μ—λŸ¬ 응닡 전달 + const errorData = await response.json().catch(() => ({})); + console.error('[Menu API] Backend error:', response.status, errorData); + + return NextResponse.json( + { + error: 'Backend Error', + message: errorData.message || '메뉴 μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€', + status: response.status + }, + { status: response.status } + ); + } + + const data = await response.json(); + + // λ°±μ—”λ“œ 응닡 ꡬ쑰: { data: [...] } (ApiResponse::handle ν‘œμ€€) + // λ˜λŠ” 둜그인 응닡과 λ™μΌν•œ { menus: [...] } ν˜•νƒœμΌ 수 있음 + const menus = data.data || data.menus || (Array.isArray(data) ? data : null); + + // 메뉴 데이터 검증 + if (!menus || !Array.isArray(menus)) { + console.error('[Menu API] Invalid response format:', data); + return NextResponse.json( + { error: 'Invalid Response', message: '메뉴 데이터 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€' }, + { status: 500 } + ); + } + + // 응닡 ꡬ쑰 톡일: { menus: [...] } + return NextResponse.json({ menus }, { status: 200 }); + + } catch (error) { + console.error('[Menu API] Proxy error:', error); + return NextResponse.json( + { + error: 'Internal Server Error', + message: error instanceof Error ? error.message : 'μ„œλ²„ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/hooks/useMenuPolling.ts b/src/hooks/useMenuPolling.ts new file mode 100644 index 00000000..24ae3540 --- /dev/null +++ b/src/hooks/useMenuPolling.ts @@ -0,0 +1,169 @@ +/** + * 메뉴 폴링 ν›… + * + * 일정 κ°„κ²©μœΌλ‘œ 메뉴 변경사항을 ν™•μΈν•˜κ³  μžλ™ κ°±μ‹ ν•©λ‹ˆλ‹€. + * + * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { refreshMenus } from '@/lib/utils/menuRefresh'; + +// κΈ°λ³Έ 폴링 간격: 30초 +const DEFAULT_POLLING_INTERVAL = 30 * 1000; + +// μ΅œμ†Œ 폴링 간격: 10초 (μ„œλ²„ λΆ€ν•˜ λ°©μ§€) +const MIN_POLLING_INTERVAL = 10 * 1000; + +// μ΅œλŒ€ 폴링 간격: 5λΆ„ +const MAX_POLLING_INTERVAL = 5 * 60 * 1000; + +interface UseMenuPollingOptions { + /** 폴링 ν™œμ„±ν™” μ—¬λΆ€ (κΈ°λ³Έ: true) */ + enabled?: boolean; + /** 폴링 간격 (ms, κΈ°λ³Έ: 30초) */ + interval?: number; + /** 메뉴 κ°±μ‹  μ‹œ 콜백 */ + onMenuUpdated?: () => void; + /** μ—λŸ¬ λ°œμƒ μ‹œ 콜백 */ + onError?: (error: string) => void; +} + +interface UseMenuPollingReturn { + /** μˆ˜λ™μœΌλ‘œ 메뉴 κ°±μ‹  μ‹€ν–‰ */ + refresh: () => Promise; + /** 폴링 μΌμ‹œ 쀑지 */ + pause: () => void; + /** 폴링 재개 */ + resume: () => void; + /** ν˜„μž¬ 폴링 μƒνƒœ */ + isPaused: boolean; +} + +/** + * 메뉴 폴링 ν›… + * + * @example + * ```tsx + * // κΈ°λ³Έ μ‚¬μš© + * useMenuPolling(); + * + * // μ˜΅μ…˜κ³Ό ν•¨κ»˜ μ‚¬μš© + * const { refresh, pause, resume } = useMenuPolling({ + * interval: 60000, // 1λΆ„λ§ˆλ‹€ + * onMenuUpdated: () => console.log('메뉴 μ—…λ°μ΄νŠΈλ¨!'), + * }); + * + * // μˆ˜λ™ κ°±μ‹  + * await refresh(); + * ``` + */ +export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn { + const { + enabled = true, + interval = DEFAULT_POLLING_INTERVAL, + onMenuUpdated, + onError, + } = options; + + // 폴링 간격 μœ νš¨μ„± 검사 + const safeInterval = Math.max(MIN_POLLING_INTERVAL, Math.min(MAX_POLLING_INTERVAL, interval)); + + const intervalRef = useRef(null); + const isPausedRef = useRef(false); + + // 메뉴 κ°±μ‹  μ‹€ν–‰ + const executeRefresh = useCallback(async () => { + if (isPausedRef.current) return; + + const result = await refreshMenus(); + + if (result.success && result.updated) { + onMenuUpdated?.(); + } + + if (!result.success && result.error) { + onError?.(result.error); + } + }, [onMenuUpdated, onError]); + + // μˆ˜λ™ κ°±μ‹  ν•¨μˆ˜ + const refresh = useCallback(async () => { + await executeRefresh(); + }, [executeRefresh]); + + // 폴링 μΌμ‹œ 쀑지 + const pause = useCallback(() => { + isPausedRef.current = true; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // 폴링 재개 + const resume = useCallback(() => { + isPausedRef.current = false; + if (enabled && !intervalRef.current) { + intervalRef.current = setInterval(executeRefresh, safeInterval); + } + }, [enabled, safeInterval, executeRefresh]); + + // 폴링 μ„€μ • + useEffect(() => { + if (!enabled) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + // νŽ˜μ΄μ§€ λ‘œλ“œ μ‹œ μ¦‰μ‹œ μ‹€ν–‰ν•˜μ§€ μ•ŠμŒ (둜그인 μ‹œ 이미 λ°›μ•„μ˜΄) + // 폴링 μ‹œμž‘ + intervalRef.current = setInterval(executeRefresh, safeInterval); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [enabled, safeInterval, executeRefresh]); + + // νƒ­ κ°€μ‹œμ„± λ³€κ²½ μ‹œ 처리 + useEffect(() => { + if (!enabled) return; + + const handleVisibilityChange = () => { + if (document.hidden) { + // 탭이 μˆ¨κ²¨μ§€λ©΄ 폴링 쀑지 (λ¦¬μ†ŒμŠ€ μ ˆμ•½) + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } else { + // 탭이 λ‹€μ‹œ 보이면 μ¦‰μ‹œ κ°±μ‹  ν›„ 폴링 재개 + if (!isPausedRef.current) { + executeRefresh(); + intervalRef.current = setInterval(executeRefresh, safeInterval); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, safeInterval, executeRefresh]); + + return { + refresh, + pause, + resume, + isPaused: isPausedRef.current, + }; +} + +export default useMenuPolling; \ No newline at end of file diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 82848935..6705c992 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -37,6 +37,7 @@ import Sidebar from '@/components/layout/Sidebar'; import { useTheme } from '@/contexts/ThemeContext'; import { useAuth } from '@/contexts/AuthContext'; import { deserializeMenuItems } from '@/lib/utils/menuTransform'; +import { useMenuPolling } from '@/hooks/useMenuPolling'; interface AuthenticatedLayoutProps { children: React.ReactNode; @@ -60,6 +61,16 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro const [userName, setUserName] = useState("μ‚¬μš©μž"); const [userPosition, setUserPosition] = useState("직책"); + // 메뉴 폴링 (30μ΄ˆλ§ˆλ‹€ 메뉴 λ³€κ²½ 확인) + // λ°±μ—”λ“œ GET /api/v1/menus API μ€€λΉ„λ˜λ©΄ μžλ™ λ™μž‘ + useMenuPolling({ + enabled: true, + interval: 30000, // 30초 + onMenuUpdated: () => { + console.log('[Menu] 메뉴가 μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€'); + }, + }); + // λͺ¨λ°”일 감지 useEffect(() => { const checkScreenSize = () => { diff --git a/src/lib/utils/menuRefresh.ts b/src/lib/utils/menuRefresh.ts new file mode 100644 index 00000000..b5cb7e17 --- /dev/null +++ b/src/lib/utils/menuRefresh.ts @@ -0,0 +1,189 @@ +/** + * 메뉴 동적 κ°±μ‹  μœ ν‹Έλ¦¬ν‹° + * + * κ΄€λ¦¬μžκ°€ κ²Œμ‹œνŒ/메뉴 μΆ”κ°€ μ‹œ 재둜그인 없이 메뉴λ₯Ό κ°±μ‹ ν•©λ‹ˆλ‹€. + * + * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md + */ + +import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform'; +import { useMenuStore } from '@/store/menuStore'; +import type { SerializableMenuItem } from '@/store/menuStore'; + +/** + * 메뉴 ν•΄μ‹œ 생성 (λ³€κ²½ κ°μ§€μš©) + * 메뉴 ID듀을 μ •λ ¬ν•˜μ—¬ ν•΄μ‹œ 생성 + */ +function generateMenuHash(menus: SerializableMenuItem[]): string { + const collectIds = (items: SerializableMenuItem[]): string[] => { + return items.flatMap(item => [ + item.id, + ...(item.children ? collectIds(item.children) : []) + ]); + }; + + return collectIds(menus).sort().join(','); +} + +/** + * ν˜„μž¬ μ €μž₯된 메뉴 ν•΄μ‹œ κ°€μ Έμ˜€κΈ° + */ +export function getCurrentMenuHash(): string { + if (typeof window === 'undefined') return ''; + + try { + const userData = localStorage.getItem('user'); + if (!userData) return ''; + + const parsed = JSON.parse(userData); + if (!parsed.menu || !Array.isArray(parsed.menu)) return ''; + + return generateMenuHash(parsed.menu); + } catch { + return ''; + } +} + +/** + * 메뉴 κ°±μ‹  κ²°κ³Ό νƒ€μž… + */ +interface RefreshMenuResult { + success: boolean; + updated: boolean; // μ‹€μ œλ‘œ 메뉴가 λ³€κ²½λ˜μ—ˆλŠ”μ§€ + error?: string; +} + +/** + * 메뉴 κ°±μ‹  ν•¨μˆ˜ + * + * 1. APIμ—μ„œ μƒˆ 메뉴 λ°›μ•„μ˜€κΈ° + * 2. κΈ°μ‘΄ 메뉴와 비ꡐ (ν•΄μ‹œ) + * 3. λ³€κ²½ μ‹œ localStorage + Zustand μ—…λ°μ΄νŠΈ + * + * @returns κ°±μ‹  κ²°κ³Ό + */ +export async function refreshMenus(): Promise { + try { + // 1. ν˜„μž¬ 메뉴 ν•΄μ‹œ μ €μž₯ + const currentHash = getCurrentMenuHash(); + + // 2. APIμ—μ„œ μƒˆ 메뉴 λ°›μ•„μ˜€κΈ° + const response = await fetch('/api/menus', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + // 401 λ“± 인증 였λ₯˜λŠ” 쑰용히 μ‹€νŒ¨ (λ‘œκ·Έμ•„μ›ƒ μƒνƒœμΌ 수 있음) + if (response.status === 401) { + return { success: false, updated: false }; + } + return { + success: false, + updated: false, + error: `API 였λ₯˜: ${response.status}` + }; + } + + const data = await response.json(); + + if (!data.menus || !Array.isArray(data.menus)) { + return { + success: false, + updated: false, + error: '메뉴 데이터 ν˜•μ‹ 였λ₯˜' + }; + } + + // 3. 메뉴 λ³€ν™˜ + const transformedMenus = transformApiMenusToMenuItems(data.menus); + const newHash = generateMenuHash(transformedMenus); + + // 4. λ³€κ²½ μ—†μœΌλ©΄ μ—…λ°μ΄νŠΈ μŠ€ν‚΅ + if (currentHash === newHash) { + return { success: true, updated: false }; + } + + // 5. localStorage μ—…λ°μ΄νŠΈ (μƒˆλ‘œκ³ μΉ¨ λŒ€μ‘) + const userData = localStorage.getItem('user'); + if (userData) { + const parsed = JSON.parse(userData); + parsed.menu = transformedMenus; + localStorage.setItem('user', JSON.stringify(parsed)); + } + + // 6. Zustand μŠ€ν† μ–΄ μ—…λ°μ΄νŠΈ (UI μ¦‰μ‹œ 반영) + const { setMenuItems } = useMenuStore.getState(); + setMenuItems(deserializeMenuItems(transformedMenus)); + + console.log('[Menu] 메뉴 κ°±μ‹  μ™„λ£Œ - λ³€κ²½ 감지됨'); + return { success: true, updated: true }; + + } catch (error) { + console.error('[Menu] 메뉴 κ°±μ‹  μ‹€νŒ¨:', error); + return { + success: false, + updated: false, + error: error instanceof Error ? error.message : 'μ•Œ 수 μ—†λŠ” 였λ₯˜' + }; + } +} + +/** + * 메뉴 κ°•μ œ κ°±μ‹  (비ꡐ 없이 무쑰건 κ°±μ‹ ) + */ +export async function forceRefreshMenus(): Promise { + try { + const response = await fetch('/api/menus', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return { + success: false, + updated: false, + error: `API 였λ₯˜: ${response.status}` + }; + } + + const data = await response.json(); + + if (!data.menus || !Array.isArray(data.menus)) { + return { + success: false, + updated: false, + error: '메뉴 데이터 ν˜•μ‹ 였λ₯˜' + }; + } + + const transformedMenus = transformApiMenusToMenuItems(data.menus); + + // localStorage μ—…λ°μ΄νŠΈ + const userData = localStorage.getItem('user'); + if (userData) { + const parsed = JSON.parse(userData); + parsed.menu = transformedMenus; + localStorage.setItem('user', JSON.stringify(parsed)); + } + + // Zustand μŠ€ν† μ–΄ μ—…λ°μ΄νŠΈ + const { setMenuItems } = useMenuStore.getState(); + setMenuItems(deserializeMenuItems(transformedMenus)); + + console.log('[Menu] 메뉴 κ°•μ œ κ°±μ‹  μ™„λ£Œ'); + return { success: true, updated: true }; + + } catch (error) { + console.error('[Menu] 메뉴 κ°•μ œ κ°±μ‹  μ‹€νŒ¨:', error); + return { + success: false, + updated: false, + error: error instanceof Error ? error.message : 'μ•Œ 수 μ—†λŠ” 였λ₯˜' + }; + } +} \ No newline at end of file