refactor(WEB): claudedocs 재정리 및 AuthContext/Zustand/유틸 코드 개선
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 기술 결정 사항
|
||||
|
||||
### `<img>` 태그 사용 — `next/image` 미사용 이유 (2026-02-10)
|
||||
|
||||
**현황**: 프로젝트 전체 `<img>` 태그 10건, `next/image` 0건
|
||||
|
||||
**결정**: `<img>` 유지, `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개 `<tr>`은 브라우저가 문제없이 처리
|
||||
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<ApiData, FrontendType>({
|
||||
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<typeof schema>`)
|
||||
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/<domain>/
|
||||
|
||||
# 전체 파일 검색
|
||||
find claudedocs/ -name "*.md" | sort
|
||||
```
|
||||
|
||||
@@ -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<T>` 제네릭 컴포넌트 생성
|
||||
```typescript
|
||||
// src/components/molecules/GenericCRUDDialog.tsx
|
||||
interface GenericCRUDDialogProps<T> {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
title: string;
|
||||
fields: FormFieldDefinition[];
|
||||
data?: T;
|
||||
onSubmit: (data: T) => Promise<void>;
|
||||
}
|
||||
```
|
||||
→ **~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<T>` 추출 |
|
||||
|
||||
→ **~500줄 절감**
|
||||
|
||||
### 3.3 재사용률 분석
|
||||
|
||||
#### 높은 재사용 (Good)
|
||||
- **UniversalListPage**: 40+ 페이지 (우수)
|
||||
- **IntegratedDetailTemplate**: 20+ 상세 페이지
|
||||
- **FormField**: 50+ 폼
|
||||
|
||||
#### 활용 부족 (Should Use More)
|
||||
- **SearchableSelectionModal**: 실제 3곳만 사용 → 더 광범위 적용 가능
|
||||
- **StandardDialog**: 존재하지만 단순 다이얼로그들이 미사용
|
||||
- **MobileCard**: 정의되었지만 비일관적 사용
|
||||
|
||||
### 3.4 패턴 비일관성
|
||||
|
||||
| 패턴 | 현재 상태 | 표준화 방향 |
|
||||
|------|----------|------------|
|
||||
| 날짜 범위 선택 | 3가지 방식 혼재 (컴포넌트/훅/인라인) | `useDateRange()` + `<DateRangeSelector />` |
|
||||
| 검색/필터 | 3가지 경쟁 패턴 (A: UniversalListPage, B: 커스텀 useState, C: IntegratedListTemplateV2) | Pattern A로 통일 |
|
||||
| 모달 vs 페이지 | VendorDetail→풀페이지, PurchaseDetail→모달 혼재 | 도메인별 기준 확립 |
|
||||
|
||||
### 3.5 추출 필요 공유 컴포넌트
|
||||
|
||||
| 컴포넌트 | 사용처 | 설명 |
|
||||
|----------|--------|------|
|
||||
| `LineItemsTable<T>` | SalesDetail, PurchaseDetail | 품목 추가/삭제/계산 테이블 (~150줄×2 절감) |
|
||||
| `DataStatsCard<T>` | 회계 리스트 페이지들 | 유연한 통계 표시 카드 |
|
||||
| `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<AuthState>()(
|
||||
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<T>` 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 유틸 추출
|
||||
@@ -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`를 호출하는 래퍼에 불과. 삭제해도 기능 손실 없음.
|
||||
@@ -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의 설정이 변경될 때만 리렌더.
|
||||
316
claudedocs/architecture/[REF] technical-decisions.md
Normal file
316
claudedocs/architecture/[REF] technical-decisions.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# 프로젝트 기술 결정 사항
|
||||
|
||||
> `_index.md`에서 분리됨 (2026-02-23). 프로젝트 전반의 기술 선택 배경과 근거를 기록.
|
||||
|
||||
---
|
||||
|
||||
### `<img>` 태그 사용 — `next/image` 미사용 이유 (2026-02-10)
|
||||
|
||||
**현황**: 프로젝트 전체 `<img>` 태그 10건, `next/image` 0건
|
||||
|
||||
**결정**: `<img>` 유지, `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개 `<tr>`은 브라우저가 문제없이 처리
|
||||
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<ApiData, FrontendType>({
|
||||
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<typeof schema>`)
|
||||
2. 별도 `interface` 중복 정의 불필요
|
||||
3. 신규 코드에서 `as` 캐스트 자연 감소 (D-2 개선 효과)
|
||||
|
||||
**규칙**:
|
||||
- 신규 폼 → `zodResolver(schema)` 사용 필수 (CLAUDE.md에 패턴 명시)
|
||||
- 기존 `rules={{ required: true }}` 패턴 폼 → 마이그레이션 불필요
|
||||
- 단순 1~2 필드 인라인 폼 → Zod 불필요 (오버엔지니어링)
|
||||
|
||||
**미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산
|
||||
@@ -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;
|
||||
|
||||
@@ -406,7 +406,10 @@ export function TaxInvoiceManagement() {
|
||||
<TableCell className="text-right text-sm">{formatNumber(item.taxAmount)}</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">{formatNumber(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{RECEIPT_TYPE_LABELS[item.receiptType]}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${item.source === 'manual' ? 'bg-purple-500' : 'bg-blue-500'}`} />
|
||||
{RECEIPT_TYPE_LABELS[item.receiptType]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.documentNumber || '-'}</TableCell>
|
||||
<TableCell className="text-center text-sm">{INVOICE_SOURCE_LABELS[item.source]}</TableCell>
|
||||
@@ -463,12 +466,12 @@ export function TaxInvoiceManagement() {
|
||||
<TableRow>
|
||||
<TableCell colSpan={14} className="py-2">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-yellow-100 border border-yellow-300 rounded" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 bg-purple-500 rounded-full" />
|
||||
<span>수기 세금계산서</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-white border border-gray-300 rounded" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 bg-blue-500 rounded-full" />
|
||||
<span>홈택스 연동 세금계산서</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 수정 모드 / 복제 모드 상태
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<CommandMenuSearchRef>((_, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const router = useRouter();
|
||||
const { menuItems } = useMenuStore();
|
||||
const menuItems = useMenuItems();
|
||||
|
||||
// 외부에서 제어할 수 있도록 ref 노출
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
171
src/components/molecules/GenericCRUDDialog.tsx
Normal file
171
src/components/molecules/GenericCRUDDialog.tsx
Normal file
@@ -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<string, string>;
|
||||
onSubmit: (data: Record<string, string>) => 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<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && initialData) {
|
||||
setFormData({ ...initialData });
|
||||
} else {
|
||||
const defaults: Record<string, string> = {};
|
||||
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<string, string> = {};
|
||||
Object.entries(formData).forEach(([k, v]) => {
|
||||
trimmed[k] = v.trim();
|
||||
});
|
||||
onSubmit(trimmed);
|
||||
|
||||
const defaults: Record<string, string> = {};
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`crud-${field.key}`}>{field.label}</Label>
|
||||
{field.type === 'text' ? (
|
||||
<Input
|
||||
id={`crud-${field.key}`}
|
||||
value={formData[field.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
autoFocus={idx === 0}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={formData[field.key] ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({ ...prev, [field.key]: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitDisabled}>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,4 +10,7 @@ export { StandardDialog } from "./StandardDialog";
|
||||
export type { StandardDialogProps } from "./StandardDialog";
|
||||
|
||||
export { YearQuarterFilter } from "./YearQuarterFilter";
|
||||
export type { Quarter } from "./YearQuarterFilter";
|
||||
export type { Quarter } from "./YearQuarterFilter";
|
||||
|
||||
export { GenericCRUDDialog } from "./GenericCRUDDialog";
|
||||
export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog";
|
||||
@@ -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<string, { name: string; isMaterialInput: boolean; is
|
||||
|
||||
export default function WorkerScreen() {
|
||||
// ===== 상태 관리 =====
|
||||
const { sidebarCollapsed } = useMenuStore();
|
||||
const sidebarCollapsed = useSidebarCollapsed();
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string>('');
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 권한명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-name">권한명</Label>
|
||||
<Input
|
||||
id="permission-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="권한명을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-status">상태</Label>
|
||||
<Select value={status} onValueChange={(value: 'active' | 'hidden') => setStatus(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">공개</SelectItem>
|
||||
<SelectItem value="hidden">숨김</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 직급명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank-name">직급명</Label>
|
||||
<Input
|
||||
id="rank-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="직급명을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<GenericCRUDDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
mode={mode}
|
||||
entityName="직급"
|
||||
fields={RANK_FIELDS}
|
||||
initialData={initialData}
|
||||
onSubmit={(data) => onSubmit(data.name)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 직책명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title-name">직책명</Label>
|
||||
<Input
|
||||
id="title-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="직책명을 입력하세요"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<GenericCRUDDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
mode={mode}
|
||||
entityName="직책"
|
||||
fields={TITLE_FIELDS}
|
||||
initialData={initialData}
|
||||
onSubmit={(data) => onSubmit(data.name)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<User>) => void;
|
||||
deleteUser: (userId: string) => void;
|
||||
getUserByUserId: (userId: string) => User | undefined;
|
||||
logout: () => Promise<void>; // ✅ 추가: 로그아웃 (완전한 캐시 정리)
|
||||
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<AuthContextType | undefined>(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<User[]>(initialUsers);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
|
||||
// ✅ 추가: 이전 tenant.id 추적 (테넌트 전환 감지용)
|
||||
const previousTenantIdRef = useRef<number | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<AuthProvider>
|
||||
<PermissionProvider>
|
||||
<ItemMasterProvider>
|
||||
{children}
|
||||
</ItemMasterProvider>
|
||||
</PermissionProvider>
|
||||
</AuthProvider>
|
||||
<PermissionProvider>
|
||||
<ItemMasterProvider>
|
||||
{children}
|
||||
</ItemMasterProvider>
|
||||
</PermissionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(); // 현재 경로 추적
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>, 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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
247
src/stores/authStore.ts
Normal file
247
src/stores/authStore.ts
Normal file
@@ -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<User>) => void;
|
||||
deleteUser: (userId: string) => void;
|
||||
getUserByUserId: (userId: string) => User | undefined;
|
||||
logout: () => Promise<void>;
|
||||
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<Pick<AuthState, 'users' | 'currentUser'>>(() => ({
|
||||
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<AuthState>()(
|
||||
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);
|
||||
@@ -63,4 +63,22 @@ export const useMenuStore = create<MenuState>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
/** 사이드바 접힘 상태만 구독 */
|
||||
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);
|
||||
@@ -41,4 +41,14 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
/** 현재 테마만 구독 */
|
||||
export const useTheme = () =>
|
||||
useThemeStore((state) => state.theme);
|
||||
|
||||
/** setTheme 액션만 구독 */
|
||||
export const useSetTheme = () =>
|
||||
useThemeStore((state) => state.setTheme);
|
||||
@@ -99,3 +99,17 @@ export const useTableColumnStore = create<TableColumnState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
/** 특정 페이지의 컬럼 설정만 구독 */
|
||||
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 ?? {});
|
||||
|
||||
Reference in New Issue
Block a user