From 07374c826c965ce4b4bb2d2dd0d54bdc4b84a8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 23 Feb 2026 17:17:13 +0900 Subject: [PATCH] =?UTF-8?q?refactor(WEB):=20claudedocs=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20AuthContext/Zustand/=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claudedocs 폴더 구조 재정리: archive/sessions, guides/migration·mobile·universal-list, refactoring 분류 - 오래된 세션 컨텍스트/체크리스트 문서 정리 (아카이브 이동 또는 삭제) - AuthContext → authStore(Zustand) 전환 시작, RootProvider 간소화 - GenericCRUDDialog 공통 다이얼로그 컴포넌트 추가 - PermissionDialog 삭제 → GenericCRUDDialog로 대체 - RankDialog/TitleDialog GenericCRUDDialog 기반으로 리팩토링 - toast-utils.ts 삭제 (미사용) - fileDownload.ts 개선, excel-download.ts 정리 - menuStore/themeStore Zustand 셀렉터 최적화 - useColumnSettings/useTableColumnStore 기능 보강 - 세금계산서/견적/작업자화면/결재 등 소규모 개선 Co-Authored-By: Claude Opus 4.6 --- claudedocs/_index.md | 706 ++---------------- ...26-02-19] frontend-comprehensive-review.md | 0 ...3] deep-analysis-util-component-zustand.md | 396 ++++++++++ ...L-2026-02-23] phase1-item4-error-format.md | 112 +++ ...6-02-23] phase1-item5-zustand-selectors.md | 229 ++++++ ...025-02-10] frontend-improvement-roadmap.md | 0 .../architecture/[REF] technical-decisions.md | 316 ++++++++ ...25-11-26] item-master-api-pending-tasks.md | 0 ...-11-26] item-master-pending-integration.md | 0 ...T-2025-12-06] item-crud-session-context.md | 0 ...NEXT-2025-12-09] client-session-context.md | 0 ...T-2025-12-09] item-crud-session-context.md | 0 ...T-2025-12-10] item-crud-session-context.md | 0 ...T-2025-12-12] item-crud-session-context.md | 0 ...12-13] item-file-upload-session-context.md | 0 ...20] zustand-refactoring-session-context.md | 0 ...-2025-12-22] production-session-context.md | 0 ...-12-24] item-master-refactoring-session.md | 0 ...25-12-30] fetch-wrapper-session-context.md | 0 ...-30] partner-management-session-context.md | 0 ...-2026-02-23] E2E-remaining-bugs-handoff.md | 0 ...EW] MD-to-PPTX 자동화 파이프라인 검토서.md | 0 ... common-component-extraction-candidates.md | 0 .../[ANALYSIS] common-component-patterns.md | 0 ... list-page-ui-standardization-checklist.md | 0 ...1-21] utility-input-migration-checklist.md | 0 ...2026-01-23] button-navigation-checklist.md | 0 ...PL-2026-01-23] mode-migration-checklist.md | 0 ...6-01-23] mode-navigation-full-checklist.md | 0 ...6-02-06] datepicker-migration-checklist.md | 0 ...UniversalListPage-pilot-session-context.md | 0 ...12-23] common-component-extraction-plan.md | 0 ...to-client-component-migration-checklist.md | 0 .../[FIX-2026-02-04] mobile-zoom-panning.md | 0 .../[GUIDE] foldable-device-layout-fix.md | 0 .../[GUIDE] mobile-responsive-patterns.md | 0 ...1-13] mobile-filter-migration-checklist.md | 0 ...2026-01-20] mobile-card-infinity-scroll.md | 0 .../[PLAN] mobile-overflow-testing.md | 0 ...1-21] mobile-infinity-scroll-inspection.md | 0 .../[REF] mobile-zoom-fix-guide.md | 0 .../[REF] mobile-zoom-prevention-guide.md | 0 .../UniversalListPage-검색기능-수정내역.md | 0 ...iversalListPage-검색리렌더링-해결가이드.md | 0 ...GN-2026-01-14] universal-list-component.md | 0 ...-14] universal-list-component-checklist.md | 0 .../[PLAN] universal-detail-component.md | 0 ...6-01-15] universal-list-page-inspection.md | 0 .../[REF] UniversalListPage-QA-patterns.md | 0 ...6-02-19] frontend-improvement-checklist.md | 0 ...-19] code-dedup-commonization-checklist.md | 0 src/components/ThemeSelect.tsx | 5 +- .../accounting/TaxInvoiceManagement/index.tsx | 13 +- .../approval/DocumentCreate/index.tsx | 4 +- .../estimates/EstimateDetailForm.tsx | 4 +- src/components/layout/CommandMenuSearch.tsx | 4 +- .../molecules/GenericCRUDDialog.tsx | 171 +++++ src/components/molecules/index.ts | 5 +- .../production/WorkerScreen/index.tsx | 4 +- .../PermissionManagement/PermissionDialog.tsx | 109 --- .../settings/RankManagement/RankDialog.tsx | 90 +-- .../settings/TitleManagement/TitleDialog.tsx | 91 +-- src/contexts/AuthContext.tsx | 282 +------ src/contexts/ItemMasterContext.tsx | 4 +- src/contexts/RootProvider.tsx | 17 +- src/hooks/useColumnSettings.ts | 20 +- src/layouts/AuthenticatedLayout.tsx | 19 +- src/lib/api/toast-utils.ts | 116 --- src/lib/auth/logout.ts | 4 + src/lib/utils/excel-download.ts | 9 +- src/lib/utils/fileDownload.ts | 57 +- src/stores/authStore.ts | 247 ++++++ src/stores/menuStore.ts | 20 +- src/stores/themeStore.ts | 12 +- src/stores/useTableColumnStore.ts | 14 + 75 files changed, 1704 insertions(+), 1376 deletions(-) rename claudedocs/{ => architecture}/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md (100%) create mode 100644 claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md create mode 100644 claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md create mode 100644 claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md rename claudedocs/{ => architecture}/[PLAN-2025-02-10] frontend-improvement-roadmap.md (100%) create mode 100644 claudedocs/architecture/[REF] technical-decisions.md rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-11-26] item-master-api-pending-tasks.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-11-26] item-master-pending-integration.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-12-06] item-crud-session-context.md (100%) rename claudedocs/{sales => archive/sessions}/[NEXT-2025-12-09] client-session-context.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-12-09] item-crud-session-context.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-12-10] item-crud-session-context.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-12-12] item-crud-session-context.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-12-13] item-file-upload-session-context.md (100%) rename claudedocs/{architecture => archive/sessions}/[NEXT-2025-12-20] zustand-refactoring-session-context.md (100%) rename claudedocs/{production => archive/sessions}/[NEXT-2025-12-22] production-session-context.md (100%) rename claudedocs/{item-master => archive/sessions}/[NEXT-2025-12-24] item-master-refactoring-session.md (100%) rename claudedocs/{api => archive/sessions}/[NEXT-2025-12-30] fetch-wrapper-session-context.md (100%) rename claudedocs/{construction => archive/sessions}/[NEXT-2025-12-30] partner-management-session-context.md (100%) rename claudedocs/{ => dev}/[REPORT-2026-02-23] E2E-remaining-bugs-handoff.md (100%) rename claudedocs/{ => dev}/[REVIEW] MD-to-PPTX 자동화 파이프라인 검토서.md (100%) rename claudedocs/guides/{ => migration}/[ANALYSIS-2025-12-23] common-component-extraction-candidates.md (100%) rename claudedocs/guides/{ => migration}/[ANALYSIS] common-component-patterns.md (100%) rename claudedocs/guides/{ => migration}/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md (100%) rename claudedocs/guides/{ => migration}/[IMPL-2026-01-21] utility-input-migration-checklist.md (100%) rename claudedocs/guides/{ => migration}/[IMPL-2026-01-23] button-navigation-checklist.md (100%) rename claudedocs/guides/{ => migration}/[IMPL-2026-01-23] mode-migration-checklist.md (100%) rename claudedocs/guides/{ => migration}/[IMPL-2026-01-23] mode-navigation-full-checklist.md (100%) rename claudedocs/guides/{ => migration}/[IMPL-2026-02-06] datepicker-migration-checklist.md (100%) rename claudedocs/guides/{ => migration}/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md (100%) rename claudedocs/guides/{ => migration}/[PLAN-2025-12-23] common-component-extraction-plan.md (100%) rename claudedocs/guides/{ => migration}/[REF-2026-01-09] server-to-client-component-migration-checklist.md (100%) rename claudedocs/guides/{ => mobile}/[FIX-2026-02-04] mobile-zoom-panning.md (100%) rename claudedocs/guides/{ => mobile}/[GUIDE] foldable-device-layout-fix.md (100%) rename claudedocs/guides/{ => mobile}/[GUIDE] mobile-responsive-patterns.md (100%) rename claudedocs/guides/{ => mobile}/[IMPL-2026-01-13] mobile-filter-migration-checklist.md (100%) rename claudedocs/guides/{ => mobile}/[PLAN-2026-01-20] mobile-card-infinity-scroll.md (100%) rename claudedocs/guides/{ => mobile}/[PLAN] mobile-overflow-testing.md (100%) rename claudedocs/guides/{ => mobile}/[QA-2026-01-21] mobile-infinity-scroll-inspection.md (100%) rename claudedocs/guides/{ => mobile}/[REF] mobile-zoom-fix-guide.md (100%) rename claudedocs/guides/{ => mobile}/[REF] mobile-zoom-prevention-guide.md (100%) rename claudedocs/guides/{ => universal-list}/UniversalListPage-검색기능-수정내역.md (100%) rename claudedocs/guides/{ => universal-list}/UniversalListPage-검색리렌더링-해결가이드.md (100%) rename claudedocs/guides/{ => universal-list}/[DESIGN-2026-01-14] universal-list-component.md (100%) rename claudedocs/guides/{ => universal-list}/[IMPL-2026-01-14] universal-list-component-checklist.md (100%) rename claudedocs/guides/{ => universal-list}/[PLAN] universal-detail-component.md (100%) rename claudedocs/guides/{ => universal-list}/[QA-2026-01-15] universal-list-page-inspection.md (100%) rename claudedocs/guides/{ => universal-list}/[REF] UniversalListPage-QA-patterns.md (100%) rename claudedocs/{ => refactoring}/[IMPL-2026-02-19] frontend-improvement-checklist.md (100%) rename claudedocs/{ => refactoring}/[REF-2026-02-19] code-dedup-commonization-checklist.md (100%) create mode 100644 src/components/molecules/GenericCRUDDialog.tsx delete mode 100644 src/components/settings/PermissionManagement/PermissionDialog.tsx delete mode 100644 src/lib/api/toast-utils.ts create mode 100644 src/stores/authStore.ts diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 12996d70..2517fe6d 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,327 +1,14 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-19) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-23) ## 빠른 참조 | 문서 | 설명 | |------|------| | **[`[REF] all-pages-test-urls.md`](./dev/[REF]%20all-pages-test-urls.md)** | 전체 페이지 테스트 URL 목록 | - ---- - -## 프로젝트 기술 결정 사항 - -### `` 태그 사용 — `next/image` 미사용 이유 (2026-02-10) - -**현황**: 프로젝트 전체 `` 태그 10건, `next/image` 0건 - -**결정**: `` 유지, `next/image` 전환 불필요 - -**근거**: -1. **폐쇄형 ERP 시스템** — SEO 불필요, LCP 점수 무의미 -2. **전량 외부 동적 이미지** — 백엔드 API에서 받아오는 URL (정적 내부 이미지 0건) -3. **프린트/문서 레이아웃** — 10건 중 8건이 검사 기준서·도해 등 인쇄용. `next/image`의 `width`/`height` 강제 지정이 프린트 레이아웃을 깰 위험 -4. **blob URL 비호환** — 업로드 미리보기(blob:)는 `next/image`가 지원 안 함 -5. **설정 부담 > 이점** — `remotePatterns` 설정 + 백엔드 도메인 관리 비용이 실질 이점보다 큼 - -### 모바일 헤더 `backdrop-filter` 깜빡임 수정 (2026-02-11) - -**현상**: 모바일(Safari/Chrome)에서 sticky 헤더가 스크롤 시 투명↔불투명 깜빡임 발생. PC 브라우저 축소로는 재현 불가, 실제 모바일 기기에서만 발생. - -**원인 2가지**: -1. `globals.css`에 `* { transition: all 0.2s }` — 전체 요소의 모든 CSS 속성에 전역 transition. 모바일 스크롤 리페인트 시 background/opacity가 매번 애니메이션 -2. 모바일 헤더의 `clean-glass` 클래스: `backdrop-filter: blur(8px)` + `background: rgba(255,255,255, 0.95)` 조합이 모바일 sticky 요소에서 GPU 컴포지팅 충돌 - -**수정**: -- `globals.css`: `*` 전역 transition → `button, a, input, select, textarea, [role]` 인터랙티브 요소만, `transition: all` → `color, background-color, border-color, box-shadow` 속성만 -- 모바일 헤더: `clean-glass` (반투명+blur) → `bg-background border border-border` (불투명 배경) - -**교훈**: -- `transition: all`은 절대 `*`에 걸지 않기. 모바일 성능 저하 + 의도치 않은 애니메이션 발생 -- `backdrop-filter: blur()` + `sticky` 조합은 모바일 브라우저 고질적 리페인트 버그. 모바일 헤더는 불투명 배경 사용 -- 0.95 투명도는 육안 구분 불가 → 불투명 처리해도 시각적 차이 없음 - -**사용처 (9개 파일)**: -| 파일 | 용도 | 이미지 소스 | -|------|------|-------------| -| `DocumentHeader.tsx` (2건) | 문서 헤더 로고 | `logo.imageUrl` (API) | -| `ProductInspectionInputModal.tsx` | 제품검사 사진 미리보기 | blob URL | -| `ProductInspectionDocument.tsx` | 제품검사 문서 | `data.productImage` (API) | -| `inspection-shared.tsx` | 검사 기준서 이미지 | `standardImage` (API) | -| `SlatInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `ScreenInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `BendingInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `SlatJointBarInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | -| `BendingWipInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | - -**참고**: `next/image`가 유효한 케이스는 공개 사이트 + 정적/내부 이미지 + SEO 중요한 상황 - -### `next/dynamic` 코드 스플리팅 적용 (2026-02-10) - -**결정**: 대형 컴포넌트 + 무거운 라이브러리에 `next/dynamic` / 동적 `import()` 적용 - -**핵심 개념 — Suspense vs dynamic()**: -- **`Suspense` + 정적 import** → 코드가 부모와 같은 번들 청크에 포함. 유저가 안 봐도 이미 다운로드됨. UI fallback만 제공하고 **코드 분할은 안 일어남** -- **`dynamic()`** → webpack이 별도 `.js` 청크로 분리. 컴포넌트가 실제 렌더될 때만 네트워크 요청으로 해당 청크 다운로드. **진짜 코드 분할** - -**적용 내역**: - -| 파일 | 대상 | 절감 | -|------|------|------| -| `reports/comprehensive-analysis/page.tsx` | MainDashboard (2,651줄 + recharts) | ~350KB | -| `components/business/Dashboard.tsx` | CEODashboard | ~200KB | -| `construction/ConstructionDashboard.tsx` | ConstructionMainDashboard | ~100KB | -| `production/dashboard/page.tsx` | ProductionDashboard | ~100KB | -| `lib/utils/excel-download.ts` | xlsx 라이브러리 (~400KB) | ~400KB | -| `quotes/LocationListPanel.tsx` | xlsx 직접 import 제거 | (위와 중복) | - -**xlsx 동적 로드 패턴**: -```typescript -// Before: 모든 페이지에 xlsx ~400KB 포함 -import * as XLSX from 'xlsx'; - -// After: 엑셀 버튼 클릭 시에만 로드 -async function loadXLSX() { - return await import('xlsx'); -} -export async function downloadExcel(...) { - const XLSX = await loadXLSX(); - // ... -} -``` - -**총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시) - -### 테이블 가상화 (react-window) — 보류 (2026-02-10) - -**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 - -**근거**: -1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 ``은 브라우저가 문제없이 처리 -2. **적용 복잡도 높음** — 테이블 헤더 고정, 체크박스 선택, `rowSpan/colSpan` 병합 등 기존 기능과 충돌 가능. DataTable + IntegratedListTemplateV2 + UniversalListPage 전부 수정 필요 -3. **YAGNI** — 500건 이상 한 번에 렌더링하는 페이지가 현재 없음 - -**도입 시점**: 한 페이지에 200건+ 데이터를 페이지네이션 없이 표시해야 하는 요구가 생길 때 - -### SWR / React Query — 보류 (2026-02-10) - -**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 - -**근거**: -1. **기존 패턴 안정화 완료** — `useEffect` + Server Action 호출 패턴이 전 페이지에 일관 적용됨 -2. **캐싱 니즈 낮음** — 폐쇄형 ERP 특성상 항상 최신 데이터 필요. stale 데이터 표시는 오히려 위험 -3. **마스터데이터 캐싱 구현됨** — Zustand (`stores/masterDataStore`)로 변경 빈도 낮은 데이터는 이미 캐싱 중 -4. **도입 비용 과다** — 수십 개 페이지 `useState`+`useEffect` 패턴 전면 리팩토링 + 팀 학습 비용 - -**도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때 - -### 컴포넌트 레지스트리 관계도 (2026-02-12) - -**구현**: `/dev/component-registry` 페이지에 관계도(카드형 플로우) 뷰 추가 - -**구성**: -- `actions.ts` — `extractComponentImports()` + `buildRelationships()`로 import 관계 양방향 파싱 (imports/usedBy) -- `ComponentRelationshipView.tsx` — 3칼럼 카드형 플로우 (사용처 → 선택 컴포넌트 → 구성요소) -- `ComponentRegistryClient.tsx` — 목록/관계도 뷰 토글 - -**활용 규칙** (CLAUDE.md에 추가됨): -- 새 컴포넌트 생성 전 → 목록에서 중복 검색 + 관계도에서 조합 패턴 확인 -- 기존 컴포넌트 수정 시 → usedBy로 영향 범위 파악 - -### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10) - -**결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용** - -**현황**: -- `src/lib/api/create-crud-service.ts` (177줄) — CRUD 보일러플레이트 자동 생성 팩토리 -- 현재 사용 중: TitleManagement, RankManagement (2개) -- 전환 가능: 15~20개 / 전환 불가 (커스텀 로직): 50+개 - -**규칙**: -- 신규 도메인 추가 시 단순 CRUD → `createCrudService` 사용 필수 -- 기존 actions.ts는 잘 동작하므로 무리하게 전환하지 않음 -- 커스텀 비즈니스 로직이 있는 도메인(견적, 수주, 생산 등)은 팩토리 비적합 - -**사용 예시**: -```typescript -import { createCrudService } from '@/lib/api/create-crud-service'; - -const service = createCrudService({ - basePath: '/api/v1/resources', - transform: (api) => ({ id: api.id, name: api.name }), - entityName: '리소스', -}); - -export const getList = service.getList; -export const getById = service.getById; -export const create = service.create; -export const update = service.update; -export const remove = service.remove; -``` - -**미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음 - -### Server Action 공통 유틸리티 — 전체 마이그레이션 완료 (2026-02-12) - -**결정**: `buildApiUrl()` 전체 43개 actions.ts에 적용 완료 - -**배경**: -- 89개 actions.ts 중 43개에서 동일한 URLSearchParams 조건부 `.set()` 패턴 반복 (326+ 건) -- 50+ 파일에서 `current_page → currentPage` 수동 변환 반복 -- `toPaginationMeta`가 `src/lib/api/types.ts`에 존재하나 import 0건 - -**생성된 유틸리티**: -1. `src/lib/api/query-params.ts` — `buildQueryParams()`, `buildApiUrl()`: URLSearchParams 보일러플레이트 제거 -2. `src/lib/api/execute-paginated-action.ts` — `executePaginatedAction()`: 페이지네이션 조회 패턴 통합 (내부에서 `toPaginationMeta` 사용) - -**마이그레이션 결과** (2026-02-12): -- `new URLSearchParams` 사용: 326건 → **0건** (actions.ts 기준) -- `const API_URL = process.env.NEXT_PUBLIC_API_URL` 선언: 43개 → **0개** (마이그레이션 대상 파일) -- `buildApiUrl()` import: 43개 actions.ts 전체 적용 -- 3가지 API_URL 패턴 통합: 표준(`process.env`), `/api` 접미사(HR), `API_BASE` 전체경로(품질) → 모두 `buildApiUrl('/api/v1/...')` 통일 - -**`executePaginatedAction` 마이그레이션** (2026-02-12): -- 14개 actions.ts에서 페이지네이션 목록 조회 함수를 `executePaginatedAction`으로 전환 -- Wave A (accounting 9개): BillManagement, DepositManagement, SalesManagement, PurchaseManagement, WithdrawalManagement, VendorLedger, CardTransactionInquiry, BankTransactionInquiry, ExpectedExpenseManagement -- Wave B (5개): PaymentHistoryManagement, StockStatus, ReceivingManagement, ShipmentManagement, quotes -- 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조) -- 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가) -- 제거된 보일러플레이트: `DEFAULT_PAGINATION`, `FrontendPagination`/`PaginationMeta` 로컬 인터페이스, `PaginatedApiResponse` import, 수동 transform+pagination 조립 -- **화면 검수 완료** (4개 페이지): Bills, StockStatus, Quotes, Shipments — 전체 PASS -- **버그 발견/수정**: `quotes/actions.ts`에서 `export type { PaginationMeta }` re-export가 Turbopack 런타임 에러 유발 (`tsc`로 미감지) → re-export 제거, 컴포넌트에서 `@/lib/api/types` 직접 import로 변경 - -### `'use server'` 파일 타입 export 제한 (2026-02-12) - -**발견 배경**: `executePaginatedAction` 마이그레이션 화면 검수 중 견적관리 페이지 빌드 에러 - -**제한 사항**: -- `'use server'` 파일에서는 **async 함수만 export 가능** (Next.js Turbopack 제한) -- `export type { X } from '...'` (re-export) → **런타임 에러 발생** -- `export interface X { ... }` / `export type X = ...` (인라인 정의) → **문제 없음** (컴파일 시 제거) -- `tsc --noEmit`으로는 감지 불가 — Next.js 전용 규칙이므로 실제 페이지 접속(Turbopack)에서만 발생 - -**현재 상태**: 전체 81개 `'use server'` 파일 점검 완료, re-export 패턴 0건 (수정된 1건 포함) - -**buildApiUrl 마이그레이션 전략**: -- Wave A: 1건짜리 단순 파일 20개 -- Wave B: 2건짜리 파일 12개 (quotes, WorkOrders, orders 등 대형 파일 포함) -- Wave C: 3건 이상 파일 12개 (VendorLedger 5건, ReceivingManagement 5건, ProcessManagement 19건 URL 등) - -**효과**: -- 페이지네이션 조회 코드: ~20줄 → ~5줄 -- `DEFAULT_PAGINATION` 중앙화 (`execute-paginated-action.ts` 내부) -- `toPaginationMeta` 자동 활용 (직접 import 불필요) -- URL 빌딩 패턴 완전 일관화 (undefined/null/'' 자동 필터링, boolean/number 자동 변환) - -### KST 안전 날짜 유틸리티 — `toISOString` 사용 금지 (2026-02-19) - -**현황**: `new Date().toISOString().split('T')[0]` — 15개 파일 26곳에서 사용 중이었음 - -**문제**: `toISOString()`은 **UTC 기준**으로 변환. 한국(KST, UTC+9)에서 오전 9시 이전에 실행하면 **전날 날짜** 반환 -``` -// 2026-02-19 08:30 KST → UTC는 2026-02-18 23:30 -new Date().toISOString().split('T')[0] // "2026-02-18" ← 잘못됨 -``` - -**결정**: KST 안전 유틸리티 함수로 전량 교체, 직접 `toISOString` 사용 금지 - -**유틸리티** (`src/lib/utils/date.ts`): -| 함수 | 용도 | 대체 대상 | -|------|------|-----------| -| `getTodayString()` | 오늘 날짜 문자열 | `new Date().toISOString().split('T')[0]` | -| `getLocalDateString(date)` | 임의 Date 객체 문자열 | `someDate.toISOString().split('T')[0]` | - -**사용 규칙**: -```typescript -// ✅ 올바른 패턴 -import { getTodayString, getLocalDateString } from '@/lib/utils/date'; -const today = getTodayString(); // "2026-02-19" -const thirtyDaysAgo = getLocalDateString(pastDate); // "2026-01-20" - -// ❌ 금지 패턴 -const today = new Date().toISOString().split('T')[0]; -``` - -**현재 상태**: `src/` 내 `toISOString().split` 사용 0건 (date.ts 내 구현부 제외) - -### 달력/스케줄 공통 리소스 — 작업 전 필수 확인 (2026-02-23) - -달력·일정·날짜 관련 작업 시 아래 공통 리소스를 **반드시 확인**하고 사용할 것. - -**날짜 유틸리티** (`src/lib/utils/date.ts`): -| 함수 | 용도 | -|------|------| -| `getLocalDateString(date)` | Date → `'YYYY-MM-DD'` (KST 안전) | -| `getTodayString()` | 오늘 날짜 문자열 | -| `formatDate(dateStr)` | 표시용 날짜 포맷 (null → `'-'`) | -| `formatDateForInput(dateStr)` | input용 `YYYY-MM-DD` 변환 | -| `formatDateRange(start, end)` | `'시작 ~ 종료'` 포맷 | -| `getDateAfterDays(n)` | N일 후 날짜 | - -**달력 일정 스토어** (`src/stores/useCalendarScheduleStore.ts`): -- 달력관리(CalendarManagement)에서 등록한 공휴일/세무일정/회사일정을 프로젝트 전체에 공유 -- `fetchSchedules(year)` — 연도별 캐시 조회 (API 호출) -- `setSchedulesForYear(year, data)` — 이미 가져온 데이터 직접 설정 -- `invalidateYear(year)` — 캐시 무효화 (등록/수정/삭제 후) -- **현재 상태**: 백엔드 API 미구현 → 호출부 주석 처리 (TODO 검색) - -**달력 이벤트 유틸** (`src/constants/calendarEvents.ts`): -- `isHoliday(date)`, `isTaxDeadline(date)`, `getHolidayName(date)` 등 -- 스토어 우선 → 하드코딩 폴백(2026년) 패턴 -- 새 연도 폴백 데이터 필요 시 이 파일에 `HOLIDAYS_YYYY`, `TAX_DEADLINES_YYYY` 추가 - -**ScheduleCalendar 공통 컴포넌트** (`src/components/common/ScheduleCalendar/`): -- `hideNavigation` prop으로 헤더 ◀ ▶ 숨김 가능 (연간 달력 등 상위 네비게이션 사용 시) -- `availableViews={[]}` 으로 뷰 전환 버튼 숨김 - -**규칙**: -- `Date → string` 변환 시 `getLocalDateString()` 필수 (`toISOString().split('T')[0]` 금지) -- 공휴일/세무일 판별 시 `calendarEvents.ts` 유틸 함수 사용 -- 달력 데이터 공유 시 zustand 스토어 경유 (컴포넌트 간 직접 전달 금지) - -### `useDateRange` 훅 — 날짜 필터 보일러플레이트 제거 (2026-02-19) - -**현황**: 20+ 리스트 페이지에서 `useState('2025-01-01')` / `useState('2025-12-31')` 하드코딩 - -**문제**: 연도가 바뀌면 수동으로 모든 파일 수정 필요 (2025→2026 전환 시 데이터 미표시 버그 발생) - -**결정**: `useDateRange` 훅으로 동적 날짜 범위 자동 계산 - -**훅** (`src/hooks/useDateRange.ts`): -```typescript -import { useDateRange } from '@/hooks'; - -// 프리셋 -const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); // 2026-01-01 ~ 2026-12-31 -const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth'); // 2026-02-01 ~ 2026-02-28 -const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today'); // 2026-02-19 ~ 2026-02-19 -``` - -**적용 규칙**: -- 리스트 페이지 날짜 필터 → `useDateRange` 필수 사용 -- 연간 조회 → `'currentYear'`, 월간 조회 → `'currentMonth'` -- `useState('YYYY-MM-DD')` 하드코딩 금지 - -**현재 상태**: `useState('2025` 패턴 0건 (전량 `useDateRange`로 전환 완료) - -### Zod 스키마 검증 — 신규 폼 적용 규칙 (2026-02-11) - -**결정**: 기존 폼은 건드리지 않음. **신규 폼에만 Zod + zodResolver 적용** - -**설치 상태**: `zod@^4.1.12`, `@hookform/resolvers@^5.2.2` — 이미 설치됨 - -**효과**: -1. 스키마 하나로 **타입 추론 + 런타임 검증** 동시 해결 (`z.infer`) -2. 별도 `interface` 중복 정의 불필요 -3. 신규 코드에서 `as` 캐스트 자연 감소 (D-2 개선 효과) - -**규칙**: -- 신규 폼 → `zodResolver(schema)` 사용 필수 (CLAUDE.md에 패턴 명시) -- 기존 `rules={{ required: true }}` 패턴 폼 → 마이그레이션 불필요 -- 단순 1~2 필드 인라인 폼 → Zod 불필요 (오버엔지니어링) - -**미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산 +| **[`[REF] technical-decisions.md`](./architecture/[REF]%20technical-decisions.md)** | 프로젝트 기술 결정 사항 (13개 항목) | +| **[`[GUIDE] common-page-patterns.md`](./guides/[GUIDE]%20common-page-patterns.md)** | 공통 페이지 패턴 가이드 | --- @@ -329,350 +16,40 @@ const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today'); ``` claudedocs/ -├── _index.md # 이 파일 - 문서 맵 -├── auth/ # 인증 & 토큰 관리 -├── hr/ # 인사관리 (부서/사원) -├── item-master/ # 품목기준관리 -├── production/ # 생산관리 (생산현황판/작업자화면) -├── quality/ # 품질관리 (검사관리) -├── sales/ # 판매관리 (견적/거래처/단가) -├── accounting/ # 회계관리 (매입/매출/출금) -├── construction/ # 주일 공사 MES -├── board/ # 게시판 관리 -├── settings/ # 설정 관리 -├── dashboard/ # 대시보드 & 사이드바 -├── security/ # 보안 & 권한 -├── api/ # API 통합 -├── dev/ # 개발도구 & 테스트 -├── guides/ # 범용 가이드 -├── architecture/ # 아키텍처 & 시스템 -├── changes/ # 변경이력 -├── vehicle/ # 차량관리 -├── material/ # 자재관리 -├── approval/ # 결재관리 -├── customer-center/ # 고객센터 -├── refactoring/ # 리팩토링 체크리스트 -└── archive/ # 레거시/완료된 문서 +├── _index.md # 이 파일 - 문서 맵 +├── auth/ # 인증 & 토큰 관리 +├── hr/ # 인사관리 (부서/사원) +├── item-master/ # 품목기준관리 +├── production/ # 생산관리 (생산현황판/작업자화면) +├── quality/ # 품질관리 (검사관리) +├── sales/ # 판매관리 (견적/거래처/단가) +├── accounting/ # 회계관리 (매입/매출/출금) +├── construction/ # 주일 공사 MES +├── board/ # 게시판 관리 +├── settings/ # 설정 관리 +├── dashboard/ # 대시보드 & 사이드바 +├── security/ # 보안 & 권한 +├── api/ # API 통합 +├── dev/ # 개발도구 & 테스트 +├── guides/ # 범용 가이드 +│ ├── mobile/ # 모바일 반응형 +│ ├── universal-list/ # UniversalListPage 관련 +│ └── migration/ # 마이그레이션 체크리스트 +├── architecture/ # 아키텍처 & 시스템 & 기술 결정 +├── changes/ # 변경이력 +├── refactoring/ # 리팩토링 체크리스트 +├── vehicle/ # 차량관리 +├── material/ # 자재관리 +├── approval/ # 결재관리 +├── customer-center/ # 고객센터 +├── components/ # 컴포넌트 문서 +├── vercel/ # Vercel 배포 +└── archive/ # 레거시/완료된 문서 + └── sessions/ # 만료된 세션 체크포인트 ``` --- -## 인증 & 토큰 관리 — `auth/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2025-12-30] token-refresh-caching.md` | 토큰 갱신 캐싱 구현 (Request Coalescing 패턴) | -| `[IMPL-2025-12-04] signup-page-blocking.md` | MVP 회원가입 페이지 차단 | -| `token-management-guide.md` | Access/Refresh Token 가이드 | -| `jwt-cookie-authentication-final.md` | JWT + HttpOnly Cookie 구현 | -| `auth-guard-usage.md` | AuthGuard 훅 사용법 | -| `route-protection-architecture.md` | 라우트 보호 아키텍처 | -| `middleware-issue-resolution.md` | 미들웨어 이슈 해결 | -| `safari-cookie-compatibility.md` | Safari 쿠키 호환성 | -| `httponly-cookie-implementation.md` | HttpOnly 쿠키 구현 계획 | -| `httponly-cookie-security-validation.md` | 보안 검증 케이스 | -| `session-migration-*.md` | 세션 마이그레이션 관련 | -| `nextjs15-middleware-*.md` | Next.js 15 미들웨어 연구 | - ---- - -## 인사관리 — `hr/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2025-12-16] mobile-attendance.md` | 모바일 출퇴근 시스템 (카카오맵 GPS) | -| `[IMPL-2025-12-05] department-management-checklist.md` | 부서관리 구현 체크리스트 | -| `[IMPL-2025-12-05] employee-management-checklist.md` | 사원관리 구현 체크리스트 | -| `[IMPL-2025-12-06] vacation-management-checklist.md` | 휴가관리 구현 체크리스트 | - ---- - -## 품목기준관리 — `item-master/` - -| 파일 | 설명 | -|------|------| -| `[PLAN-2025-12-16] dynamicitemform-hook-extraction.md` | DynamicItemForm 훅 분리 계획서 | -| `[FIX-2025-12-16] options-details-duplicate-bug.md` | options vs item_details 중복 저장 버그 | -| `[IMPL-2025-12-15] backend-item-api-migration.md` | 백엔드 품목 API 통합 | -| `[DESIGN-2025-12-12] item-master-form-builder-roadmap.md` | Low-Code Form Builder 로드맵 | -| `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | DynamicItemForm 품목별 분리 계획 | -| `[REF] item-code-hardcoding.md` | 품목관리 하드코딩 내역 종합 | -| `[REF] items-route-consolidation.md` | 품목 라우트 통합 | -| `[IMPL-2025-12-02] item-code-auto-generation.md` | 품목코드 자동생성 구현 | -| `[PLAN-2025-12-01] service-layer-refactoring.md` | 서비스 레이어 리팩토링 계획 | -| `[REF-2025-12-01] state-sync-solutions.md` | 상태 동기화 문제 및 해결 방안 | -| `[IMPL-2025-12-02] dynamic-item-form-rebuild.md` | 동적 페이지 재구현 | -| `[API-REQUEST-2025-11-28] dynamic-page-rendering-api.md` | 동적 페이지 렌더링 API 요청서 | -| `[PLAN-2025-11-27] item-form-component-separation.md` | ItemForm 컴포넌트 분리 | -| `[IMPL-2025-11-27] realtime-sync-fixes.md` | 실시간 동기화 수정 | -| `[IMPL-2026-01-09] item-management-api-integration.md` | 품목관리 API 연동 | -| `[API-REQUEST-2026-02-12] dynamic-field-type-backend-spec.md` | 동적 필드 타입 확장 백엔드 API 스펙 | -| `NEXT-*.md` | 세션 체크포인트 (다수) | -| `API-*.md` | API 명세/요청 (다수) | -| `ANALYSIS-*.md` | 분석 노트 (다수) | - ---- - -## 생산관리 — `production/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2025-12-22] production-dashboard-checklist.md` | 생산 현황판 구현 체크리스트 (8 Phase) | -| `[DESIGN-2026-01-29] worker-screen-spec.md` | 작업자 화면 설계 스펙 | -| `[NEXT-2025-12-22] production-session-context.md` | 세션 체크포인트 | - ---- - -## 품질관리 — `quality/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2025-12-23] inspection-management-checklist.md` | 검사관리 구현 체크리스트 (7 Phase) | -| `[PLAN-2026-02-02] document-viewer-architecture.md` | 문서 뷰어 아키텍처 | -| `[PLAN-2026-02-04] quality-audit-document-management.md` | 품질심사 문서관리 | - ---- - -## 판매관리 — `sales/` - -| 파일 | 설명 | -|------|------| -| `[API-2025-12-08] pricing-api-enhancement-request.md` | 단가관리 API 개선 요청서 | -| `[IMPL-2025-12-05] pricing-management-migration.md` | 단가관리 마이그레이션 | -| `[API-2025-12-04] quote-api-request.md` | 견적관리 API 요청서 | -| `[PLAN-2025-12-04] quote-management-implementation.md` | 견적관리 작업계획서 | -| `[IMPL-2025-12-04] client-management-api-integration.md` | 거래처관리 API 연동 | -| `[API-2025-12-04] client-api-analysis.md` | 거래처 API 분석 | -| `[PLAN-2025-12-02] sales-pages-migration.md` | 판매 페이지 마이그레이션 | -| `[IMPL-2025-12-22] order-management-sales.md` | 수주관리 | -| `[IMPL-2026-01-12] quote-v2-test-pages-checklist.md` | 견적 v2 테스트 페이지 | -| `[IMPL-2025-12-09] pricing-api-integration-checklist.md` | 단가 API 연동 체크리스트 | -| `[NEXT-2026-02-04] price-distribution-session-context.md` | 단가배포 세션 체크포인트 | -| `[NEXT-2025-12-09] client-session-context.md` | 거래처 세션 체크포인트 | - ---- - -## 회계관리 — `accounting/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2025-12-18] vendor-management-checklist.md` | 거래처관리 구현 체크리스트 | -| `[IMPL-2025-12-18] purchase-management.md` | 매입관리 페이지 구현 | -| `[IMPL-2025-12-18] bill-management.md` | 어음관리 | -| `[IMPL-2025-12-18] expected-expense-checklist.md` | 지출예정 체크리스트 | -| `[IMPL-2025-12-18] receivables-status.md` | 미수금 현황 | -| `[IMPL-2025-12-18] vendor-ledger.md` | 거래처원장 | -| `[IMPL-2025-12-18] withdrawal-management-checklist.md` | 출금관리 체크리스트 | -| `[IMPL-2025-12-19] bad-debt-collection-management.md` | 부실채권 관리 | -| `[IMPL-2025-12-19] card-transaction-inquiry.md` | 카드거래 조회 | -| `[PLAN-2025-12-18] sales-management.md` | 매출관리 계획 | -| `[PLAN-2025-12-19] bank-account-transaction-inquiry.md` | 은행거래 조회 계획 | -| `[PLAN-2026-01-23] vendor-credit-analysis-modal.md` | 거래처 여신분석 모달 | - ---- - -## 주일 공사 MES — `construction/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2026-01-05] item-management-checklist.md` | 품목관리 구현 체크리스트 | -| `[IMPL-2026-01-05] category-management-checklist.md` | 카테고리관리 구현 체크리스트 | -| `[IMPL-2026-01-05] pricing-management-checklist.md` | 단가관리 구현 체크리스트 | -| `[IMPL-2026-01-09] partner-management-api-integration.md` | 거래처관리 API 연동 | -| `[IMPL-2026-01-09] site-management-api-integration.md` | 현장관리 API 연동 | -| `[IMPL-2026-01-12] project-detail-checklist.md` | 프로젝트 상세 체크리스트 | -| `[PLAN-2026-01-05] order-management-implementation.md` | 발주관리 구현 계획 | -| `[PLAN-2026-01-02] estimate-detail-form-refactoring.md` | 견적상세 폼 리팩토링 | -| `[PLAN-2026-01-05] order-detail-form-separation.md` | 발주상세 폼 분리 | -| `[REF] construction-project-flow.md` | 프로젝트 플로우 | -| `[REF] juil-project-structure.md` | 프로젝트 구조 가이드 | -| `[NEXT-2025-12-30] partner-management-session-context.md` | 세션 체크포인트 | - ---- - -## 대시보드 & 사이드바 — `dashboard/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2026-02-11] favorites-feature.md` | 즐겨찾기 기능 (localStorage → 추후 API 전환) | -| `[IMPL-2026-01-07] ceo-dashboard-checklist.md` | 대표님 전용 대시보드 (11개 섹션) | -| `dashboard-integration-complete.md` | 대시보드 통합 완료 | -| `dashboard-cleanup-summary.md` | 정리 요약 | -| `dashboard-migration-summary.md` | 마이그레이션 요약 | -| `sidebar-active-menu-sync.md` | 사이드바 메뉴 동기화 | -| `sidebar-scroll-improvements.md` | 스크롤 개선 | - ---- - -## 보안 & 권한 — `security/` - -| 파일 | 설명 | -|------|------| -| `[PLAN-2025-01-20] permission-system-implementation.md` | 권한 시스템 구현 계획 | -| `[QA-2026-02-03] permission-verification-checklist.md` | 권한 검증 체크리스트 | -| `[PLAN-2025-12-12] tenant-data-isolation-implementation.md` | 테넌트 데이터 격리 구현 | -| `[SECURITY-2025-12-12] tenant-data-isolation-audit.md` | 테넌트 데이터 격리 감사 | - ---- - -## API 통합 — `api/` - -| 파일 | 설명 | -|------|------| -| `api-requirements.md` | API 요구사항 | -| `api-analysis.md` | API 분석 | -| `api-route-type-safety.md` | 라우트 타입 안전성 | -| `api-key-management.md` | API 키 관리 | - ---- - -## 개발도구 & 테스트 — `dev/` - -| 파일 | 설명 | -|------|------| -| `[REF] all-pages-test-urls.md` | 전체 페이지 테스트 URL 목록 | -| `[REF] construction-pages-test-urls.md` | 주일 페이지 테스트 URL | -| `[REF] page-builder-implementation.md` | 페이지 빌더 구현 참조 | -| `[REF] chrome-devtools-mcp-emoji-issue.md` | Chrome DevTools MCP 이모지 이슈 | -| `[PLAN] detail-page-pattern-classification.md` | 상세페이지 패턴 분류 | -| `[PLAN-2026-02-03] claude-config-optimization.md` | Claude 설정 최적화 | -| `[IMPL-2025-12-29] quality-inspection-checklist.md` | 품질검사 체크리스트 | -| `[IMPL-2026-01-23] full-page-inspection.md` | 전체 페이지 검사 | -| `[FIX-2026-01-29] typecheck-errors-checklist.md` | 타입체크 에러 체크리스트 | -| `[HOTFIX-2026-01-27] E2E-테스트-수정계획서.md` | E2E 테스트 수정 계획서 | -| **Component Registry** | `/dev/component-registry` — 실시간 컴포넌트 스캔 + 관계도 (목록/카드형 플로우 뷰) | - ---- - -## 범용 가이드 — `guides/` - -| 파일 | 설명 | -|------|------| -| **UI 컴포넌트** | | -| `[DESIGN-2026-01-14] universal-list-component.md` | UniversalListPage 설계 | -| `[IMPL-2026-01-14] universal-list-component-checklist.md` | UniversalListPage 구현 체크리스트 | -| `[PLAN] universal-detail-component.md` | UniversalDetail 컴포넌트 계획 | -| `[REF] UniversalListPage-QA-patterns.md` | UniversalListPage QA 패턴 | -| `UniversalListPage-검색기능-수정내역.md` | 검색 기능 수정 내역 | -| `UniversalListPage-검색리렌더링-해결가이드.md` | 검색 리렌더링 해결 | -| `[DESIGN-2026-01-02] document-modal-common-component.md` | 문서 모달 공통 컴포넌트 | -| `badge-commonization-guide.md` | 배지 공통화 가이드 | -| **공통화 & 마이그레이션** | | -| `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 공통 컴포넌트 추출 후보 분석 | -| `[ANALYSIS] common-component-patterns.md` | 공통 컴포넌트 패턴 | -| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획 | -| `[IMPL-2025-01-26] list-page-ui-standardization-checklist.md` | 리스트 페이지 UI 표준화 | -| `[IMPL-2026-01-23] button-navigation-checklist.md` | 버튼 네비게이션 체크리스트 | -| `[IMPL-2026-01-23] mode-migration-checklist.md` | 모드 마이그레이션 체크리스트 | -| `[IMPL-2026-01-23] mode-navigation-full-checklist.md` | 모드 네비게이션 전체 체크리스트 | -| `[IMPL-2026-01-21] utility-input-migration-checklist.md` | 유틸리티 입력 마이그레이션 | -| `[IMPL-2026-02-06] datepicker-migration-checklist.md` | DatePicker 마이그레이션 | -| `[REF-2026-01-09] server-to-client-component-migration-checklist.md` | Server→Client 마이그레이션 | -| **모바일** | | -| `[GUIDE] mobile-responsive-patterns.md` | 모바일 반응형 패턴 | -| `[IMPL-2026-01-13] mobile-filter-migration-checklist.md` | 모바일 필터 마이그레이션 | -| `[PLAN-2026-01-20] mobile-card-infinity-scroll.md` | 모바일 카드 무한스크롤 | -| `[PLAN] mobile-overflow-testing.md` | 모바일 오버플로우 테스트 | -| `[QA-2026-01-21] mobile-infinity-scroll-inspection.md` | 모바일 무한스크롤 검사 | -| `[REF] mobile-zoom-fix-guide.md` | 모바일 줌 수정 가이드 | -| `[REF] mobile-zoom-prevention-guide.md` | 모바일 줌 방지 가이드 | -| `[FIX-2026-02-04] mobile-zoom-panning.md` | 모바일 줌 패닝 수정 | -| `[GUIDE] foldable-device-layout-fix.md` | 폴더블 기기 레이아웃 | -| **프로젝트 헬스 & 문서 시스템** | | -| `[PLAN-2025-12-19] project-health-improvement.md` | 프로젝트 헬스 개선 계획 | -| `[PLAN-2025-12-19] page-layout-standardization.md` | 페이지 레이아웃 표준화 | -| `[PLAN-2025-01-21] document-system-integration.md` | 문서 시스템 통합 | -| `[QA-2025-01-21] document-system-inspection.md` | 문서 시스템 검사 | -| `[QA-2026-01-15] universal-list-page-inspection.md` | UniversalListPage 검사 | -| **기술 가이드** | | -| `[GUIDE] print-area-utility.md` | 인쇄 printArea 유틸리티 | -| `[GUIDE-2025-12-29] vercel-deployment.md` | Vercel 배포 가이드 | -| `[GUIDE-2025-12-16] options-vs-flattened-data.md` | options vs 평탄화 데이터 패턴 | -| `[GUIDE] large-file-handling-strategy.md` | 대용량 파일 처리 전략 | -| `[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | Radix UI Select 버그 해결 | -| `[GUIDE] CSS-MIGRATION-WORKFLOW.md` | CSS 마이그레이션 워크플로우 | -| `[GUIDE] LARGE-FILE-WORKFLOW.md` | 대용량 파일 작업 워크플로우 | -| `[GUIDE] ZOD-VALIDATION-TROUBLESHOOTING.md` | Zod 유효성 검사 트러블슈팅 | -| `[REF] nextjs-error-handling-guide.md` | Next.js 에러 처리 | -| `[REF-2026-01-07] nextjs-security-update-and-migration-plan.md` | Next.js 보안 업데이트 계획 | -| `[GUIDE] collaboration-with-claude.md` | Claude 협업 가이드 | -| `[IMPL-2025-11-06] i18n-usage-guide.md` | 다국어 사용 가이드 | -| `[IMPL-2025-11-07] form-validation-guide.md` | 폼 유효성 검사 | -| `[IMPL-2026-01-05] stat-cards-grid-layout.md` | 스탯 카드 그리드 레이아웃 | -| `[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md` | 세션 체크포인트 | - ---- - -## 아키텍처 & 시스템 — `architecture/` - -| 파일 | 설명 | -|------|------| -| **리팩토링 로드맵** | | -| `[PLAN-2026-02-06] refactoring-roadmap.md` | 리팩토링 종합 로드맵 (5 Phase) | -| `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md` | 멀티테넌시 최적화 로드맵 (8 Phase) | -| **공통화 & 마이그레이션 분석** | | -| `[ANALYSIS-2026-01-20] 공통화-현황-분석.md` | 공통화 현황 분석 | -| `[ANALYSIS-2026-02-05] SAM-ERP-MES-정체성-분석.md` | SAM ERP/MES 정체성 분석 | -| `[ANALYSIS-2026-02-05] list-page-commonization-status.md` | 리스트 페이지 공통화 현황 | -| **컴포넌트 아키텍처** | | -| `[PLAN-2026-01-22] ui-component-abstraction.md` | UI 컴포넌트 추상화 | -| `[IMPL-2026-01-21] input-form-componentization.md` | 입력폼 컴포넌트화 | -| `[IMPL-2026-01-21] phase4-input-migration-rollout.md` | Phase 4 입력 마이그레이션 | -| `[IMPL-2026-02-05] detail-hooks-migration-plan.md` | 상세 훅 마이그레이션 | -| `[IMPL-2026-02-05] formatter-commonization-plan.md` | formatter 공통화 계획 | -| `[IMPL] IntegratedDetailTemplate-checklist.md` | 통합 상세 템플릿 체크리스트 | -| `[REF] template-migration-status.md` | 템플릿 마이그레이션 현황 | -| **동적 렌더링 플랫폼** | | -| `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md` | 동적 렌더링 플랫폼 전략 (기준관리 기반 화면 자동 구성 비전) | -| `[DESIGN-2026-02-11] dynamic-field-type-extension.md` | 동적 필드 타입 확장 설계서 (4-Level 구조) | -| `[IMPL-2026-02-11] dynamic-field-components.md` | 동적 필드 컴포넌트 구현 기획서 (Phase 1~3 완료) | -| **시스템 설계** | | -| `[PLAN-2026-01-16] layout-restructure.md` | 레이아웃 구조 변경 | -| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | 동적 메뉴 갱신 시스템 | -| `[FIX-2026-01-29] masterdata-cache-tenant-isolation.md` | masterData 캐시 테넌트 격리 | -| `[DESIGN-2025-12-20] item-master-zustand-refactoring.md` | Zustand 리팩토링 설계 | -| `[REF-2025-11-19] multi-tenancy-implementation.md` | 멀티테넌시 구현 | -| `[TEST-2025-11-19] multi-tenancy-test-guide.md` | 멀티테넌시 테스트 가이드 | -| `[IMPL-2025-11-13] browser-support-policy.md` | 브라우저 지원 정책 | -| `[IMPL-2025-11-18] ssr-hydration-fix.md` | SSR 하이드레이션 수정 | -| `[REF] architecture-integration-risks.md` | 통합 리스크 | -| `[NEXT-2025-12-20] zustand-refactoring-session-context.md` | 세션 체크포인트 | - ---- - -## 게시판 관리 — `board/` - -| 파일 | 설명 | -|------|------| -| `[PLAN-2025-12-19] board-management-implementation.md` | 게시판 구현 계획서 | - ---- - -## 설정 관리 — `settings/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2025-12-19] company-info.md` | 회사정보 구현 | -| `[IMPL-2025-12-19] popup-management.md` | 팝업관리 구현 | - ---- - -## 리팩토링 — `refactoring/` - -| 파일 | 설명 | -|------|------| -| `[IMPL-2026-02-09] phase1-common-hooks-checklist.md` | Phase 1 공통 훅 추출 체크리스트 (완료) + Phase 3 프로토타입 기록 | -| `[REF-2026-02-19] code-dedup-commonization-checklist.md` | 코드 중복 제거 및 공통화 체크리스트 (6 WP, 3 Phase 병렬 실행 계획) | - ---- - -## archive/ - 레거시/완료된 문서 - -완료되거나 더 이상 활성화되지 않은 문서들. 참조용으로 보관. -테스트 스크린샷(`qa-*.png`)도 여기에 보관. - ---- - ## 문서 작성 규칙 ### 파일명 컨벤션 @@ -689,14 +66,25 @@ claudedocs/ - `PLAN` - 계획 문서 - `DESIGN` - 설계 문서 - `TEST` - 테스트 가이드 -- `NEXT` - 다음 작업 목록 +- `NEXT` - 다음 작업 목록 (세션 체크포인트) - `FIX` - 버그 해결 문서 - `QA` - 품질 검사 문서 - `HOTFIX` - 긴급 수정 문서 +- `REPORT` - 보고서/전달 문서 ### 폴더 배치 기준 1. **기능/도메인 우선**: 문서 주제에 맞는 폴더에 배치 2. **범용 가이드**: 여러 기능에 적용되면 `guides/`에 배치 -3. **시스템 전체**: 아키텍처/리팩토링은 `architecture/`에 배치 -4. **개발도구**: 테스트 URL, 빌드, 설정은 `dev/`에 배치 +3. **시스템 전체**: 아키텍처/리팩토링/기술결정은 `architecture/`에 배치 +4. **개발도구**: 테스트 URL, 빌드, E2E, 설정은 `dev/`에 배치 5. **완료된 작업**: 더 이상 활성화되지 않으면 `archive/`로 이동 +6. **만료 세션**: 2개월 이상 경과한 NEXT-* 파일은 `archive/sessions/`로 이동 + +### 파일 목록 확인 +```bash +# 특정 도메인 파일 확인 +ls claudedocs// + +# 전체 파일 검색 +find claudedocs/ -name "*.md" | sort +``` diff --git a/claudedocs/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md b/claudedocs/architecture/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md similarity index 100% rename from claudedocs/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md rename to claudedocs/architecture/[ANALYSIS-2026-02-19] frontend-comprehensive-review.md diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md b/claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md new file mode 100644 index 00000000..dc10c79e --- /dev/null +++ b/claudedocs/architecture/[ANALYSIS-2026-02-23] deep-analysis-util-component-zustand.md @@ -0,0 +1,396 @@ +# SAM ERP 프로젝트 심층분석 종합 보고서 +> 분석일: 2026-02-23 | 분석 영역: Util 분리 / 컴포넌트 공통화 / Zustand 통합 + +--- + +## 목차 +1. [Executive Summary](#1-executive-summary) +2. [Util 함수 분리 분석](#2-util-함수-분리-분석) +3. [컴포넌트 공통화 분석](#3-컴포넌트-공통화-분석) +4. [Zustand 스토어 통합 분석](#4-zustand-스토어-통합-분석) +5. [통합 리팩토링 로드맵](#5-통합-리팩토링-로드맵) + +--- + +## 1. Executive Summary + +### 전체 현황 스코어카드 + +| 영역 | 현재 수준 | 주요 이슈 | 예상 절감 | +|------|----------|----------|----------| +| **Util 분리** | 🟡 보통 | 중복 함수 6건, 과대 파일 4개, 인라인 유틸 6패턴 | ~800줄 | +| **컴포넌트 공통화** | 🟡 보통 | 중복 다이얼로그 5건, Detail 버전 혼재, 패턴 비일관 | ~1,500줄 | +| **Zustand 통합** | 🟢 양호 | Context→Zustand 미전환 3건, 셀렉터 훅 미비 | 리렌더 최적화 | + +### Top 5 우선 조치 항목 + +1. 🔴 **AuthContext → Zustand 마이그레이션** (전역 리렌더 제거) +2. 🔴 **GenericCRUDDialog 추출** (5개 중복 다이얼로그 통합) +3. 🔴 **파일 다운로드 로직 통합** (3곳 중복 → 1곳) +4. 🟡 **dashboard/transformers.ts 분할** (1,700줄 → 도메인별 분리) +5. 🟡 **Detail/DetailClient/DetailClientV2 정리** (버전 혼재 제거) + +--- + +## 2. Util 함수 분리 분석 + +### 2.1 현재 유틸 파일 인벤토리 + +``` +src/lib/ +├── utils.ts (cn, safeJsonParse - 최소) +├── formatters.ts (phone, businessNumber, card, account 포맷터) +├── print-utils.ts (인쇄 유틸) +├── sanitize.ts (데이터 정제) +├── error-reporting.ts (에러 리포팅) +├── utils/ (13개 파일, ~82KB) +│ ├── amount.ts (금액 포맷: 원/만원) +│ ├── date.ts (날짜 유틸) +│ ├── validation.ts (Zod 스키마 - 725줄 ⚠️) +│ ├── excel-download.ts (엑셀 다운로드 - 528줄 ⚠️) +│ ├── fileDownload.ts (파일 다운로드) +│ ├── export.ts (엑셀 내보내기 - 중복 ⚠️) +│ ├── search.ts (검색/필터 파이프라인) +│ ├── materialTransform.ts (자재 데이터 변환) +│ ├── menuTransform.ts (메뉴 구조 변환) +│ ├── menuRefresh.ts (메뉴 새로고침) +│ ├── status-config.ts (상태 스타일 설정) +│ ├── redirect-error.ts (Next.js 리다이렉트 에러) +│ └── locale.ts (로케일 유틸) +├── api/ (25개 파일) +│ ├── error-handler.ts (API 에러 처리) +│ ├── toast-utils.ts (토스트 유틸 - 중복 ⚠️) +│ ├── transformers.ts (변환기 - 454줄 ⚠️) +│ ├── dashboard/transformers.ts (대시보드 변환 - 1,700줄 🔴) +│ ├── execute-server-action.ts +│ ├── execute-paginated-action.ts +│ └── query-params.ts (buildApiUrl - 표준화 완료) +├── permissions/ (3개 파일) +├── auth/ (2개 파일) +└── cache/ (2개 파일) +``` + +### 2.2 중복 로직 탐지 (6건) + +#### 🔴 HIGH PRIORITY + +| # | 중복 항목 | 위치 | 상세 | +|---|----------|------|------| +| 1 | **Blob 다운로드** | `export.ts`, `excel-download.ts`, `fileDownload.ts` | 동일한 `URL.createObjectURL → link.click → revokeObjectURL` 패턴이 3곳에 존재 | +| 2 | **날짜 문자열 생성** | `export.ts:58`, `excel-download.ts:78` | `toISOString().slice(0,10).replace(/-/g,'')` 동일 패턴, 시간 정밀도만 다름(초 vs 분) | +| 3 | **에러 메시지 포맷** | `error-handler.ts:122`, `toast-utils.ts:106` | `getErrorMessage()` vs `formatApiError()` - 동일 로직 | +| 4 | **숫자 포맷팅** | `amount.ts:15`, `formatters.ts:178` | `Intl.NumberFormat` vs regex 기반 - 3가지 접근법 혼재 | + +#### 🟡 MEDIUM PRIORITY + +| # | 중복 항목 | 위치 | +|---|----------|------| +| 5 | 엑셀 파일명 생성 | `export.ts:54` vs `excel-download.ts:78` | +| 6 | 쿼리 파라미터 빌드 | 레거시 `URLSearchParams` 패턴 (마이그레이션 완료 상태) | + +### 2.3 인라인 유틸 추출 후보 (6패턴) + +컴포넌트 내부에 반복적으로 등장하지만 util로 분리되지 않은 패턴: + +| 패턴 | 발견 위치 | 영향 파일 | 추천 위치 | +|------|----------|----------|----------| +| 월/분기 날짜 범위 계산 | TaxInvoice, HR 페이지들 | 5+ | `lib/utils/dateRange.ts` | +| 시간 문자열 포맷팅 | TransactionFormModal, time-picker | 4+ | `lib/utils/timeFormatter.ts` | +| 포맷된 숫자 파싱 | VendorManagement, Withdrawal 등 | 8+ | `lib/formatters.ts` 확장 | +| 에러 객체→메시지 변환 | attendance/page, employee/page | 3+ | `lib/utils/errorFormatter.ts` | +| 배열 합계/카운트 reduce | 대시보드, 주문관리 등 | 6+ | `lib/utils/aggregation.ts` | +| 파일 크기 포맷팅 | file-input.tsx | 2 | `lib/utils/fileSizeFormatter.ts` | + +### 2.4 과대 파일 (분할 필요) + +| 파일 | 줄 수 | 문제 | 분할 방안 | +|------|-------|------|----------| +| 🔴 `api/dashboard/transformers.ts` | **1,700+** | 10+ 도메인 변환 혼재 | `dashboard/transformers/{sales,production,quality,accounting,hr,common}.ts` | +| 🟡 `utils/validation.ts` | 725 | 5개 아이템 타입 스키마 혼재 | `validations/{item-master-base,product,part,material,filters}.ts` | +| 🟡 `utils/excel-download.ts` | 528 | 다운로드/내보내기/템플릿 혼재 | `{blob-download,excel-export,excel-template}.ts` | +| 🟡 `api/transformers.ts` | 454 | 27개 export 함수 | `transformers/{pages,sections,fields,bom,templates,options}.ts` | + +### 2.5 미사용 유틸 (후보) + +| 함수 | 파일 | 상태 | +|------|------|------| +| `parsePhoneNumber()` | `formatters.ts:36` | import 0건 | +| `extractNumbers()` | `formatters.ts:220` | import 0건 | +| `formatPersonalNumber()` | `formatters.ts:84` | 실제 사용은 `formatPersonalNumberMasked` | + +--- + +## 3. 컴포넌트 공통화 분석 + +### 3.1 현재 컴포넌트 계층 구조 + +``` +src/components/ +├── ui/ (49개 - Radix UI 래퍼) +├── atoms/ (3개 - 최소 단위) +├── molecules/ (9개 - 복합 폼/표시) +├── organisms/ (11개 - 비즈니스 컴포넌트) +├── templates/ (2+1개 - UniversalListPage, IntegratedDetailTemplate, IntegratedListTemplateV2) +├── accounting/ (18개 도메인 폴더, 100+ 컴포넌트) +├── settings/ (12개 도메인 폴더) +└── [기타 도메인] (15+ 폴더) +``` + +### 3.2 중복 컴포넌트 패턴 (핵심 발견) + +#### 🔴 CRITICAL: 단순 CRUD 다이얼로그 중복 (5건) + +거의 동일한 구조: Dialog 래퍼 → 폼 필드 → 유효성 검증 → 제출/취소 버튼 + +| 컴포넌트 | 줄 수 | 차이점 | +|----------|-------|--------| +| `settings/RankManagement/RankDialog.tsx` | 89 | 라벨명만 다름 | +| `settings/TitleManagement/TitleDialog.tsx` | 90 | 라벨명만 다름 | +| `settings/PermissionManagement/PermissionDialog.tsx` | ~90 | 라벨명만 다름 | +| `settings/NotificationSettings/ItemSettingsDialog.tsx` | ~90 | 라벨명만 다름 | +| `accounting/VendorManagement/CreditAnalysisModal/` | ~100 | 약간 복잡 | + +**해결안**: `GenericCRUDDialog` 제네릭 컴포넌트 생성 +```typescript +// src/components/molecules/GenericCRUDDialog.tsx +interface GenericCRUDDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + mode: 'add' | 'edit'; + title: string; + fields: FormFieldDefinition[]; + data?: T; + onSubmit: (data: T) => Promise; +} +``` +→ **~400줄 절감** + +#### 🔴 CRITICAL: Detail 파일 버전 혼재 + +한 엔티티에 대해 여러 버전의 Detail 파일이 공존: + +| 엔티티 | 파일들 | 문제 | +|--------|--------|------| +| BadDebt | `BadDebtDetail.tsx`, `BadDebtDetailClientV2.tsx` | V2 마이그레이션 미완 | +| Withdrawal | `WithdrawalDetailClientV2.tsx` | ClientV2 접미사 | +| Deposit | `DepositDetailClientV2.tsx` | ClientV2 접미사 | +| Vendor | `VendorDetail.tsx`, `VendorDetailClient.tsx` | 두 파일 공존 | + +→ **단일 소스로 통합 필요, ~300줄 절감** + +#### 🟡 HIGH: 리스트 페이지 설정 중복 + +`UniversalListPage`로 통합은 잘 되어있으나, 설정(config) 코드가 각 페이지에 반복: + +| 반복 요소 | 발견 위치 | 해결안 | +|-----------|----------|--------| +| 상태 관리 (data, filters, pagination) | Sales, Purchase, Vendor 등 | 설정 파일 분리 | +| DateRange 선택기 | 8+ 회계 페이지 | `useDateRange()` 훅 표준화 | +| Stats 계산 useMemo | 대부분의 리스트 페이지 | `DataStatsCard` 추출 | + +→ **~500줄 절감** + +### 3.3 재사용률 분석 + +#### 높은 재사용 (Good) +- **UniversalListPage**: 40+ 페이지 (우수) +- **IntegratedDetailTemplate**: 20+ 상세 페이지 +- **FormField**: 50+ 폼 + +#### 활용 부족 (Should Use More) +- **SearchableSelectionModal**: 실제 3곳만 사용 → 더 광범위 적용 가능 +- **StandardDialog**: 존재하지만 단순 다이얼로그들이 미사용 +- **MobileCard**: 정의되었지만 비일관적 사용 + +### 3.4 패턴 비일관성 + +| 패턴 | 현재 상태 | 표준화 방향 | +|------|----------|------------| +| 날짜 범위 선택 | 3가지 방식 혼재 (컴포넌트/훅/인라인) | `useDateRange()` + `` | +| 검색/필터 | 3가지 경쟁 패턴 (A: UniversalListPage, B: 커스텀 useState, C: IntegratedListTemplateV2) | Pattern A로 통일 | +| 모달 vs 페이지 | VendorDetail→풀페이지, PurchaseDetail→모달 혼재 | 도메인별 기준 확립 | + +### 3.5 추출 필요 공유 컴포넌트 + +| 컴포넌트 | 사용처 | 설명 | +|----------|--------|------| +| `LineItemsTable` | SalesDetail, PurchaseDetail | 품목 추가/삭제/계산 테이블 (~150줄×2 절감) | +| `DataStatsCard` | 회계 리스트 페이지들 | 유연한 통계 표시 카드 | +| `DocumentTemplate` | CreditAnalysis, InspectionReport | 인쇄용 문서 래퍼 (헤더/푸터/워터마크) | +| `DataTableWithActions` | 대부분의 리스트 | 페이지네이션+선택+액션 통합 | + +--- + +## 4. Zustand 스토어 통합 분석 + +### 4.1 현재 스토어 인벤토리 (7개) + +| 스토어 | 파일 | 줄 수 | 미들웨어 | 용도 | +|--------|------|-------|---------|------| +| `useItemMasterStore` | `stores/item-master/useItemMasterStore.ts` | 1,150 | devtools, immer | 품목기준관리 정규화 상태 | +| `useMasterDataStore` | `stores/masterDataStore.ts` | 450 | devtools | 동적 폼 설정 캐싱 | +| `useMenuStore` | `stores/menuStore.ts` | ~100 | persist | 사이드바/메뉴 상태 | +| `useFavoritesStore` | `stores/favoritesStore.ts` | ~100 | persist + custom storage | 즐겨찾기 (최대 10개) | +| `useThemeStore` | `stores/themeStore.ts` | ~50 | persist | 테마 (light/dark/senior) | +| `useTableColumnStore` | `stores/useTableColumnStore.ts` | ~100 | persist + custom storage | 테이블 컬럼 가시성/너비 | +| `useCalendarScheduleStore` | `stores/useCalendarScheduleStore.ts` | ~100 | devtools | 캘린더 일정 연도별 캐싱 | + +### 4.2 핵심 발견: Context → Zustand 미전환 (3건) + +#### 🔴 #1: AuthContext (최우선) + +| 항목 | 현재 | 문제 | +|------|------|------| +| **위치** | `/src/contexts/AuthContext.tsx` (278줄) | React Context + useState | +| **상태** | users[], currentUser, roles, tenants | Provider 리렌더 전파 | +| **localStorage** | 수동 동기화 (line 162-190) | Zustand persist가 자동 처리 가능 | +| **영향** | 사이드바, 대시보드, 모든 인증 페이지 | 상태 변경 시 전체 앱 리렌더 | + +**전환 방안**: +```typescript +// /src/stores/authStore.ts +export const useAuthStore = create()( + persist( + devtools((set) => ({ + currentUser: null, + setCurrentUser: (user) => set({ currentUser: user }), + // ... 기타 액션 + })), + { name: 'mes-currentUser' } + ) +); +``` + +#### 🟡 #2: ItemMasterContext (중복 제거) + +| 항목 | 현재 | 문제 | +|------|------|------| +| **Context** | `contexts/ItemMasterContext.tsx` (27,922 토큰) | useState 13개+ 상태 | +| **Zustand** | `stores/item-master/useItemMasterStore.ts` (1,150줄) | 유사 데이터 관리 | +| **중복** | 양쪽에서 품목 마스터 데이터 관리 | 캐싱/API 레이어 분리 | + +→ **Context를 Zustand 스토어로 통합, Context는 얇은 래퍼로만 유지** + +#### 🟡 #3: PermissionContext + +| 항목 | 현재 | 문제 | +|------|------|------| +| **위치** | `contexts/PermissionContext.tsx` | 순수 데이터/셀렉터 패턴 | +| **적합도** | Zustand 셀렉터 패턴에 완벽 부합 | Provider 불필요 | + +### 4.3 셀렉터 훅 미비 (성능 이슈) + +| 스토어 | 셀렉터 훅 | 문제 | +|--------|----------|------| +| ✅ `masterDataStore` | `usePageConfig()`, `usePageConfigLoading()` 등 | 양호 | +| ❌ `useTableColumnStore` | 없음 - 전체 스토어 구독 | 불필요한 리렌더 | +| ❌ `useMenuStore` | 없음 - 전체 스토어 구독 | 사이드바 토글이 모든 구독자 리렌더 | +| ❌ `useThemeStore` | 없음 | 경미 | + +**해결 패턴**: +```typescript +// ✅ 추가 필요 +export const useTableSettings = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId]); + +export const useMenuActiveId = () => + useMenuStore((state) => state.activeMenu); + +export const useSidebarCollapsed = () => + useMenuStore((state) => state.sidebarCollapsed); +``` + +### 4.4 Custom Storage 중복 + +`favoritesStore`와 `tableColumnStore`에서 동일한 사용자별 localStorage 래퍼가 반복: + +```typescript +// 두 파일 모두 동일 패턴 반복: +const customStorage = { + getItem: (name) => { /* userId 기반 키 생성 */ }, + setItem: (name, value) => { /* userId 기반 키로 저장 */ }, + removeItem: (name) => { /* userId 기반 키로 삭제 */ }, +}; +``` + +**해결안**: `/src/lib/storage/user-scoped-storage.ts` 추출 +```typescript +export function createUserScopedStorage(prefix: string): StateStorage { + return { getItem, setItem, removeItem }; +} +``` + +### 4.5 누락된 스토어 기회 + +| 스토어 | 용도 | 현재 상태 | +|--------|------|----------| +| 🔴 `useUIStore` | 전역 모달/노티/로딩 | 각 컴포넌트에서 로컬 관리 | +| 🟡 글로벌 필터 상태 | 리스트 페이지 공통 필터 | useState로 산재 | + +--- + +## 5. 통합 리팩토링 로드맵 + +### Phase 1: 즉시 (1주) + +| 작업 | 영역 | 영향도 | 난이도 | +|------|------|--------|--------| +| AuthContext → Zustand 마이그레이션 | Zustand | 🔴 전역 리렌더 제거 | 중 | +| GenericCRUDDialog 추출 (5개 다이얼로그 통합) | 컴포넌트 | 🔴 ~400줄 절감 | 저 | +| Blob 다운로드 로직 통합 (3곳→1곳) | Util | 🔴 중복 제거 | 저 | +| 에러 메시지 포맷 통합 (`formatApiError` 제거) | Util | 🟡 API 레이어 정리 | 저 | +| Zustand 셀렉터 훅 추가 (3개 스토어) | Zustand | 🟡 리렌더 최적화 | 저 | + +### Phase 2: 단기 (2~3주) + +| 작업 | 영역 | 영향도 | 난이도 | +|------|------|--------|--------| +| `dashboard/transformers.ts` 분할 (1,700줄) | Util | 🟡 유지보수성 | 중 | +| Detail 파일 버전 정리 (V2 통합) | 컴포넌트 | 🟡 ~300줄 절감 | 중 | +| `LineItemsTable` organism 추출 | 컴포넌트 | 🟡 Sales/Purchase 공통화 | 중 | +| Custom Storage 유틸 추출 | Zustand | 🟡 DRY | 저 | +| 날짜 범위 선택 표준화 | 컴포넌트 | 🟡 패턴 통일 | 중 | + +### Phase 3: 중기 (3~4주) + +| 작업 | 영역 | 영향도 | 난이도 | +|------|------|--------|--------| +| `validation.ts` 분할 (725줄) | Util | 🟢 유지보수성 | 저 | +| ItemMasterContext → Zustand 통합 | Zustand | 🟡 중복 제거 | 고 | +| IntegratedListTemplateV2 폐기 | 컴포넌트 | 🟢 레거시 제거 | 중 | +| 인라인 유틸 추출 (6패턴) | Util | 🟢 코드 품질 | 저 | +| 미사용 유틸 함수 정리 | Util | 🟢 코드 청결 | 저 | + +### Phase 4: 장기 (4주+) + +| 작업 | 영역 | 영향도 | 난이도 | +|------|------|--------|--------| +| PermissionContext → Zustand | Zustand | 🟢 아키텍처 통일 | 중 | +| DocumentTemplate organism 추출 | 컴포넌트 | 🟢 인쇄 공통화 | 중 | +| useUIStore 생성 (전역 UI 상태) | Zustand | 🟢 모달/노티 통합 | 중 | +| 숫자 포맷팅 API 표준화 | Util | 🟢 일관성 | 저 | + +--- + +## 부록: 핵심 파일 참조 + +### 리팩토링 대상 (Util) +- `/src/lib/utils/export.ts` - 중복 제거 대상 +- `/src/lib/utils/excel-download.ts` - 분할 대상 (528줄) +- `/src/lib/utils/validation.ts` - 분할 대상 (725줄) +- `/src/lib/api/dashboard/transformers.ts` - 분할 대상 (1,700줄) +- `/src/lib/api/toast-utils.ts` - `formatApiError` 제거 대상 + +### 리팩토링 대상 (컴포넌트) +- `/src/components/settings/RankManagement/RankDialog.tsx` - GenericCRUDDialog로 대체 +- `/src/components/settings/TitleManagement/TitleDialog.tsx` - GenericCRUDDialog로 대체 +- `/src/components/accounting/BadDebtCollection/BadDebtDetailClientV2.tsx` - 버전 통합 +- `/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx` - 버전 통합 +- `/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx` - 버전 통합 + +### 리팩토링 대상 (Zustand) +- `/src/contexts/AuthContext.tsx` → `/src/stores/authStore.ts` +- `/src/contexts/ItemMasterContext.tsx` → `/src/stores/item-master/` 통합 +- `/src/stores/useTableColumnStore.ts` - 셀렉터 훅 추가 +- `/src/stores/menuStore.ts` - 셀렉터 훅 추가 +- `/src/stores/favoritesStore.ts` - custom storage 유틸 추출 diff --git a/claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md b/claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md new file mode 100644 index 00000000..50bf72af --- /dev/null +++ b/claudedocs/architecture/[IMPL-2026-02-23] phase1-item4-error-format.md @@ -0,0 +1,112 @@ +# Phase 1-4: 에러 메시지 포맷 통합 (`formatApiError` 제거) + +> 난이도: 저 | 영향도: 🟡 API 레이어 정리 | 예상 변경: 1파일 삭제 + +--- + +## 현황 요약 + +에러 메시지 포맷팅 함수가 2곳에 중복: + +| 파일 | 함수 | 외부 사용처 | +|------|------|------------| +| `src/lib/api/error-handler.ts:122` | `getErrorMessage()` | **5+ 파일** (활발히 사용) | +| `src/lib/api/toast-utils.ts:106` | `formatApiError()` | **0건** (dead code) | + +또한 `SHOW_ERROR_CODE` 상수도 양쪽에 중복 정의됨. + +--- + +## 핵심 발견: toast-utils.ts 전체가 dead code + +`from '@/lib/api/toast-utils'` 를 import하는 파일이 **0건**. + +``` +toast-utils.ts 내보내는 함수 전부 미사용: +- toastApiError() → 0 import +- toastSuccess() → 0 import +- toastWarning() → 0 import +- toastInfo() → 0 import +- formatApiError() → 0 import +``` + +현재 프로젝트에서 에러 토스트 표시는 직접 `toast.error(getErrorMessage(err))` 패턴으로 처리 중. + +--- + +## 작업 내역 + +### Step 1: `src/lib/api/toast-utils.ts` 삭제 + +파일 전체가 dead code이므로 삭제. + +### Step 2: (선택) 유용한 헬퍼를 error-handler.ts로 이동 + +`toastApiError()` 함수는 validation 에러의 첫 번째 필드를 표시하는 로직이 있어, +향후 유용할 수 있으면 error-handler.ts 하단에 통합 가능. + +```typescript +// src/lib/api/error-handler.ts 하단에 추가 (선택) +import { toast } from 'sonner'; + +export function toastApiError(error: unknown, fallbackMessage = '오류가 발생했습니다.'): void { + if (error instanceof ApiError && error.errors && SHOW_ERROR_CODE) { + const firstField = Object.keys(error.errors)[0]; + if (firstField) { + toast.error(`${getErrorMessage(error)}\n${firstField}: ${error.errors[firstField][0]}`); + return; + } + } + toast.error(getErrorMessage(error) || fallbackMessage); +} +``` + +이 step은 **선택**. 현재 사용처가 없으므로 당장은 삭제만으로 충분. + +### Step 3: 검증 + +```bash +npx tsc --noEmit +``` + +toast-utils.ts를 삭제해도 외부 import가 없으므로 타입 에러 없음. + +--- + +## 관련 파일 참조 + +### 활발히 사용 중인 함수 (변경 없음) + +`getErrorMessage()` 사용처 (error-handler.ts에서 export): +- `src/contexts/ItemMasterContext.tsx` (line 7, 589, 682) +- `src/components/items/ItemMasterDataManagement/hooks/usePageManagement.ts` (line 7, 122, 159, 198, 219) +- `src/components/items/ItemMasterDataManagement/hooks/useImportManagement.ts` (line 5, 58, 80, 92) +- `src/components/items/ItemMasterDataManagement/hooks/useInitialDataLoading.ts` (line 7, 130) +- `src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx` (line 40, 301, 347) + +### 삭제 대상 + +- `src/lib/api/toast-utils.ts` (전체 116줄) + +--- + +## 중복 구조 비교 + +``` +error-handler.ts toast-utils.ts (삭제 대상) +───────────────── ────────────────────────── +const SHOW_ERROR_CODE = true; const SHOW_ERROR_CODE = true; ← 중복 + +getErrorMessage(error): formatApiError(error): + DuplicateCodeError → [status] ApiError → [status] msg + ApiError → [status] msg else → getErrorMessage() ← 결국 위임 + Error → .message + unknown → 기본 메시지 + toastApiError(error): + DuplicateCodeError → toast ← getErrorMessage와 동일 로직 + ApiError → toast + Error → toast + unknown → toast +``` + +`formatApiError`는 결국 `getErrorMessage`를 호출하는 래퍼에 불과. 삭제해도 기능 손실 없음. diff --git a/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md b/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md new file mode 100644 index 00000000..cbc53aca --- /dev/null +++ b/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md @@ -0,0 +1,229 @@ +# Phase 1-5: Zustand 셀렉터 훅 추가 (3개 스토어) + +> 난이도: 저 | 영향도: 🟡 리렌더 최적화 | 예상 변경: 3 스토어 + 4 컨슈머 + +--- + +## 현황 요약 + +셀렉터 없이 전체 스토어를 구독하면, 무관한 상태 변경에도 컴포넌트가 리렌더됩니다. + +| 스토어 | 셀렉터 훅 | 사용처 | 문제 | +|--------|----------|--------|------| +| ✅ `masterDataStore` | `usePageConfig()` 등 | 다수 | 양호 | +| ✅ `authStore` | `useCurrentUser()` 등 | 4곳 | 양호 (방금 추가) | +| ❌ `useTableColumnStore` | 없음 | 1곳 | 전체 스토어 구독 | +| ❌ `useMenuStore` | 없음 | 15곳 | 일부 전체 구독 | +| ❌ `useThemeStore` | 없음 | 2곳 | 전체 구독 | + +--- + +## 작업 내역 + +### Step 1: `src/stores/useTableColumnStore.ts` — 셀렉터 훅 추가 + +파일 끝에 추가: + +```typescript +// ===== 셀렉터 훅 ===== + +/** 특정 페이지의 컬럼 설정만 구독 */ +export const usePageColumnSettings = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId] ?? DEFAULT_PAGE_SETTINGS); + +/** 특정 페이지의 숨김 컬럼만 구독 */ +export const useHiddenColumns = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId]?.hiddenColumns ?? []); + +/** 특정 페이지의 컬럼 너비만 구독 */ +export const useColumnWidths = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId]?.columnWidths ?? {}); +``` + +**주의**: `DEFAULT_PAGE_SETTINGS` 객체는 파일 내에 이미 정의되어 있음 (line 30-33). + +**컨슈머 변경** — `src/hooks/useColumnSettings.ts`: + +```typescript +// Before (line 17) +const store = useTableColumnStore(); // 전체 스토어 구독 +const settings = store.getPageSettings(pageId); + +// After +const settings = usePageColumnSettings(pageId); // 해당 페이지 설정만 구독 +const { setColumnWidth: storeSetWidth, toggleColumnVisibility: storeToggle, resetPageSettings } = useTableColumnStore.getState(); +// 또는 액션만 별도 구독 (액션은 참조 안정적이라 리렌더 유발 안 함): +const setColumnWidth = useTableColumnStore((s) => s.setColumnWidth); +const toggleColumnVisibility = useTableColumnStore((s) => s.toggleColumnVisibility); +const resetPageSettings = useTableColumnStore((s) => s.resetPageSettings); +``` + +--- + +### Step 2: `src/stores/menuStore.ts` — 셀렉터 훅 추가 + +파일 끝에 추가: + +```typescript +// ===== 셀렉터 훅 ===== + +/** 사이드바 접힘 상태만 구독 */ +export const useSidebarCollapsed = () => + useMenuStore((state) => state.sidebarCollapsed); + +/** 활성 메뉴 ID만 구독 */ +export const useActiveMenu = () => + useMenuStore((state) => state.activeMenu); + +/** 메뉴 아이템 목록만 구독 */ +export const useMenuItems = () => + useMenuStore((state) => state.menuItems); + +/** 하이드레이션 완료 여부만 구독 */ +export const useMenuHydrated = () => + useMenuStore((state) => state._hasHydrated); +``` + +**컨슈머 변경 대상**: + +#### 2-A. `src/layouts/AuthenticatedLayout.tsx` (line 99) — 🔴 핵심 + +현재: 전체 스토어 디스트럭처링 +```typescript +const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore(); +``` + +변경: +```typescript +const menuItems = useMenuItems(); +const activeMenu = useActiveMenu(); +const sidebarCollapsed = useSidebarCollapsed(); +const _hasHydrated = useMenuHydrated(); +// 액션은 참조 안정적이므로 별도 셀렉터: +const setActiveMenu = useMenuStore((s) => s.setActiveMenu); +const setMenuItems = useMenuStore((s) => s.setMenuItems); +const toggleSidebar = useMenuStore((s) => s.toggleSidebar); +``` + +#### 2-B. `src/components/production/WorkerScreen/index.tsx` (line 327) + +현재: +```typescript +const { sidebarCollapsed } = useMenuStore(); // 전체 구독 +``` + +변경: +```typescript +const sidebarCollapsed = useSidebarCollapsed(); +``` + +#### 2-C. `src/components/layout/CommandMenuSearch.tsx` (line 68) + +현재: +```typescript +const { menuItems } = useMenuStore(); // 전체 구독 +``` + +변경: +```typescript +const menuItems = useMenuItems(); +``` + +#### 2-D. 나머지 sidebarCollapsed 사용 파일 (이미 셀렉터 패턴) + +아래 파일들은 이미 `useMenuStore((state) => state.sidebarCollapsed)` 패턴을 사용 중이므로 **변경 불필요**: +- `ItemDetail.tsx`, `ChecklistDetail.tsx`, `PriceDistributionDetail.tsx` +- `StepDetail.tsx`, `PermissionDetailClient.tsx`, `BoardDetail/index.tsx` +- `ProcessDetail.tsx`, `PricingTableForm.tsx`, `DynamicItemForm/index.tsx` +- `ItemDetailClient.tsx`, `ClientDetail.tsx`, `DetailActions.tsx` + +단, 셀렉터 훅이 추가되면 이 파일들도 향후 `useSidebarCollapsed()`로 전환 가능 (선택). + +--- + +### Step 3: `src/stores/themeStore.ts` — 셀렉터 훅 추가 + +파일 끝에 추가: + +```typescript +// ===== 셀렉터 훅 ===== + +/** 현재 테마만 구독 */ +export const useTheme = () => + useThemeStore((state) => state.theme); + +/** setTheme 액션만 구독 */ +export const useSetTheme = () => + useThemeStore((state) => state.setTheme); +``` + +**컨슈머 변경 대상**: + +#### 3-A. `src/layouts/AuthenticatedLayout.tsx` (line 100) + +현재: +```typescript +const { theme, setTheme } = useThemeStore(); +``` + +변경: +```typescript +const theme = useTheme(); +const setTheme = useSetTheme(); +``` + +#### 3-B. `src/components/ThemeSelect.tsx` (line 24) + +현재: +```typescript +const { theme, setTheme } = useThemeStore(); +``` + +변경: +```typescript +const theme = useTheme(); +const setTheme = useSetTheme(); +``` + +--- + +## 검증 + +```bash +npx tsc --noEmit +``` + +셀렉터 훅은 기존 API에 추가만 하는 것이므로 기존 코드에 영향 없음. +컨슈머 변경은 import 경로와 호출 패턴만 바뀌므로 타입 에러 가능성 낮음. + +--- + +## 변경 파일 총 정리 + +| # | 파일 | 작업 | 내용 | +|---|------|------|------| +| 1 | `src/stores/useTableColumnStore.ts` | 추가 | 셀렉터 훅 3개 (`usePageColumnSettings`, `useHiddenColumns`, `useColumnWidths`) | +| 2 | `src/stores/menuStore.ts` | 추가 | 셀렉터 훅 4개 (`useSidebarCollapsed`, `useActiveMenu`, `useMenuItems`, `useMenuHydrated`) | +| 3 | `src/stores/themeStore.ts` | 추가 | 셀렉터 훅 2개 (`useTheme`, `useSetTheme`) | +| 4 | `src/hooks/useColumnSettings.ts` | 수정 | `useTableColumnStore()` → 셀렉터 패턴 | +| 5 | `src/layouts/AuthenticatedLayout.tsx` | 수정 | menuStore/themeStore 전체 구독 → 셀렉터 | +| 6 | `src/components/production/WorkerScreen/index.tsx` | 수정 | `useMenuStore()` → `useSidebarCollapsed()` | +| 7 | `src/components/layout/CommandMenuSearch.tsx` | 수정 | `useMenuStore()` → `useMenuItems()` | +| 8 | `src/components/ThemeSelect.tsx` | 수정 | `useThemeStore()` → `useTheme()` + `useSetTheme()` | + +--- + +## 참고: Zustand 셀렉터가 중요한 이유 + +``` +// ❌ 전체 구독 — menuItems 변경 시 sidebarCollapsed만 쓰는 컴포넌트도 리렌더 +const { sidebarCollapsed } = useMenuStore(); + +// ✅ 셀렉터 — sidebarCollapsed 변경 시에만 리렌더 +const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); +// 또는 +const sidebarCollapsed = useSidebarCollapsed(); +``` + +Zustand는 `Object.is`로 반환값을 비교. 셀렉터가 원시값(string, boolean, number)을 반환하면 참조 비교로 정확히 변경 감지. +객체를 반환하는 셀렉터(예: `usePageColumnSettings`)는 같은 참조를 반환하므로 해당 pageId의 설정이 변경될 때만 리렌더. diff --git a/claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md b/claudedocs/architecture/[PLAN-2025-02-10] frontend-improvement-roadmap.md similarity index 100% rename from claudedocs/[PLAN-2025-02-10] frontend-improvement-roadmap.md rename to claudedocs/architecture/[PLAN-2025-02-10] frontend-improvement-roadmap.md diff --git a/claudedocs/architecture/[REF] technical-decisions.md b/claudedocs/architecture/[REF] technical-decisions.md new file mode 100644 index 00000000..2e12c4e9 --- /dev/null +++ b/claudedocs/architecture/[REF] technical-decisions.md @@ -0,0 +1,316 @@ +# 프로젝트 기술 결정 사항 + +> `_index.md`에서 분리됨 (2026-02-23). 프로젝트 전반의 기술 선택 배경과 근거를 기록. + +--- + +### `` 태그 사용 — `next/image` 미사용 이유 (2026-02-10) + +**현황**: 프로젝트 전체 `` 태그 10건, `next/image` 0건 + +**결정**: `` 유지, `next/image` 전환 불필요 + +**근거**: +1. **폐쇄형 ERP 시스템** — SEO 불필요, LCP 점수 무의미 +2. **전량 외부 동적 이미지** — 백엔드 API에서 받아오는 URL (정적 내부 이미지 0건) +3. **프린트/문서 레이아웃** — 10건 중 8건이 검사 기준서·도해 등 인쇄용. `next/image`의 `width`/`height` 강제 지정이 프린트 레이아웃을 깰 위험 +4. **blob URL 비호환** — 업로드 미리보기(blob:)는 `next/image`가 지원 안 함 +5. **설정 부담 > 이점** — `remotePatterns` 설정 + 백엔드 도메인 관리 비용이 실질 이점보다 큼 + +### 모바일 헤더 `backdrop-filter` 깜빡임 수정 (2026-02-11) + +**현상**: 모바일(Safari/Chrome)에서 sticky 헤더가 스크롤 시 투명↔불투명 깜빡임 발생. PC 브라우저 축소로는 재현 불가, 실제 모바일 기기에서만 발생. + +**원인 2가지**: +1. `globals.css`에 `* { transition: all 0.2s }` — 전체 요소의 모든 CSS 속성에 전역 transition. 모바일 스크롤 리페인트 시 background/opacity가 매번 애니메이션 +2. 모바일 헤더의 `clean-glass` 클래스: `backdrop-filter: blur(8px)` + `background: rgba(255,255,255, 0.95)` 조합이 모바일 sticky 요소에서 GPU 컴포지팅 충돌 + +**수정**: +- `globals.css`: `*` 전역 transition → `button, a, input, select, textarea, [role]` 인터랙티브 요소만, `transition: all` → `color, background-color, border-color, box-shadow` 속성만 +- 모바일 헤더: `clean-glass` (반투명+blur) → `bg-background border border-border` (불투명 배경) + +**교훈**: +- `transition: all`은 절대 `*`에 걸지 않기. 모바일 성능 저하 + 의도치 않은 애니메이션 발생 +- `backdrop-filter: blur()` + `sticky` 조합은 모바일 브라우저 고질적 리페인트 버그. 모바일 헤더는 불투명 배경 사용 +- 0.95 투명도는 육안 구분 불가 → 불투명 처리해도 시각적 차이 없음 + +**사용처 (9개 파일)**: +| 파일 | 용도 | 이미지 소스 | +|------|------|-------------| +| `DocumentHeader.tsx` (2건) | 문서 헤더 로고 | `logo.imageUrl` (API) | +| `ProductInspectionInputModal.tsx` | 제품검사 사진 미리보기 | blob URL | +| `ProductInspectionDocument.tsx` | 제품검사 문서 | `data.productImage` (API) | +| `inspection-shared.tsx` | 검사 기준서 이미지 | `standardImage` (API) | +| `SlatInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `ScreenInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `BendingInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `SlatJointBarInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | +| `BendingWipInspectionContent.tsx` | 도해 이미지 | `schematicImage` (API) | + +**참고**: `next/image`가 유효한 케이스는 공개 사이트 + 정적/내부 이미지 + SEO 중요한 상황 + +### `next/dynamic` 코드 스플리팅 적용 (2026-02-10) + +**결정**: 대형 컴포넌트 + 무거운 라이브러리에 `next/dynamic` / 동적 `import()` 적용 + +**핵심 개념 — Suspense vs dynamic()**: +- **`Suspense` + 정적 import** → 코드가 부모와 같은 번들 청크에 포함. 유저가 안 봐도 이미 다운로드됨. UI fallback만 제공하고 **코드 분할은 안 일어남** +- **`dynamic()`** → webpack이 별도 `.js` 청크로 분리. 컴포넌트가 실제 렌더될 때만 네트워크 요청으로 해당 청크 다운로드. **진짜 코드 분할** + +**적용 내역**: + +| 파일 | 대상 | 절감 | +|------|------|------| +| `reports/comprehensive-analysis/page.tsx` | MainDashboard (2,651줄 + recharts) | ~350KB | +| `components/business/Dashboard.tsx` | CEODashboard | ~200KB | +| `construction/ConstructionDashboard.tsx` | ConstructionMainDashboard | ~100KB | +| `production/dashboard/page.tsx` | ProductionDashboard | ~100KB | +| `lib/utils/excel-download.ts` | xlsx 라이브러리 (~400KB) | ~400KB | +| `quotes/LocationListPanel.tsx` | xlsx 직접 import 제거 | (위와 중복) | + +**xlsx 동적 로드 패턴**: +```typescript +// Before: 모든 페이지에 xlsx ~400KB 포함 +import * as XLSX from 'xlsx'; + +// After: 엑셀 버튼 클릭 시에만 로드 +async function loadXLSX() { + return await import('xlsx'); +} +export async function downloadExcel(...) { + const XLSX = await loadXLSX(); + // ... +} +``` + +**총 절감**: 초기 번들에서 ~850KB 제외 (대시보드 미방문 + 엑셀 미사용 시) + +### 테이블 가상화 (react-window) — 보류 (2026-02-10) + +**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 + +**근거**: +1. **페이지네이션 사용 중** — 리스트 페이지 대부분 서버 사이드 페이지네이션 (20~50건/페이지). 50개 ``은 브라우저가 문제없이 처리 +2. **적용 복잡도 높음** — 테이블 헤더 고정, 체크박스 선택, `rowSpan/colSpan` 병합 등 기존 기능과 충돌 가능. DataTable + IntegratedListTemplateV2 + UniversalListPage 전부 수정 필요 +3. **YAGNI** — 500건 이상 한 번에 렌더링하는 페이지가 현재 없음 + +**도입 시점**: 한 페이지에 200건+ 데이터를 페이지네이션 없이 표시해야 하는 요구가 생길 때 + +### SWR / React Query — 보류 (2026-02-10) + +**결정**: 현시점 도입 불필요, 성능 이슈 발생 시 검토 + +**근거**: +1. **기존 패턴 안정화 완료** — `useEffect` + Server Action 호출 패턴이 전 페이지에 일관 적용됨 +2. **캐싱 니즈 낮음** — 폐쇄형 ERP 특성상 항상 최신 데이터 필요. stale 데이터 표시는 오히려 위험 +3. **마스터데이터 캐싱 구현됨** — Zustand (`stores/masterDataStore`)로 변경 빈도 낮은 데이터는 이미 캐싱 중 +4. **도입 비용 과다** — 수십 개 페이지 `useState`+`useEffect` 패턴 전면 리팩토링 + 팀 학습 비용 + +**도입 시점**: 동일 데이터를 여러 컴포넌트에서 동시 요구하거나, 목록 ↔ 상세 이동 시 재로딩이 체감될 때 + +### 컴포넌트 레지스트리 관계도 (2026-02-12) + +**구현**: `/dev/component-registry` 페이지에 관계도(카드형 플로우) 뷰 추가 + +**구성**: +- `actions.ts` — `extractComponentImports()` + `buildRelationships()`로 import 관계 양방향 파싱 (imports/usedBy) +- `ComponentRelationshipView.tsx` — 3칼럼 카드형 플로우 (사용처 → 선택 컴포넌트 → 구성요소) +- `ComponentRegistryClient.tsx` — 목록/관계도 뷰 토글 + +**활용 규칙** (CLAUDE.md에 추가됨): +- 새 컴포넌트 생성 전 → 목록에서 중복 검색 + 관계도에서 조합 패턴 확인 +- 기존 컴포넌트 수정 시 → usedBy로 영향 범위 파악 + +### Action 팩토리 패턴 — 신규 CRUD 적용 규칙 (2026-02-10) + +**결정**: 기존 84개 actions.ts 전면 전환은 하지 않음. **신규 CRUD 도메인에만 팩토리 사용** + +**현황**: +- `src/lib/api/create-crud-service.ts` (177줄) — CRUD 보일러플레이트 자동 생성 팩토리 +- 현재 사용 중: TitleManagement, RankManagement (2개) +- 전환 가능: 15~20개 / 전환 불가 (커스텀 로직): 50+개 + +**규칙**: +- 신규 도메인 추가 시 단순 CRUD → `createCrudService` 사용 필수 +- 기존 actions.ts는 잘 동작하므로 무리하게 전환하지 않음 +- 커스텀 비즈니스 로직이 있는 도메인(견적, 수주, 생산 등)은 팩토리 비적합 + +**사용 예시**: +```typescript +import { createCrudService } from '@/lib/api/create-crud-service'; + +const service = createCrudService({ + basePath: '/api/v1/resources', + transform: (api) => ({ id: api.id, name: api.name }), + entityName: '리소스', +}); + +export const getList = service.getList; +export const getById = service.getById; +export const create = service.create; +export const update = service.update; +export const remove = service.remove; +``` + +**미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음 + +### Server Action 공통 유틸리티 — 전체 마이그레이션 완료 (2026-02-12) + +**결정**: `buildApiUrl()` 전체 43개 actions.ts에 적용 완료 + +**배경**: +- 89개 actions.ts 중 43개에서 동일한 URLSearchParams 조건부 `.set()` 패턴 반복 (326+ 건) +- 50+ 파일에서 `current_page → currentPage` 수동 변환 반복 +- `toPaginationMeta`가 `src/lib/api/types.ts`에 존재하나 import 0건 + +**생성된 유틸리티**: +1. `src/lib/api/query-params.ts` — `buildQueryParams()`, `buildApiUrl()`: URLSearchParams 보일러플레이트 제거 +2. `src/lib/api/execute-paginated-action.ts` — `executePaginatedAction()`: 페이지네이션 조회 패턴 통합 (내부에서 `toPaginationMeta` 사용) + +**마이그레이션 결과** (2026-02-12): +- `new URLSearchParams` 사용: 326건 → **0건** (actions.ts 기준) +- `const API_URL = process.env.NEXT_PUBLIC_API_URL` 선언: 43개 → **0개** (마이그레이션 대상 파일) +- `buildApiUrl()` import: 43개 actions.ts 전체 적용 +- 3가지 API_URL 패턴 통합: 표준(`process.env`), `/api` 접미사(HR), `API_BASE` 전체경로(품질) → 모두 `buildApiUrl('/api/v1/...')` 통일 + +**`executePaginatedAction` 마이그레이션** (2026-02-12): +- 14개 actions.ts에서 페이지네이션 목록 조회 함수를 `executePaginatedAction`으로 전환 +- Wave A (accounting 9개): BillManagement, DepositManagement, SalesManagement, PurchaseManagement, WithdrawalManagement, VendorLedger, CardTransactionInquiry, BankTransactionInquiry, ExpectedExpenseManagement +- Wave B (5개): PaymentHistoryManagement, StockStatus, ReceivingManagement, ShipmentManagement, quotes +- 제외 5개: AccountManagement(`meta` 필드명), orders(`data.items` 중첩), VacationManagement, EmployeeManagement, construction/order-management (별도 구조) +- 순 감소: ~220줄 (14파일 × ~20줄 제거, ~28줄 추가) +- 제거된 보일러플레이트: `DEFAULT_PAGINATION`, `FrontendPagination`/`PaginationMeta` 로컬 인터페이스, `PaginatedApiResponse` import, 수동 transform+pagination 조립 +- **화면 검수 완료** (4개 페이지): Bills, StockStatus, Quotes, Shipments — 전체 PASS +- **버그 발견/수정**: `quotes/actions.ts`에서 `export type { PaginationMeta }` re-export가 Turbopack 런타임 에러 유발 (`tsc`로 미감지) → re-export 제거, 컴포넌트에서 `@/lib/api/types` 직접 import로 변경 + +### `'use server'` 파일 타입 export 제한 (2026-02-12) + +**발견 배경**: `executePaginatedAction` 마이그레이션 화면 검수 중 견적관리 페이지 빌드 에러 + +**제한 사항**: +- `'use server'` 파일에서는 **async 함수만 export 가능** (Next.js Turbopack 제한) +- `export type { X } from '...'` (re-export) → **런타임 에러 발생** +- `export interface X { ... }` / `export type X = ...` (인라인 정의) → **문제 없음** (컴파일 시 제거) +- `tsc --noEmit`으로는 감지 불가 — Next.js 전용 규칙이므로 실제 페이지 접속(Turbopack)에서만 발생 + +**현재 상태**: 전체 81개 `'use server'` 파일 점검 완료, re-export 패턴 0건 (수정된 1건 포함) + +**buildApiUrl 마이그레이션 전략**: +- Wave A: 1건짜리 단순 파일 20개 +- Wave B: 2건짜리 파일 12개 (quotes, WorkOrders, orders 등 대형 파일 포함) +- Wave C: 3건 이상 파일 12개 (VendorLedger 5건, ReceivingManagement 5건, ProcessManagement 19건 URL 등) + +**효과**: +- 페이지네이션 조회 코드: ~20줄 → ~5줄 +- `DEFAULT_PAGINATION` 중앙화 (`execute-paginated-action.ts` 내부) +- `toPaginationMeta` 자동 활용 (직접 import 불필요) +- URL 빌딩 패턴 완전 일관화 (undefined/null/'' 자동 필터링, boolean/number 자동 변환) + +### KST 안전 날짜 유틸리티 — `toISOString` 사용 금지 (2026-02-19) + +**현황**: `new Date().toISOString().split('T')[0]` — 15개 파일 26곳에서 사용 중이었음 + +**문제**: `toISOString()`은 **UTC 기준**으로 변환. 한국(KST, UTC+9)에서 오전 9시 이전에 실행하면 **전날 날짜** 반환 +``` +// 2026-02-19 08:30 KST → UTC는 2026-02-18 23:30 +new Date().toISOString().split('T')[0] // "2026-02-18" ← 잘못됨 +``` + +**결정**: KST 안전 유틸리티 함수로 전량 교체, 직접 `toISOString` 사용 금지 + +**유틸리티** (`src/lib/utils/date.ts`): +| 함수 | 용도 | 대체 대상 | +|------|------|-----------| +| `getTodayString()` | 오늘 날짜 문자열 | `new Date().toISOString().split('T')[0]` | +| `getLocalDateString(date)` | 임의 Date 객체 문자열 | `someDate.toISOString().split('T')[0]` | + +**사용 규칙**: +```typescript +// 올바른 패턴 +import { getTodayString, getLocalDateString } from '@/lib/utils/date'; +const today = getTodayString(); // "2026-02-19" +const thirtyDaysAgo = getLocalDateString(pastDate); // "2026-01-20" + +// 금지 패턴 +const today = new Date().toISOString().split('T')[0]; +``` + +**현재 상태**: `src/` 내 `toISOString().split` 사용 0건 (date.ts 내 구현부 제외) + +### 달력/스케줄 공통 리소스 — 작업 전 필수 확인 (2026-02-23) + +달력·일정·날짜 관련 작업 시 아래 공통 리소스를 **반드시 확인**하고 사용할 것. + +**날짜 유틸리티** (`src/lib/utils/date.ts`): +| 함수 | 용도 | +|------|------| +| `getLocalDateString(date)` | Date → `'YYYY-MM-DD'` (KST 안전) | +| `getTodayString()` | 오늘 날짜 문자열 | +| `formatDate(dateStr)` | 표시용 날짜 포맷 (null → `'-'`) | +| `formatDateForInput(dateStr)` | input용 `YYYY-MM-DD` 변환 | +| `formatDateRange(start, end)` | `'시작 ~ 종료'` 포맷 | +| `getDateAfterDays(n)` | N일 후 날짜 | + +**달력 일정 스토어** (`src/stores/useCalendarScheduleStore.ts`): +- 달력관리(CalendarManagement)에서 등록한 공휴일/세무일정/회사일정을 프로젝트 전체에 공유 +- `fetchSchedules(year)` — 연도별 캐시 조회 (API 호출) +- `setSchedulesForYear(year, data)` — 이미 가져온 데이터 직접 설정 +- `invalidateYear(year)` — 캐시 무효화 (등록/수정/삭제 후) +- **현재 상태**: 백엔드 API 미구현 → 호출부 주석 처리 (TODO 검색) + +**달력 이벤트 유틸** (`src/constants/calendarEvents.ts`): +- `isHoliday(date)`, `isTaxDeadline(date)`, `getHolidayName(date)` 등 +- 스토어 우선 → 하드코딩 폴백(2026년) 패턴 +- 새 연도 폴백 데이터 필요 시 이 파일에 `HOLIDAYS_YYYY`, `TAX_DEADLINES_YYYY` 추가 + +**ScheduleCalendar 공통 컴포넌트** (`src/components/common/ScheduleCalendar/`): +- `hideNavigation` prop으로 헤더 숨김 가능 (연간 달력 등 상위 네비게이션 사용 시) +- `availableViews={[]}` 으로 뷰 전환 버튼 숨김 + +**규칙**: +- `Date → string` 변환 시 `getLocalDateString()` 필수 (`toISOString().split('T')[0]` 금지) +- 공휴일/세무일 판별 시 `calendarEvents.ts` 유틸 함수 사용 +- 달력 데이터 공유 시 zustand 스토어 경유 (컴포넌트 간 직접 전달 금지) + +### `useDateRange` 훅 — 날짜 필터 보일러플레이트 제거 (2026-02-19) + +**현황**: 20+ 리스트 페이지에서 `useState('2025-01-01')` / `useState('2025-12-31')` 하드코딩 + +**문제**: 연도가 바뀌면 수동으로 모든 파일 수정 필요 (2025→2026 전환 시 데이터 미표시 버그 발생) + +**결정**: `useDateRange` 훅으로 동적 날짜 범위 자동 계산 + +**훅** (`src/hooks/useDateRange.ts`): +```typescript +import { useDateRange } from '@/hooks'; + +// 프리셋 +const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); // 2026-01-01 ~ 2026-12-31 +const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth'); // 2026-02-01 ~ 2026-02-28 +const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today'); // 2026-02-19 ~ 2026-02-19 +``` + +**적용 규칙**: +- 리스트 페이지 날짜 필터 → `useDateRange` 필수 사용 +- 연간 조회 → `'currentYear'`, 월간 조회 → `'currentMonth'` +- `useState('YYYY-MM-DD')` 하드코딩 금지 + +**현재 상태**: `useState('2025` 패턴 0건 (전량 `useDateRange`로 전환 완료) + +### Zod 스키마 검증 — 신규 폼 적용 규칙 (2026-02-11) + +**결정**: 기존 폼은 건드리지 않음. **신규 폼에만 Zod + zodResolver 적용** + +**설치 상태**: `zod@^4.1.12`, `@hookform/resolvers@^5.2.2` — 이미 설치됨 + +**효과**: +1. 스키마 하나로 **타입 추론 + 런타임 검증** 동시 해결 (`z.infer`) +2. 별도 `interface` 중복 정의 불필요 +3. 신규 코드에서 `as` 캐스트 자연 감소 (D-2 개선 효과) + +**규칙**: +- 신규 폼 → `zodResolver(schema)` 사용 필수 (CLAUDE.md에 패턴 명시) +- 기존 `rules={{ required: true }}` 패턴 폼 → 마이그레이션 불필요 +- 단순 1~2 필드 인라인 폼 → Zod 불필요 (오버엔지니어링) + +**미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산 diff --git a/claudedocs/item-master/[NEXT-2025-11-26] item-master-api-pending-tasks.md b/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-api-pending-tasks.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-11-26] item-master-api-pending-tasks.md rename to claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-api-pending-tasks.md diff --git a/claudedocs/item-master/[NEXT-2025-11-26] item-master-pending-integration.md b/claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-pending-integration.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-11-26] item-master-pending-integration.md rename to claudedocs/archive/sessions/[NEXT-2025-11-26] item-master-pending-integration.md diff --git a/claudedocs/item-master/[NEXT-2025-12-06] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-06] item-crud-session-context.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-12-06] item-crud-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-06] item-crud-session-context.md diff --git a/claudedocs/sales/[NEXT-2025-12-09] client-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-09] client-session-context.md similarity index 100% rename from claudedocs/sales/[NEXT-2025-12-09] client-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-09] client-session-context.md diff --git a/claudedocs/item-master/[NEXT-2025-12-09] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-09] item-crud-session-context.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-12-09] item-crud-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-09] item-crud-session-context.md diff --git a/claudedocs/item-master/[NEXT-2025-12-10] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-10] item-crud-session-context.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-12-10] item-crud-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-10] item-crud-session-context.md diff --git a/claudedocs/item-master/[NEXT-2025-12-12] item-crud-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-12] item-crud-session-context.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-12-12] item-crud-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-12] item-crud-session-context.md diff --git a/claudedocs/item-master/[NEXT-2025-12-13] item-file-upload-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-13] item-file-upload-session-context.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-12-13] item-file-upload-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-13] item-file-upload-session-context.md diff --git a/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-20] zustand-refactoring-session-context.md similarity index 100% rename from claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-20] zustand-refactoring-session-context.md diff --git a/claudedocs/production/[NEXT-2025-12-22] production-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-22] production-session-context.md similarity index 100% rename from claudedocs/production/[NEXT-2025-12-22] production-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-22] production-session-context.md diff --git a/claudedocs/item-master/[NEXT-2025-12-24] item-master-refactoring-session.md b/claudedocs/archive/sessions/[NEXT-2025-12-24] item-master-refactoring-session.md similarity index 100% rename from claudedocs/item-master/[NEXT-2025-12-24] item-master-refactoring-session.md rename to claudedocs/archive/sessions/[NEXT-2025-12-24] item-master-refactoring-session.md diff --git a/claudedocs/api/[NEXT-2025-12-30] fetch-wrapper-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-30] fetch-wrapper-session-context.md similarity index 100% rename from claudedocs/api/[NEXT-2025-12-30] fetch-wrapper-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-30] fetch-wrapper-session-context.md diff --git a/claudedocs/construction/[NEXT-2025-12-30] partner-management-session-context.md b/claudedocs/archive/sessions/[NEXT-2025-12-30] partner-management-session-context.md similarity index 100% rename from claudedocs/construction/[NEXT-2025-12-30] partner-management-session-context.md rename to claudedocs/archive/sessions/[NEXT-2025-12-30] partner-management-session-context.md diff --git a/claudedocs/[REPORT-2026-02-23] E2E-remaining-bugs-handoff.md b/claudedocs/dev/[REPORT-2026-02-23] E2E-remaining-bugs-handoff.md similarity index 100% rename from claudedocs/[REPORT-2026-02-23] E2E-remaining-bugs-handoff.md rename to claudedocs/dev/[REPORT-2026-02-23] E2E-remaining-bugs-handoff.md diff --git a/claudedocs/[REVIEW] MD-to-PPTX 자동화 파이프라인 검토서.md b/claudedocs/dev/[REVIEW] MD-to-PPTX 자동화 파이프라인 검토서.md similarity index 100% rename from claudedocs/[REVIEW] MD-to-PPTX 자동화 파이프라인 검토서.md rename to claudedocs/dev/[REVIEW] MD-to-PPTX 자동화 파이프라인 검토서.md diff --git a/claudedocs/guides/[ANALYSIS-2025-12-23] common-component-extraction-candidates.md b/claudedocs/guides/migration/[ANALYSIS-2025-12-23] common-component-extraction-candidates.md similarity index 100% rename from claudedocs/guides/[ANALYSIS-2025-12-23] common-component-extraction-candidates.md rename to claudedocs/guides/migration/[ANALYSIS-2025-12-23] common-component-extraction-candidates.md diff --git a/claudedocs/guides/[ANALYSIS] common-component-patterns.md b/claudedocs/guides/migration/[ANALYSIS] common-component-patterns.md similarity index 100% rename from claudedocs/guides/[ANALYSIS] common-component-patterns.md rename to claudedocs/guides/migration/[ANALYSIS] common-component-patterns.md diff --git a/claudedocs/guides/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md b/claudedocs/guides/migration/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md rename to claudedocs/guides/migration/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md diff --git a/claudedocs/guides/[IMPL-2026-01-21] utility-input-migration-checklist.md b/claudedocs/guides/migration/[IMPL-2026-01-21] utility-input-migration-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-01-21] utility-input-migration-checklist.md rename to claudedocs/guides/migration/[IMPL-2026-01-21] utility-input-migration-checklist.md diff --git a/claudedocs/guides/[IMPL-2026-01-23] button-navigation-checklist.md b/claudedocs/guides/migration/[IMPL-2026-01-23] button-navigation-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-01-23] button-navigation-checklist.md rename to claudedocs/guides/migration/[IMPL-2026-01-23] button-navigation-checklist.md diff --git a/claudedocs/guides/[IMPL-2026-01-23] mode-migration-checklist.md b/claudedocs/guides/migration/[IMPL-2026-01-23] mode-migration-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-01-23] mode-migration-checklist.md rename to claudedocs/guides/migration/[IMPL-2026-01-23] mode-migration-checklist.md diff --git a/claudedocs/guides/[IMPL-2026-01-23] mode-navigation-full-checklist.md b/claudedocs/guides/migration/[IMPL-2026-01-23] mode-navigation-full-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-01-23] mode-navigation-full-checklist.md rename to claudedocs/guides/migration/[IMPL-2026-01-23] mode-navigation-full-checklist.md diff --git a/claudedocs/guides/[IMPL-2026-02-06] datepicker-migration-checklist.md b/claudedocs/guides/migration/[IMPL-2026-02-06] datepicker-migration-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-02-06] datepicker-migration-checklist.md rename to claudedocs/guides/migration/[IMPL-2026-02-06] datepicker-migration-checklist.md diff --git a/claudedocs/guides/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md b/claudedocs/guides/migration/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md similarity index 100% rename from claudedocs/guides/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md rename to claudedocs/guides/migration/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md diff --git a/claudedocs/guides/[PLAN-2025-12-23] common-component-extraction-plan.md b/claudedocs/guides/migration/[PLAN-2025-12-23] common-component-extraction-plan.md similarity index 100% rename from claudedocs/guides/[PLAN-2025-12-23] common-component-extraction-plan.md rename to claudedocs/guides/migration/[PLAN-2025-12-23] common-component-extraction-plan.md diff --git a/claudedocs/guides/[REF-2026-01-09] server-to-client-component-migration-checklist.md b/claudedocs/guides/migration/[REF-2026-01-09] server-to-client-component-migration-checklist.md similarity index 100% rename from claudedocs/guides/[REF-2026-01-09] server-to-client-component-migration-checklist.md rename to claudedocs/guides/migration/[REF-2026-01-09] server-to-client-component-migration-checklist.md diff --git a/claudedocs/guides/[FIX-2026-02-04] mobile-zoom-panning.md b/claudedocs/guides/mobile/[FIX-2026-02-04] mobile-zoom-panning.md similarity index 100% rename from claudedocs/guides/[FIX-2026-02-04] mobile-zoom-panning.md rename to claudedocs/guides/mobile/[FIX-2026-02-04] mobile-zoom-panning.md diff --git a/claudedocs/guides/[GUIDE] foldable-device-layout-fix.md b/claudedocs/guides/mobile/[GUIDE] foldable-device-layout-fix.md similarity index 100% rename from claudedocs/guides/[GUIDE] foldable-device-layout-fix.md rename to claudedocs/guides/mobile/[GUIDE] foldable-device-layout-fix.md diff --git a/claudedocs/guides/[GUIDE] mobile-responsive-patterns.md b/claudedocs/guides/mobile/[GUIDE] mobile-responsive-patterns.md similarity index 100% rename from claudedocs/guides/[GUIDE] mobile-responsive-patterns.md rename to claudedocs/guides/mobile/[GUIDE] mobile-responsive-patterns.md diff --git a/claudedocs/guides/[IMPL-2026-01-13] mobile-filter-migration-checklist.md b/claudedocs/guides/mobile/[IMPL-2026-01-13] mobile-filter-migration-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-01-13] mobile-filter-migration-checklist.md rename to claudedocs/guides/mobile/[IMPL-2026-01-13] mobile-filter-migration-checklist.md diff --git a/claudedocs/guides/[PLAN-2026-01-20] mobile-card-infinity-scroll.md b/claudedocs/guides/mobile/[PLAN-2026-01-20] mobile-card-infinity-scroll.md similarity index 100% rename from claudedocs/guides/[PLAN-2026-01-20] mobile-card-infinity-scroll.md rename to claudedocs/guides/mobile/[PLAN-2026-01-20] mobile-card-infinity-scroll.md diff --git a/claudedocs/guides/[PLAN] mobile-overflow-testing.md b/claudedocs/guides/mobile/[PLAN] mobile-overflow-testing.md similarity index 100% rename from claudedocs/guides/[PLAN] mobile-overflow-testing.md rename to claudedocs/guides/mobile/[PLAN] mobile-overflow-testing.md diff --git a/claudedocs/guides/[QA-2026-01-21] mobile-infinity-scroll-inspection.md b/claudedocs/guides/mobile/[QA-2026-01-21] mobile-infinity-scroll-inspection.md similarity index 100% rename from claudedocs/guides/[QA-2026-01-21] mobile-infinity-scroll-inspection.md rename to claudedocs/guides/mobile/[QA-2026-01-21] mobile-infinity-scroll-inspection.md diff --git a/claudedocs/guides/[REF] mobile-zoom-fix-guide.md b/claudedocs/guides/mobile/[REF] mobile-zoom-fix-guide.md similarity index 100% rename from claudedocs/guides/[REF] mobile-zoom-fix-guide.md rename to claudedocs/guides/mobile/[REF] mobile-zoom-fix-guide.md diff --git a/claudedocs/guides/[REF] mobile-zoom-prevention-guide.md b/claudedocs/guides/mobile/[REF] mobile-zoom-prevention-guide.md similarity index 100% rename from claudedocs/guides/[REF] mobile-zoom-prevention-guide.md rename to claudedocs/guides/mobile/[REF] mobile-zoom-prevention-guide.md diff --git a/claudedocs/guides/UniversalListPage-검색기능-수정내역.md b/claudedocs/guides/universal-list/UniversalListPage-검색기능-수정내역.md similarity index 100% rename from claudedocs/guides/UniversalListPage-검색기능-수정내역.md rename to claudedocs/guides/universal-list/UniversalListPage-검색기능-수정내역.md diff --git a/claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md b/claudedocs/guides/universal-list/UniversalListPage-검색리렌더링-해결가이드.md similarity index 100% rename from claudedocs/guides/UniversalListPage-검색리렌더링-해결가이드.md rename to claudedocs/guides/universal-list/UniversalListPage-검색리렌더링-해결가이드.md diff --git a/claudedocs/guides/[DESIGN-2026-01-14] universal-list-component.md b/claudedocs/guides/universal-list/[DESIGN-2026-01-14] universal-list-component.md similarity index 100% rename from claudedocs/guides/[DESIGN-2026-01-14] universal-list-component.md rename to claudedocs/guides/universal-list/[DESIGN-2026-01-14] universal-list-component.md diff --git a/claudedocs/guides/[IMPL-2026-01-14] universal-list-component-checklist.md b/claudedocs/guides/universal-list/[IMPL-2026-01-14] universal-list-component-checklist.md similarity index 100% rename from claudedocs/guides/[IMPL-2026-01-14] universal-list-component-checklist.md rename to claudedocs/guides/universal-list/[IMPL-2026-01-14] universal-list-component-checklist.md diff --git a/claudedocs/guides/[PLAN] universal-detail-component.md b/claudedocs/guides/universal-list/[PLAN] universal-detail-component.md similarity index 100% rename from claudedocs/guides/[PLAN] universal-detail-component.md rename to claudedocs/guides/universal-list/[PLAN] universal-detail-component.md diff --git a/claudedocs/guides/[QA-2026-01-15] universal-list-page-inspection.md b/claudedocs/guides/universal-list/[QA-2026-01-15] universal-list-page-inspection.md similarity index 100% rename from claudedocs/guides/[QA-2026-01-15] universal-list-page-inspection.md rename to claudedocs/guides/universal-list/[QA-2026-01-15] universal-list-page-inspection.md diff --git a/claudedocs/guides/[REF] UniversalListPage-QA-patterns.md b/claudedocs/guides/universal-list/[REF] UniversalListPage-QA-patterns.md similarity index 100% rename from claudedocs/guides/[REF] UniversalListPage-QA-patterns.md rename to claudedocs/guides/universal-list/[REF] UniversalListPage-QA-patterns.md diff --git a/claudedocs/[IMPL-2026-02-19] frontend-improvement-checklist.md b/claudedocs/refactoring/[IMPL-2026-02-19] frontend-improvement-checklist.md similarity index 100% rename from claudedocs/[IMPL-2026-02-19] frontend-improvement-checklist.md rename to claudedocs/refactoring/[IMPL-2026-02-19] frontend-improvement-checklist.md diff --git a/claudedocs/[REF-2026-02-19] code-dedup-commonization-checklist.md b/claudedocs/refactoring/[REF-2026-02-19] code-dedup-commonization-checklist.md similarity index 100% rename from claudedocs/[REF-2026-02-19] code-dedup-commonization-checklist.md rename to claudedocs/refactoring/[REF-2026-02-19] code-dedup-commonization-checklist.md diff --git a/src/components/ThemeSelect.tsx b/src/components/ThemeSelect.tsx index 1a2e206e..46616355 100644 --- a/src/components/ThemeSelect.tsx +++ b/src/components/ThemeSelect.tsx @@ -1,6 +1,6 @@ "use client"; -import { useThemeStore } from "@/stores/themeStore"; +import { useTheme, useSetTheme } from "@/stores/themeStore"; import { Select, SelectContent, @@ -21,7 +21,8 @@ interface ThemeSelectProps { } export function ThemeSelect({ native = true }: ThemeSelectProps) { - const { theme, setTheme } = useThemeStore(); + const theme = useTheme(); + const setTheme = useSetTheme(); const currentTheme = themes.find((t) => t.value === theme); const CurrentIcon = currentTheme?.icon || Sun; diff --git a/src/components/accounting/TaxInvoiceManagement/index.tsx b/src/components/accounting/TaxInvoiceManagement/index.tsx index 356641c0..04ded406 100644 --- a/src/components/accounting/TaxInvoiceManagement/index.tsx +++ b/src/components/accounting/TaxInvoiceManagement/index.tsx @@ -406,7 +406,10 @@ export function TaxInvoiceManagement() { {formatNumber(item.taxAmount)} {formatNumber(item.totalAmount)} - {RECEIPT_TYPE_LABELS[item.receiptType]} + + + {RECEIPT_TYPE_LABELS[item.receiptType]} + {item.documentNumber || '-'} {INVOICE_SOURCE_LABELS[item.source]} @@ -463,12 +466,12 @@ export function TaxInvoiceManagement() {
-
-
+
+
수기 세금계산서
-
-
+
+
홈택스 연동 세금계산서
diff --git a/src/components/approval/DocumentCreate/index.tsx b/src/components/approval/DocumentCreate/index.tsx index 4d92b93b..92a6847b 100644 --- a/src/components/approval/DocumentCreate/index.tsx +++ b/src/components/approval/DocumentCreate/index.tsx @@ -23,7 +23,7 @@ import { deleteApproval, getEmployees, } from './actions'; -import { useAuth } from '@/contexts/AuthContext'; +import { useAuthStore } from '@/stores/authStore'; import { Button } from '@/components/ui/button'; import { BasicInfoSection } from './BasicInfoSection'; import { ApprovalLineSection } from './ApprovalLineSection'; @@ -88,7 +88,7 @@ export function DocumentCreate() { const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); - const { currentUser } = useAuth(); + const currentUser = useAuthStore((state) => state.currentUser); const { canCreate, canDelete } = usePermission(); // 수정 모드 / 복제 모드 상태 diff --git a/src/components/business/construction/estimates/EstimateDetailForm.tsx b/src/components/business/construction/estimates/EstimateDetailForm.tsx index 95467636..9789e0c7 100644 --- a/src/components/business/construction/estimates/EstimateDetailForm.tsx +++ b/src/components/business/construction/estimates/EstimateDetailForm.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions'; import { createBiddingFromEstimate } from '../bidding/actions'; -import { useAuth } from '@/contexts/AuthContext'; +import { useAuthStore } from '@/stores/authStore'; import { Button } from '@/components/ui/button'; import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; @@ -49,7 +49,7 @@ export default function EstimateDetailForm({ initialData, }: EstimateDetailFormProps) { const router = useRouter(); - const { currentUser } = useAuth(); + const currentUser = useAuthStore((state) => state.currentUser); const isViewMode = mode === 'view'; const isEditMode = mode === 'edit'; diff --git a/src/components/layout/CommandMenuSearch.tsx b/src/components/layout/CommandMenuSearch.tsx index 88452f5e..981b7ff4 100644 --- a/src/components/layout/CommandMenuSearch.tsx +++ b/src/components/layout/CommandMenuSearch.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react'; import { useRouter } from 'next/navigation'; -import { useMenuStore, type MenuItem } from '@/stores/menuStore'; +import { useMenuItems, type MenuItem } from '@/stores/menuStore'; import { CommandDialog, CommandInput, @@ -65,7 +65,7 @@ const CommandMenuSearch = forwardRef((_, ref) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const router = useRouter(); - const { menuItems } = useMenuStore(); + const menuItems = useMenuItems(); // 외부에서 제어할 수 있도록 ref 노출 useImperativeHandle(ref, () => ({ diff --git a/src/components/molecules/GenericCRUDDialog.tsx b/src/components/molecules/GenericCRUDDialog.tsx new file mode 100644 index 00000000..34ba14a5 --- /dev/null +++ b/src/components/molecules/GenericCRUDDialog.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; + +/** + * 필드 정의 + */ +export interface CRUDFieldDefinition { + key: string; + label: string; + type: 'text' | 'select'; + placeholder?: string; + options?: { value: string; label: string }[]; + defaultValue?: string; +} + +export interface GenericCRUDDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + mode: 'add' | 'edit'; + entityName: string; + fields: CRUDFieldDefinition[]; + initialData?: Record; + onSubmit: (data: Record) => void; + isLoading?: boolean; + addLabel?: string; + editLabel?: string; +} + +/** + * 단순 CRUD 다이얼로그 공통 컴포넌트 + * + * 텍스트 입력 + Select 조합의 단순 폼 다이얼로그를 생성합니다. + * RankDialog, TitleDialog 등 동일 패턴의 다이얼로그를 대체합니다. + */ +export function GenericCRUDDialog({ + isOpen, + onOpenChange, + mode, + entityName, + fields, + initialData, + onSubmit, + isLoading = false, + addLabel = '등록', + editLabel = '수정', +}: GenericCRUDDialogProps) { + const [formData, setFormData] = useState>({}); + + useEffect(() => { + if (isOpen) { + if (mode === 'edit' && initialData) { + setFormData({ ...initialData }); + } else { + const defaults: Record = {}; + fields.forEach((f) => { + defaults[f.key] = f.defaultValue ?? ''; + }); + setFormData(defaults); + } + } + }, [isOpen, mode, initialData, fields]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const firstTextField = fields.find((f) => f.type === 'text'); + if (firstTextField && !formData[firstTextField.key]?.trim()) return; + + const trimmed: Record = {}; + Object.entries(formData).forEach(([k, v]) => { + trimmed[k] = v.trim(); + }); + onSubmit(trimmed); + + const defaults: Record = {}; + fields.forEach((f) => { + defaults[f.key] = f.defaultValue ?? ''; + }); + setFormData(defaults); + }; + + const title = mode === 'add' ? `${entityName} 추가` : `${entityName} 수정`; + const submitText = mode === 'add' ? addLabel : editLabel; + + const firstTextField = fields.find((f) => f.type === 'text'); + const isSubmitDisabled = + isLoading || (firstTextField ? !formData[firstTextField.key]?.trim() : false); + + return ( + + + + {title} + + +
+
+ {fields.map((field, idx) => ( +
+ + {field.type === 'text' ? ( + + setFormData((prev) => ({ ...prev, [field.key]: e.target.value })) + } + placeholder={field.placeholder} + autoFocus={idx === 0} + disabled={isLoading} + /> + ) : ( + + )} +
+ ))} +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index bbde8a3a..0d81d69a 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -10,4 +10,7 @@ export { StandardDialog } from "./StandardDialog"; export type { StandardDialogProps } from "./StandardDialog"; export { YearQuarterFilter } from "./YearQuarterFilter"; -export type { Quarter } from "./YearQuarterFilter"; \ No newline at end of file +export type { Quarter } from "./YearQuarterFilter"; + +export { GenericCRUDDialog } from "./GenericCRUDDialog"; +export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog"; \ No newline at end of file diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index ab79868b..2f005a1b 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -15,7 +15,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import dynamic from 'next/dynamic'; -import { useMenuStore } from '@/stores/menuStore'; +import { useSidebarCollapsed } from '@/stores/menuStore'; import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react'; import { Dialog, @@ -324,7 +324,7 @@ const PROCESS_STEPS: Record([]); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTab] = useState(''); diff --git a/src/components/settings/PermissionManagement/PermissionDialog.tsx b/src/components/settings/PermissionManagement/PermissionDialog.tsx deleted file mode 100644 index 61c7757b..00000000 --- a/src/components/settings/PermissionManagement/PermissionDialog.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import type { PermissionDialogProps } from './types'; - -/** - * 권한 추가/수정 다이얼로그 - */ -export function PermissionDialog({ - isOpen, - onOpenChange, - mode, - permission, - onSubmit -}: PermissionDialogProps) { - const [name, setName] = useState(''); - const [status, setStatus] = useState<'active' | 'hidden'>('active'); - - // 다이얼로그 열릴 때 초기값 설정 - useEffect(() => { - if (isOpen) { - if (mode === 'edit' && permission) { - setName(permission.name); - setStatus(permission.status); - } else { - setName(''); - setStatus('active'); - } - } - }, [isOpen, mode, permission]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - onSubmit({ name: name.trim(), status }); - setName(''); - setStatus('active'); - } - }; - - const dialogTitle = mode === 'add' ? '권한 등록' : '권한 수정'; - const submitText = mode === 'add' ? '등록' : '수정'; - - return ( - - - - {dialogTitle} - - -
-
- {/* 권한명 입력 */} -
- - setName(e.target.value)} - placeholder="권한명을 입력하세요" - autoFocus - /> -
- - {/* 상태 선택 */} -
- - -
-
- - - - - -
-
-
- ); -} \ No newline at end of file diff --git a/src/components/settings/RankManagement/RankDialog.tsx b/src/components/settings/RankManagement/RankDialog.tsx index c4bd2883..ffca2132 100644 --- a/src/components/settings/RankManagement/RankDialog.tsx +++ b/src/components/settings/RankManagement/RankDialog.tsx @@ -1,19 +1,13 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Loader2 } from 'lucide-react'; +import { useMemo } from 'react'; +import { GenericCRUDDialog, type CRUDFieldDefinition } from '@/components/molecules/GenericCRUDDialog'; import type { RankDialogProps } from './types'; +const RANK_FIELDS: CRUDFieldDefinition[] = [ + { key: 'name', label: '직급명', type: 'text', placeholder: '직급명을 입력하세요' }, +]; + /** * 직급 추가/수정 다이얼로그 */ @@ -25,65 +19,21 @@ export function RankDialog({ onSubmit, isLoading = false, }: RankDialogProps) { - const [name, setName] = useState(''); - - // 다이얼로그 열릴 때 초기값 설정 - useEffect(() => { - if (isOpen) { - if (mode === 'edit' && rank) { - setName(rank.name); - } else { - setName(''); - } - } - }, [isOpen, mode, rank]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - onSubmit(name.trim()); - setName(''); - } - }; - - const title = mode === 'add' ? '직급 추가' : '직급 수정'; - const submitText = mode === 'add' ? '등록' : '수정'; + const initialData = useMemo( + () => (rank ? { name: rank.name } : undefined), + [rank] + ); return ( - - - - {title} - - -
-
- {/* 직급명 입력 */} -
- - setName(e.target.value)} - placeholder="직급명을 입력하세요" - autoFocus - /> -
-
- - - - - -
-
-
+ onSubmit(data.name)} + isLoading={isLoading} + /> ); } \ No newline at end of file diff --git a/src/components/settings/TitleManagement/TitleDialog.tsx b/src/components/settings/TitleManagement/TitleDialog.tsx index 7329156f..80825a1f 100644 --- a/src/components/settings/TitleManagement/TitleDialog.tsx +++ b/src/components/settings/TitleManagement/TitleDialog.tsx @@ -1,19 +1,13 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Loader2 } from 'lucide-react'; +import { useMemo } from 'react'; +import { GenericCRUDDialog, type CRUDFieldDefinition } from '@/components/molecules/GenericCRUDDialog'; import type { TitleDialogProps } from './types'; +const TITLE_FIELDS: CRUDFieldDefinition[] = [ + { key: 'name', label: '직책명', type: 'text', placeholder: '직책명을 입력하세요' }, +]; + /** * 직책 추가/수정 다이얼로그 */ @@ -25,66 +19,21 @@ export function TitleDialog({ onSubmit, isLoading = false, }: TitleDialogProps) { - const [name, setName] = useState(''); - - // 다이얼로그 열릴 때 초기값 설정 - useEffect(() => { - if (isOpen) { - if (mode === 'edit' && title) { - setName(title.name); - } else { - setName(''); - } - } - }, [isOpen, mode, title]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - onSubmit(name.trim()); - setName(''); - } - }; - - const dialogTitle = mode === 'add' ? '직책 추가' : '직책 수정'; - const submitText = mode === 'add' ? '등록' : '수정'; + const initialData = useMemo( + () => (title ? { name: title.name } : undefined), + [title] + ); return ( - - - - {dialogTitle} - - -
-
- {/* 직책명 입력 */} -
- - setName(e.target.value)} - placeholder="직책명을 입력하세요" - autoFocus - disabled={isLoading} - /> -
-
- - - - - -
-
-
+ onSubmit(data.name)} + isLoading={isLoading} + /> ); } \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index ac365128..9ebf0403 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,277 +1,25 @@ 'use client'; -import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; -import { performFullLogout } from '@/lib/auth/logout'; -import { useMasterDataStore } from '@/stores/masterDataStore'; +/** + * AuthContext - 하위호환 re-export 심 + * + * 실제 구현은 src/stores/authStore.ts (Zustand)로 이동됨. + * 기존 import { useAuth } from '@/contexts/AuthContext' 코드가 + * 깨지지 않도록 타입과 훅을 re-export. + */ -// ===== 타입 정의 ===== +import { type ReactNode } from 'react'; +import { useAuthStore } from '@/stores/authStore'; -// ✅ 추가: 테넌트 타입 (실제 서버 응답 구조) -export interface Tenant { - id: number; // 테넌트 고유 ID (number) - company_name: string; // 회사명 - business_num: string; // 사업자번호 - tenant_st_code: string; // 테넌트 상태 코드 (trial, active 등) - options?: { // 테넌트 옵션 (선택) - company_scale?: string; // 회사 규모 - industry?: string; // 업종 - }; -} - -// ✅ 추가: 권한 타입 -export interface Role { - id: number; - name: string; - description: string; -} - -// ✅ 추가: 메뉴 아이템 타입 -export interface MenuItem { - id: string; - label: string; - iconName: string; - path: string; -} - -// ✅ 수정: User 타입을 실제 서버 응답에 맞게 변경 -export interface User { - userId: string; // 사용자 ID (username 아님) - name: string; // 사용자 이름 - position: string; // 직책 - roles: Role[]; // 권한 목록 (배열) - tenant: Tenant; // ✅ 테넌트 정보 (필수!) - menu: MenuItem[]; // 메뉴 목록 -} - -// ❌ 삭제 예정: 기존 UserRole (더 이상 사용하지 않음) -export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales'; - -// ===== Context 타입 ===== - -interface AuthContextType { - users: User[]; - currentUser: User | null; - setCurrentUser: (user: User | null) => void; - addUser: (user: User) => void; - updateUser: (userId: string, updates: Partial) => void; - deleteUser: (userId: string) => void; - getUserByUserId: (userId: string) => User | undefined; - logout: () => Promise; // ✅ 추가: 로그아웃 (완전한 캐시 정리) - clearTenantCache: (tenantId: number) => void; // ✅ 추가: 테넌트 캐시 삭제 - resetAllData: () => void; -} - -// ===== 초기 데이터 ===== - -const initialUsers: User[] = [ - { - userId: "TestUser1", - name: "김대표", - position: "대표이사", - roles: [ - { - id: 1, - name: "ceo", - description: "최고경영자" - } - ], - tenant: { - id: 282, - company_name: "(주)테크컴퍼니", - business_num: "123-45-67890", - tenant_st_code: "trial" - }, - menu: [ - { - id: "13664", - label: "시스템 대시보드", - iconName: "layout-dashboard", - path: "/dashboard" - } - ] - }, - { - userId: "TestUser2", - name: "박관리", - position: "생산관리자", - roles: [ - { - id: 2, - name: "production_manager", - description: "생산관리자" - } - ], - tenant: { - id: 282, - company_name: "(주)테크컴퍼니", - business_num: "123-45-67890", - tenant_st_code: "trial" - }, - menu: [ - { - id: "13664", - label: "시스템 대시보드", - iconName: "layout-dashboard", - path: "/dashboard" - } - ] - }, - { - userId: "TestUser3", - name: "드미트리", - position: "시스템 관리자", - roles: [ - { - id: 19, - name: "system_manager", - description: "시스템 관리자" - } - ], - tenant: { - id: 282, - company_name: "(주)테크컴퍼니", - business_num: "123-45-67890", - tenant_st_code: "trial" - }, - menu: [ - { - id: "13664", - label: "시스템 대시보드", - iconName: "layout-dashboard", - path: "/dashboard" - } - ] - } -]; - -// ===== Context 생성 ===== - -const AuthContext = createContext(undefined); - -// ===== Provider 컴포넌트 ===== +// 타입 re-export +export type { Tenant, Role, MenuItem, User, UserRole } from '@/stores/authStore'; +// AuthProvider: 빈 passthrough (미발견 import 안전망) export function AuthProvider({ children }: { children: ReactNode }) { - // 상태 관리 (SSR-safe: 항상 초기값으로 시작) - const [users, setUsers] = useState(initialUsers); - const [currentUser, setCurrentUser] = useState(null); - - // ✅ 추가: 이전 tenant.id 추적 (테넌트 전환 감지용) - const previousTenantIdRef = useRef(null); - - // localStorage에서 초기 데이터 로드 (클라이언트에서만 실행) - useEffect(() => { - try { - const savedUsers = localStorage.getItem('mes-users'); - if (savedUsers) { - setUsers(JSON.parse(savedUsers)); - } - - const savedCurrentUser = localStorage.getItem('mes-currentUser'); - if (savedCurrentUser) { - setCurrentUser(JSON.parse(savedCurrentUser)); - } - } catch (error) { - console.error('Failed to load auth data from localStorage:', error); - // 손상된 데이터 제거 - localStorage.removeItem('mes-users'); - localStorage.removeItem('mes-currentUser'); - } - }, []); - - // localStorage 동기화 (상태 변경 시 자동 저장) - useEffect(() => { - localStorage.setItem('mes-users', JSON.stringify(users)); - }, [users]); - - useEffect(() => { - if (currentUser) { - localStorage.setItem('mes-currentUser', JSON.stringify(currentUser)); - } - }, [currentUser]); - - // ✅ 추가: 테넌트 전환 감지 - useEffect(() => { - const prevTenantId = previousTenantIdRef.current; - const currentTenantId = currentUser?.tenant?.id; - - if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) { - clearTenantCache(prevTenantId); - } - - previousTenantIdRef.current = currentTenantId || null; - }, [currentUser?.tenant?.id]); - - // ✅ 추가: masterDataStore에 현재 테넌트 ID 동기화 - useEffect(() => { - const tenantId = currentUser?.tenant?.id ?? null; - useMasterDataStore.getState().setCurrentTenantId(tenantId); - }, [currentUser?.tenant?.id]); - - // ✅ 추가: 테넌트별 캐시 삭제 함수 (SSR-safe) - const clearTenantCache = (tenantId: number) => { - // 서버 환경에서는 실행 안함 - if (typeof window === 'undefined') return; - - const tenantAwarePrefix = `mes-${tenantId}-`; - const pageConfigPrefix = `page_config_${tenantId}_`; - - // localStorage 캐시 삭제 - Object.keys(localStorage).forEach(key => { - if (key.startsWith(tenantAwarePrefix)) { - localStorage.removeItem(key); - } - }); - - // sessionStorage 캐시 삭제 (TenantAwareCache + masterDataStore) - Object.keys(sessionStorage).forEach(key => { - if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) { - sessionStorage.removeItem(key); - } - }); - }; - - // ✅ 추가: 로그아웃 함수 (완전한 캐시 정리) - const logout = async () => { - - // 1. React 상태 초기화 (UI 즉시 반영) - setCurrentUser(null); - - // 2. 완전한 로그아웃 수행 (Zustand, sessionStorage, localStorage, 서버 API) - await performFullLogout({ - skipServerLogout: false, // 서버 API 호출 (HttpOnly 쿠키 삭제) - redirectTo: null, // 리다이렉트는 호출하는 곳에서 처리 - }); - - }; - - // Context value - const value: AuthContextType = { - users, - currentUser, - setCurrentUser, - addUser: (user) => setUsers(prev => [...prev, user]), - updateUser: (userId, updates) => setUsers(prev => - prev.map(user => user.userId === userId ? { ...user, ...updates } : user) - ), - deleteUser: (userId) => setUsers(prev => prev.filter(user => user.userId !== userId)), - getUserByUserId: (userId) => users.find(user => user.userId === userId), - logout, - clearTenantCache, - resetAllData: () => { - setUsers(initialUsers); - setCurrentUser(null); - } - }; - - return {children}; + return <>{children}; } -// ===== Custom Hook ===== - +// useAuth: authStore 전체 상태를 반환 (기존 Context 인터페이스 유지) export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; + return useAuthStore(); } diff --git a/src/contexts/ItemMasterContext.tsx b/src/contexts/ItemMasterContext.tsx index 546b3b0e..29e96c5c 100644 --- a/src/contexts/ItemMasterContext.tsx +++ b/src/contexts/ItemMasterContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react'; -import { useAuth } from './AuthContext'; +import { useAuthStore } from '@/stores/authStore'; import { TenantAwareCache } from '@/lib/cache'; import { itemMasterApi } from '@/lib/api/item-master'; import { getErrorMessage, ApiError } from '@/lib/api/error-handler'; @@ -224,7 +224,7 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) { const initialItemPages: ItemPage[] = []; // ===== Auth & Cache Setup ===== - const { currentUser } = useAuth(); + const currentUser = useAuthStore((state) => state.currentUser); const tenantId = currentUser?.tenant?.id; // ✅ TenantAwareCache 인스턴스 생성 (tenant.id 기반, SSR-safe) diff --git a/src/contexts/RootProvider.tsx b/src/contexts/RootProvider.tsx index fbfc6ab9..70099d46 100644 --- a/src/contexts/RootProvider.tsx +++ b/src/contexts/RootProvider.tsx @@ -1,15 +1,14 @@ 'use client'; import { ReactNode } from 'react'; -import { AuthProvider } from './AuthContext'; import { PermissionProvider } from './PermissionContext'; import { ItemMasterProvider } from './ItemMasterContext'; /** * RootProvider - 모든 Context Provider를 통합하는 최상위 Provider * - * 현재 사용 중인 Context: - * 1. AuthContext - 사용자/인증 (2개 상태) + * 현재 사용 중인 Context/Store: + * 1. authStore (Zustand) - 사용자/인증 (Provider 불필요) * 2. PermissionContext - 권한 관리 (URL 자동매칭) * 3. ItemMasterContext - 품목관리 (13개 상태) * @@ -19,13 +18,11 @@ import { ItemMasterProvider } from './ItemMasterContext'; */ export function RootProvider({ children }: { children: ReactNode }) { return ( - - - - {children} - - - + + + {children} + + ); } diff --git a/src/hooks/useColumnSettings.ts b/src/hooks/useColumnSettings.ts index d7aa9ab3..e7d8ad5a 100644 --- a/src/hooks/useColumnSettings.ts +++ b/src/hooks/useColumnSettings.ts @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react'; -import { useTableColumnStore } from '@/stores/useTableColumnStore'; +import { useTableColumnStore, usePageColumnSettings } from '@/stores/useTableColumnStore'; import type { TableColumn } from '@/components/templates/UniversalListPage/types'; export interface ColumnWithVisibility extends TableColumn { @@ -14,8 +14,10 @@ interface UseColumnSettingsParams { } export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: UseColumnSettingsParams) { - const store = useTableColumnStore(); - const settings = store.getPageSettings(pageId); + const settings = usePageColumnSettings(pageId); + const setColumnWidthAction = useTableColumnStore((s) => s.setColumnWidth); + const toggleColumnVisibilityAction = useTableColumnStore((s) => s.toggleColumnVisibility); + const resetPageSettingsAction = useTableColumnStore((s) => s.resetPageSettings); const visibleColumns = useMemo(() => { return columns.filter((col) => !settings.hiddenColumns.includes(col.key)); @@ -33,22 +35,22 @@ export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: U const setColumnWidth = useCallback( (key: string, width: number) => { - store.setColumnWidth(pageId, key, width); + setColumnWidthAction(pageId, key, width); }, - [store, pageId] + [setColumnWidthAction, pageId] ); const toggleColumnVisibility = useCallback( (key: string) => { if (alwaysVisibleKeys.includes(key)) return; - store.toggleColumnVisibility(pageId, key); + toggleColumnVisibilityAction(pageId, key); }, - [store, pageId, alwaysVisibleKeys] + [toggleColumnVisibilityAction, pageId, alwaysVisibleKeys] ); const resetSettings = useCallback(() => { - store.resetPageSettings(pageId); - }, [store, pageId]); + resetPageSettingsAction(pageId); + }, [resetPageSettingsAction, pageId]); const hasHiddenColumns = settings.hiddenColumns.length > 0; diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index ed99efef..cd9ea21c 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMenuStore } from '@/stores/menuStore'; +import { useMenuStore, useMenuItems, useActiveMenu, useSidebarCollapsed, useMenuHydrated } from '@/stores/menuStore'; import type { SerializableMenuItem } from '@/stores/menuStore'; import type { MenuItem } from '@/stores/menuStore'; import { useRouter, usePathname } from 'next/navigation'; @@ -43,8 +43,8 @@ import { import Sidebar from '@/components/layout/Sidebar'; import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar'; import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch'; -import { useThemeStore } from '@/stores/themeStore'; -import { useAuth } from '@/contexts/AuthContext'; +import { useTheme, useSetTheme } from '@/stores/themeStore'; +import { useAuthStore } from '@/stores/authStore'; import { deserializeMenuItems } from '@/lib/utils/menuTransform'; import { stripLocalePrefix } from '@/lib/utils/locale'; import { safeJsonParse } from '@/lib/utils'; @@ -96,9 +96,16 @@ interface AuthenticatedLayoutProps { } export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) { - const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore(); - const { theme, setTheme } = useThemeStore(); - const { logout } = useAuth(); + const menuItems = useMenuItems(); + const activeMenu = useActiveMenu(); + const sidebarCollapsed = useSidebarCollapsed(); + const _hasHydrated = useMenuHydrated(); + const setActiveMenu = useMenuStore((s) => s.setActiveMenu); + const setMenuItems = useMenuStore((s) => s.setMenuItems); + const toggleSidebar = useMenuStore((s) => s.toggleSidebar); + const theme = useTheme(); + const setTheme = useSetTheme(); + const logout = useAuthStore((state) => state.logout); const router = useRouter(); const pathname = usePathname(); // 현재 경로 추적 diff --git a/src/lib/api/toast-utils.ts b/src/lib/api/toast-utils.ts deleted file mode 100644 index 64febbe8..00000000 --- a/src/lib/api/toast-utils.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * API 에러 토스트 유틸리티 - * - 개발 중 디버깅을 위해 에러 코드와 메시지를 함께 표시 - * - 나중에 프로덕션에서 코드 숨기려면 이 파일만 수정하면 됨 - */ -import { toast } from 'sonner'; -import { ApiError, DuplicateCodeError, getErrorMessage } from './error-handler'; - -/** - * 디버그 모드 설정 - * - true: 에러 코드 표시 (개발/테스트) - * - false: 메시지만 표시 (프로덕션) - * - * TODO: 프로덕션 배포 시 false로 변경하거나 환경변수 사용 - */ -const SHOW_ERROR_CODE = true; - -/** - * API 에러를 토스트로 표시 - * - ApiError: [상태코드] 메시지 형식 - * - DuplicateCodeError: 중복 코드 정보 포함 - * - 일반 Error: 메시지만 표시 - * - * @param error - 발생한 에러 객체 - * @param fallbackMessage - 에러 메시지가 없을 때 표시할 기본 메시지 - */ -export function toastApiError( - error: unknown, - fallbackMessage = '오류가 발생했습니다.' -): void { - // DuplicateCodeError - 중복 코드 에러 (별도 처리 필요할 수 있음) - if (error instanceof DuplicateCodeError) { - const message = SHOW_ERROR_CODE - ? `[${error.status}] ${error.message} (코드: ${error.duplicateCode})` - : error.message; - toast.error(message); - return; - } - - // ApiError - HTTP 에러 - if (error instanceof ApiError) { - const message = SHOW_ERROR_CODE - ? `[${error.status}] ${error.message}` - : error.message; - - // Validation 에러가 있으면 첫 번째 에러도 표시 - if (error.errors && SHOW_ERROR_CODE) { - const firstErrorField = Object.keys(error.errors)[0]; - if (firstErrorField) { - const firstError = error.errors[firstErrorField][0]; - toast.error(`${message}\n${firstErrorField}: ${firstError}`); - return; - } - } - - toast.error(message); - return; - } - - // 일반 Error - if (error instanceof Error) { - toast.error(error.message || fallbackMessage); - return; - } - - // unknown 타입 - toast.error(fallbackMessage); -} - -/** - * API 성공 토스트 - * - 일관된 성공 메시지 표시 - * - * @param message - 성공 메시지 - */ -export function toastSuccess(message: string): void { - toast.success(message); -} - -/** - * API 경고 토스트 - * - * @param message - 경고 메시지 - */ -export function toastWarning(message: string): void { - toast.warning(message); -} - -/** - * API 정보 토스트 - * - * @param message - 정보 메시지 - */ -export function toastInfo(message: string): void { - toast.info(message); -} - -/** - * 에러 메시지 포맷팅 (토스트 외 용도) - * - 에러 코드 포함 여부는 SHOW_ERROR_CODE 설정 따름 - * - * @param error - 발생한 에러 객체 - * @param fallbackMessage - 기본 메시지 - * @returns 포맷팅된 에러 메시지 - */ -export function formatApiError( - error: unknown, - fallbackMessage = '오류가 발생했습니다.' -): string { - if (error instanceof ApiError) { - return SHOW_ERROR_CODE - ? `[${error.status}] ${error.message}` - : error.message; - } - return getErrorMessage(error) || fallbackMessage; -} \ No newline at end of file diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts index cffad69c..b5393017 100644 --- a/src/lib/auth/logout.ts +++ b/src/lib/auth/logout.ts @@ -13,6 +13,7 @@ import { useMasterDataStore } from '@/stores/masterDataStore'; import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore'; +import { useAuthStore } from '@/stores/authStore'; // FCM은 Capacitor 환경에서만 사용 (동적 import로 웹 빌드 에러 방지) @@ -87,6 +88,9 @@ export function clearLocalStorageCache(): void { */ export function resetZustandStores(): void { try { + // authStore 초기화 + useAuthStore.getState().resetAllData(); + // masterDataStore 초기화 const masterDataStore = useMasterDataStore.getState(); masterDataStore.reset(); diff --git a/src/lib/utils/excel-download.ts b/src/lib/utils/excel-download.ts index 6bb58966..1e89b5d6 100644 --- a/src/lib/utils/excel-download.ts +++ b/src/lib/utils/excel-download.ts @@ -23,6 +23,7 @@ */ import { getTodayString } from '@/lib/utils/date'; +import { generateExportFilename } from '@/lib/utils/export'; // xlsx는 ~400KB로 무거워서, 실제 사용 시점에 동적 로드 async function loadXLSX() { @@ -74,17 +75,13 @@ function getNestedValue(obj: Record, path: string): unknown { /** * 날짜 형식의 파일명 생성 + * export.ts의 generateExportFilename에 위임 */ function generateFilename(baseName: string, appendDate: boolean): string { if (!appendDate) { return `${baseName}.xlsx`; } - - const now = new Date(); - const dateStr = now.toISOString().slice(0, 10).replace(/-/g, ''); - const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, ''); - - return `${baseName}_${dateStr}_${timeStr}.xlsx`; + return generateExportFilename(baseName, 'xlsx'); } /** diff --git a/src/lib/utils/fileDownload.ts b/src/lib/utils/fileDownload.ts index 5259fa32..ad29b55c 100644 --- a/src/lib/utils/fileDownload.ts +++ b/src/lib/utils/fileDownload.ts @@ -5,6 +5,26 @@ * 프록시: GET /api/proxy/files/{id}/download */ +import { downloadBlob } from './export'; + +/** + * Content-Disposition 헤더에서 파일명 추출 + */ +function extractFilenameFromHeader(response: Response): string | null { + const contentDisposition = response.headers.get('Content-Disposition'); + if (!contentDisposition) return null; + + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (!match?.[1]) return null; + + const raw = match[1].replace(/['"]/g, ''); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + /** * 파일 ID로 다운로드 * @param fileId 파일 ID @@ -19,40 +39,11 @@ export async function downloadFileById(fileId: number, fileName?: string): Promi } const blob = await response.blob(); + const downloadFileName = fileName + ?? extractFilenameFromHeader(response) + ?? `file_${fileId}`; - // 파일명이 없으면 Content-Disposition 헤더에서 추출 시도 - let downloadFileName = fileName; - if (!downloadFileName) { - const contentDisposition = response.headers.get('Content-Disposition'); - if (contentDisposition) { - const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); - if (match && match[1]) { - downloadFileName = match[1].replace(/['"]/g, ''); - // URL 디코딩 (한글 파일명 처리) - try { - downloadFileName = decodeURIComponent(downloadFileName); - } catch { - // 디코딩 실패 시 그대로 사용 - } - } - } - } - - // 그래도 없으면 기본 파일명 - if (!downloadFileName) { - downloadFileName = `file_${fileId}`; - } - - // Blob URL 생성 및 다운로드 트리거 - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = downloadFileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - + downloadBlob(blob, downloadFileName); } catch (error) { console.error('[fileDownload] 다운로드 오류:', error); throw error; diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts new file mode 100644 index 00000000..b8089d24 --- /dev/null +++ b/src/stores/authStore.ts @@ -0,0 +1,247 @@ +/** + * Auth Zustand Store + * + * AuthContext(React Context + useState)에서 마이그레이션. + * - persist: custom storage로 기존 localStorage 키(mes-users, mes-currentUser) 유지 + * - devtools: Redux DevTools 디버깅 지원 + * - subscribe: 테넌트 전환 감지 + masterDataStore 동기화 + */ + +import { create } from 'zustand'; +import { devtools, persist, createJSONStorage } from 'zustand/middleware'; +import { useMasterDataStore } from '@/stores/masterDataStore'; + +// ===== 타입 정의 ===== + +export interface Tenant { + id: number; + company_name: string; + business_num: string; + tenant_st_code: string; + options?: { + company_scale?: string; + industry?: string; + }; +} + +export interface Role { + id: number; + name: string; + description: string; +} + +export interface MenuItem { + id: string; + label: string; + iconName: string; + path: string; +} + +export interface User { + userId: string; + name: string; + position: string; + roles: Role[]; + tenant: Tenant; + menu: MenuItem[]; +} + +export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales'; + +// ===== Store 타입 ===== + +interface AuthState { + // State + users: User[]; + currentUser: User | null; + + // Actions + setCurrentUser: (user: User | null) => void; + addUser: (user: User) => void; + updateUser: (userId: string, updates: Partial) => void; + deleteUser: (userId: string) => void; + getUserByUserId: (userId: string) => User | undefined; + logout: () => Promise; + clearTenantCache: (tenantId: number) => void; + resetAllData: () => void; +} + +// ===== 초기 데이터 ===== + +const initialUsers: User[] = [ + { + userId: "TestUser1", + name: "김대표", + position: "대표이사", + roles: [{ id: 1, name: "ceo", description: "최고경영자" }], + tenant: { + id: 282, + company_name: "(주)테크컴퍼니", + business_num: "123-45-67890", + tenant_st_code: "trial", + }, + menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }], + }, + { + userId: "TestUser2", + name: "박관리", + position: "생산관리자", + roles: [{ id: 2, name: "production_manager", description: "생산관리자" }], + tenant: { + id: 282, + company_name: "(주)테크컴퍼니", + business_num: "123-45-67890", + tenant_st_code: "trial", + }, + menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }], + }, + { + userId: "TestUser3", + name: "드미트리", + position: "시스템 관리자", + roles: [{ id: 19, name: "system_manager", description: "시스템 관리자" }], + tenant: { + id: 282, + company_name: "(주)테크컴퍼니", + business_num: "123-45-67890", + tenant_st_code: "trial", + }, + menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }], + }, +]; + +// ===== Custom Storage ===== +// 기존 코드가 mes-users / mes-currentUser 두 개 키를 사용하므로 호환성 유지 + +const authStorage = createJSONStorage>(() => ({ + getItem: (_name: string): string | null => { + if (typeof window === 'undefined') return null; + try { + const users = localStorage.getItem('mes-users'); + const currentUser = localStorage.getItem('mes-currentUser'); + return JSON.stringify({ + state: { + users: users ? JSON.parse(users) : initialUsers, + currentUser: currentUser ? JSON.parse(currentUser) : null, + }, + }); + } catch { + localStorage.removeItem('mes-users'); + localStorage.removeItem('mes-currentUser'); + return null; + } + }, + setItem: (_name: string, value: string): void => { + if (typeof window === 'undefined') return; + try { + const parsed = JSON.parse(value); + const { users, currentUser } = parsed.state; + localStorage.setItem('mes-users', JSON.stringify(users)); + if (currentUser) { + localStorage.setItem('mes-currentUser', JSON.stringify(currentUser)); + } + } catch { + // 저장 실패 무시 + } + }, + removeItem: (_name: string): void => { + if (typeof window === 'undefined') return; + localStorage.removeItem('mes-users'); + localStorage.removeItem('mes-currentUser'); + }, +})); + +// ===== Store 생성 ===== + +export const useAuthStore = create()( + devtools( + persist( + (set, get) => ({ + // State + users: initialUsers, + currentUser: null, + + // Actions + setCurrentUser: (user) => set({ currentUser: user }), + + addUser: (user) => set((state) => ({ users: [...state.users, user] })), + + updateUser: (userId, updates) => + set((state) => ({ + users: state.users.map((u) => + u.userId === userId ? { ...u, ...updates } : u + ), + })), + + deleteUser: (userId) => + set((state) => ({ + users: state.users.filter((u) => u.userId !== userId), + })), + + getUserByUserId: (userId) => get().users.find((u) => u.userId === userId), + + logout: async () => { + set({ currentUser: null }); + const { performFullLogout } = await import('@/lib/auth/logout'); + await performFullLogout({ + skipServerLogout: false, + redirectTo: null, + }); + }, + + clearTenantCache: (tenantId: number) => { + if (typeof window === 'undefined') return; + + const tenantAwarePrefix = `mes-${tenantId}-`; + const pageConfigPrefix = `page_config_${tenantId}_`; + + Object.keys(localStorage).forEach((key) => { + if (key.startsWith(tenantAwarePrefix)) { + localStorage.removeItem(key); + } + }); + + Object.keys(sessionStorage).forEach((key) => { + if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) { + sessionStorage.removeItem(key); + } + }); + }, + + resetAllData: () => set({ users: initialUsers, currentUser: null }), + }), + { + name: 'auth-store', + storage: authStorage, + partialize: (state) => ({ + users: state.users, + currentUser: state.currentUser, + }), + } + ), + { name: 'AuthStore' } + ) +); + +// ===== Subscribe: 테넌트 전환 감지 + masterDataStore 동기화 ===== + +let _prevTenantId: number | null = null; + +useAuthStore.subscribe((state) => { + const currentTenantId = state.currentUser?.tenant?.id ?? null; + + // 테넌트 전환 감지 (이전값이 있고, 현재값과 다를 때만) + if (_prevTenantId && currentTenantId && _prevTenantId !== currentTenantId) { + state.clearTenantCache(_prevTenantId); + } + + _prevTenantId = currentTenantId; + + // masterDataStore 동기화 + useMasterDataStore.getState().setCurrentTenantId(currentTenantId); +}); + +// ===== 셀렉터 훅 ===== + +export const useCurrentUser = () => useAuthStore((state) => state.currentUser); +export const useAuthLogout = () => useAuthStore((state) => state.logout); diff --git a/src/stores/menuStore.ts b/src/stores/menuStore.ts index a926673e..834eed25 100644 --- a/src/stores/menuStore.ts +++ b/src/stores/menuStore.ts @@ -63,4 +63,22 @@ export const useMenuStore = create()( }, } ) -); \ No newline at end of file +); + +// ===== 셀렉터 훅 ===== + +/** 사이드바 접힘 상태만 구독 */ +export const useSidebarCollapsed = () => + useMenuStore((state) => state.sidebarCollapsed); + +/** 활성 메뉴 ID만 구독 */ +export const useActiveMenu = () => + useMenuStore((state) => state.activeMenu); + +/** 메뉴 아이템 목록만 구독 */ +export const useMenuItems = () => + useMenuStore((state) => state.menuItems); + +/** 하이드레이션 완료 여부만 구독 */ +export const useMenuHydrated = () => + useMenuStore((state) => state._hasHydrated); \ No newline at end of file diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts index 96a1f885..e6fe3ddf 100644 --- a/src/stores/themeStore.ts +++ b/src/stores/themeStore.ts @@ -41,4 +41,14 @@ export const useThemeStore = create()( }, } ) -); \ No newline at end of file +); + +// ===== 셀렉터 훅 ===== + +/** 현재 테마만 구독 */ +export const useTheme = () => + useThemeStore((state) => state.theme); + +/** setTheme 액션만 구독 */ +export const useSetTheme = () => + useThemeStore((state) => state.setTheme); \ No newline at end of file diff --git a/src/stores/useTableColumnStore.ts b/src/stores/useTableColumnStore.ts index e7dc836a..ba5e1917 100644 --- a/src/stores/useTableColumnStore.ts +++ b/src/stores/useTableColumnStore.ts @@ -99,3 +99,17 @@ export const useTableColumnStore = create()( } ) ); + +// ===== 셀렉터 훅 ===== + +/** 특정 페이지의 컬럼 설정만 구독 */ +export const usePageColumnSettings = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId] ?? DEFAULT_PAGE_SETTINGS); + +/** 특정 페이지의 숨김 컬럼만 구독 */ +export const useHiddenColumns = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId]?.hiddenColumns ?? []); + +/** 특정 페이지의 컬럼 너비만 구독 */ +export const useColumnWidths = (pageId: string) => + useTableColumnStore((state) => state.pageSettings[pageId]?.columnWidths ?? {});