From b1587071f22b943c0bf517f057f75ecf70d8fcc2 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Tue, 16 Dec 2025 11:01:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품목 상세/수정 페이지 파일 다운로드 기능 개선 - DynamicItemForm 파일 업로드 UI/UX 개선 (시방서, 인정서) - BendingDiagramSection 조립/절곡 부품 전개도 통합 - API proxy route 품목 타입별 라우팅 개선 - ItemListClient 파일 다운로드 유틸리티 적용 - 품목코드 중복 체크 및 다이얼로그 추가 문서화: - DynamicItemForm 훅 분리 계획서 추가 (2161줄 → 900줄 목표) - 백엔드 API 마이그레이션 문서 추가 - 대용량 파일 처리 전략 가이드 추가 - 테넌트 데이터 격리 감사 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...2025-12-12] tenant-data-isolation-audit.md | 958 ++++++++++++++++++ claudedocs/_index.md | 12 +- .../[GUIDE] large-file-handling-strategy.md | 458 +++++++++ ...12-12] item-master-form-builder-roadmap.md | 421 ++++++++ ...25-12-16] options-details-duplicate-bug.md | 179 ++++ ...-2025-12-15] backend-item-api-migration.md | 166 +++ ...T-2025-12-10] item-crud-session-context.md | 119 +++ ...T-2025-12-12] item-crud-session-context.md | 205 ++++ ...12-13] item-file-upload-session-context.md | 96 ++ ...-12-16] dynamicitemform-hook-extraction.md | 258 +++++ ...2] tenant-data-isolation-implementation.md | 324 ++++++ .../(protected)/items/[id]/edit/page.tsx | 95 +- .../[locale]/(protected)/items/[id]/page.tsx | 115 ++- .../(protected)/items/create/page.tsx | 4 +- .../sales/pricing-management/page.tsx | 10 +- src/app/api/proxy/[...path]/route.ts | 46 +- .../items/DynamicItemForm/index.tsx | 316 ++++-- .../sections/DynamicBOMSection.tsx | 1 + src/components/items/ItemDetailClient.tsx | 64 +- .../items/ItemForm/BendingDiagramSection.tsx | 103 +- src/components/items/ItemListClient.tsx | 21 +- src/hooks/useItemList.ts | 19 +- src/lib/api/items.ts | 18 +- src/lib/utils/fileDownload.ts | 71 ++ src/types/item.ts | 9 +- 25 files changed, 3905 insertions(+), 183 deletions(-) create mode 100644 claudedocs/[SECURITY-2025-12-12] tenant-data-isolation-audit.md create mode 100644 claudedocs/guides/[GUIDE] large-file-handling-strategy.md create mode 100644 claudedocs/item-master/[DESIGN-2025-12-12] item-master-form-builder-roadmap.md create mode 100644 claudedocs/item-master/[FIX-2025-12-16] options-details-duplicate-bug.md create mode 100644 claudedocs/item-master/[IMPL-2025-12-15] backend-item-api-migration.md create mode 100644 claudedocs/item-master/[NEXT-2025-12-10] item-crud-session-context.md create mode 100644 claudedocs/item-master/[NEXT-2025-12-12] item-crud-session-context.md create mode 100644 claudedocs/item-master/[NEXT-2025-12-13] item-file-upload-session-context.md create mode 100644 claudedocs/item-master/[PLAN-2025-12-16] dynamicitemform-hook-extraction.md create mode 100644 claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md create mode 100644 src/lib/utils/fileDownload.ts diff --git a/claudedocs/[SECURITY-2025-12-12] tenant-data-isolation-audit.md b/claudedocs/[SECURITY-2025-12-12] tenant-data-isolation-audit.md new file mode 100644 index 00000000..91c615e1 --- /dev/null +++ b/claudedocs/[SECURITY-2025-12-12] tenant-data-isolation-audit.md @@ -0,0 +1,958 @@ +# 테넌트 데이터 격리 보안 진단 리포트 + +**작성일**: 2025-12-12 +**대상 시스템**: SAM Multi-Tenant ERP System +**분석 범위**: 프론트엔드 (Next.js 15) + API 프록시 계층 + +--- + +## 1. Executive Summary + +### 전체 보안 등급: **🟡 Medium Risk (주의 필요)** + +이 시스템은 **HttpOnly 쿠키 기반 인증**과 **API 프록시 패턴**을 사용하여 기본적인 보안 구조는 갖추었으나, **테넌트 데이터 격리에 치명적인 취약점**이 존재합니다. + +**핵심 문제**: +- ✅ 토큰 보안: HttpOnly 쿠키로 XSS 방어 양호 +- ❌ 테넌트 격리: **클라이언트 사이드에서 tenant.id 검증 없음** +- ❌ 데이터 오염: 테넌트 전환 시 **캐시 미정리**로 데이터 혼합 위험 +- ⚠️ API 의존성: **PHP 백엔드가 유일한 방어선** (단일 실패 지점) + +--- + +## 2. 테넌트 데이터 흐름 분석 + +### 2.1 인증 토큰 흐름 (✅ 양호) + +``` +[로그인 시] +1. 사용자 → Next.js /api/auth/login (user_id, user_pwd) +2. Next.js → PHP /api/v1/login +3. PHP → Next.js (access_token, refresh_token, user, tenant) +4. Next.js: HttpOnly 쿠키 설정 (access_token, refresh_token) +5. Next.js → 클라이언트 (user, tenant 정보만 전달) + +[API 호출 시] +1. 클라이언트 → Next.js /api/proxy/* (토큰 없이) +2. Next.js: HttpOnly 쿠키에서 access_token 읽기 ✅ +3. Next.js → PHP /api/v1/* (Authorization: Bearer {token}) +4. PHP: 토큰 검증 + tenant.id 추출 ✅ +5. PHP → Next.js → 클라이언트 (응답) +``` + +**보안 평가**: ✅ **양호** +- HttpOnly 쿠키로 JavaScript 접근 차단 (XSS 방어) +- SameSite=Lax로 CSRF 방어 +- 토큰은 서버 사이드에서만 처리 + +--- + +### 2.2 테넌트 ID 흐름 (❌ 취약) + +``` +[로그인 응답] +PHP → Next.js: +{ + "user": { "id": 1, "user_id": "test" }, + "tenant": { "id": 282, "company_name": "(주)테크컴퍼니" }, // ← 여기서 tenant.id 전달 + "access_token": "...", + "refresh_token": "..." +} + +[문제점] +1. AuthContext: tenant 정보를 localStorage에 저장 + - localStorage.setItem('mes-currentUser', JSON.stringify({ tenant: { id: 282 } })) + - ❌ 클라이언트가 tenant.id를 임의 조작 가능 + +2. API 호출 시: tenant.id를 URL에 포함 + - fetch(`/api/tenants/${currentUser.tenant.id}/item-master-config`) + - ❌ URL의 tenant.id를 조작하면 다른 테넌트 데이터 접근 가능 + +3. 프론트엔드 검증 없음: + - Next.js 프록시: tenant.id 검증 없이 그대로 전달 + - PHP 백엔드만 검증 (단일 실패 지점) +``` + +**보안 평가**: ❌ **Critical** +- **IDOR (Insecure Direct Object Reference) 취약점** +- 클라이언트가 tenant.id를 조작하여 타 테넌트 데이터 접근 시도 가능 + +--- + +### 2.3 클라이언트 사이드 캐시 (⚠️ 위험) + +#### localStorage 사용 현황 + +**AuthContext.tsx (Lines 160-188)**: +```typescript +// localStorage에 사용자 정보 저장 +useEffect(() => { + localStorage.setItem('mes-users', JSON.stringify(users)); +}, [users]); + +useEffect(() => { + if (currentUser) { + localStorage.setItem('mes-currentUser', JSON.stringify(currentUser)); + // ❌ tenant.id 변경 시 기존 테넌트 캐시를 정리하지 않음 + } +}, [currentUser]); +``` + +**문제점**: +- `mes-users`: 여러 테넌트의 사용자 정보가 혼합될 가능성 +- `mes-currentUser`: tenant 정보 포함, 조작 가능 +- ❌ **테넌트 전환 시 캐시 정리 로직 없음** + +#### sessionStorage 사용 현황 + +**masterDataStore.ts (Lines 82-142)**: +```typescript +const STORAGE_PREFIX = 'page_config_'; +// 저장 키: 'page_config_item-master', 'page_config_quotation' + +function getConfigFromSessionStorage(pageType: PageType): PageConfig | null { + const cachedData = window.sessionStorage.getItem(`${STORAGE_PREFIX}${pageType}`); + // ❌ tenant.id 검증 없음 - 다른 테넌트 데이터가 남아있을 수 있음 + return cachedData ? JSON.parse(cachedData) : null; +} +``` + +**문제점**: +- ❌ **tenant.id가 캐시 키에 포함되지 않음** +- 사용자 A (tenant 282) → 사용자 B (tenant 300) 전환 시 + - 기존 캐시 `page_config_item-master`가 남아있음 + - 사용자 B가 사용자 A의 설정을 보게 됨 + +#### TenantAwareCache (✅ 우수한 설계) + +**TenantAwareCache.ts (Lines 54-123)**: +```typescript +private getKey(key: string): string { + return `mes-${this.tenantId}-${key}`; // ✅ tenant.id 기반 키 생성 +} + +get(key: string): T | null { + const parsed: CachedData = JSON.parse(cached); + + // ✅ tenant.id 검증 + if (parsed.tenantId !== this.tenantId) { + console.warn(`tenantId mismatch: ${parsed.tenantId} !== ${this.tenantId}`); + this.remove(key); + return null; + } + + // ✅ TTL 검증 + if (Date.now() - parsed.timestamp > this.ttl) { + this.remove(key); + return null; + } + + return parsed.data; +} +``` + +**평가**: ✅ **우수** +- tenant.id 기반 캐시 키 생성 +- tenant.id 불일치 시 자동 삭제 +- TTL로 만료 처리 + +**문제**: ⚠️ **masterDataStore가 TenantAwareCache를 사용하지 않음** + +--- + +## 3. 위험 지점 분석 + +### 3.1 🔴 CRITICAL - API 엔드포인트 테넌트 ID 노출 + +**파일**: `/src/app/api/tenants/[tenantId]/item-master-config/route.ts` + +```typescript +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ tenantId: string }> } +) { + const { tenantId } = await params; // ❌ URL에서 받은 tenantId를 그대로 사용 + + const phpEndpoint = `/api/v1/tenants/${tenantId}/item-master-config`; + return proxyToPhpBackend(request, phpEndpoint, { method: 'GET' }); +} +``` + +**취약점**: +1. **IDOR 공격 가능**: 클라이언트가 URL의 `tenantId`를 조작 가능 + - 정상: `GET /api/tenants/282/item-master-config` + - 공격: `GET /api/tenants/300/item-master-config` (다른 테넌트) + +2. **검증 없음**: Next.js 프록시가 토큰의 tenant.id와 URL의 tenantId 일치 여부를 확인하지 않음 + +3. **PHP 의존**: PHP 백엔드만 검증 → 백엔드 버그 시 전체 시스템 무방비 + +**공격 시나리오**: +```javascript +// 공격자가 브라우저 콘솔에서 실행 +fetch('/api/tenants/300/item-master-config', { + method: 'GET', + headers: { 'Content-Type': 'application/json' } +}) +.then(res => res.json()) +.then(data => console.log('타 테넌트 데이터:', data)); +// ← PHP 백엔드가 방어하지 않으면 데이터 유출 +``` + +**개선 권고**: +```typescript +export async function GET(request: NextRequest, { params }: { params: Promise<{ tenantId: string }> }) { + const { tenantId } = await params; + + // ✅ 토큰에서 실제 tenant.id 추출 + const token = request.cookies.get('access_token')?.value; + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const decoded = decodeJWT(token); // JWT에서 tenant.id 추출 + + // ✅ URL의 tenantId와 토큰의 tenant.id 일치 여부 확인 + if (decoded.tenant_id !== parseInt(tenantId, 10)) { + console.warn(`[Security] tenant.id mismatch: token=${decoded.tenant_id}, url=${tenantId}`); + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // 검증 통과 후 백엔드 호출 + const phpEndpoint = `/api/v1/tenants/${tenantId}/item-master-config`; + return proxyToPhpBackend(request, phpEndpoint, { method: 'GET' }); +} +``` + +--- + +### 3.2 🔴 CRITICAL - 클라이언트 사이드 tenant.id 조작 가능 + +**파일**: `/src/contexts/AuthContext.tsx` + +```typescript +export interface User { + userId: string; + name: string; + tenant: Tenant; // ← tenant.id 포함 +} + +// localStorage에 저장 +useEffect(() => { + if (currentUser) { + localStorage.setItem('mes-currentUser', JSON.stringify(currentUser)); + // ❌ localStorage는 JavaScript로 조작 가능 + } +}, [currentUser]); +``` + +**취약점**: +1. **localStorage 조작**: 브라우저 콘솔에서 tenant.id 변경 가능 + ```javascript + // 공격자가 콘솔에서 실행 + const user = JSON.parse(localStorage.getItem('mes-currentUser')); + user.tenant.id = 300; // 다른 테넌트 ID로 변경 + localStorage.setItem('mes-currentUser', JSON.stringify(user)); + window.location.reload(); // 페이지 새로고침 + // ← 이제 모든 API 호출이 tenant 300으로 전송됨 + ``` + +2. **검증 부재**: AuthContext가 tenant.id의 진위 여부를 확인하지 않음 + +**개선 권고**: +```typescript +// ✅ 로그인 시 tenant.id를 HttpOnly 쿠키에 저장 +// /api/auth/login에서 추가: +response.headers.append('Set-Cookie', + `tenant_id=${data.tenant.id}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=7200` +); + +// ✅ AuthContext에서 tenant.id 검증 +useEffect(() => { + const verifyTenantId = async () => { + const response = await fetch('/api/auth/verify-tenant'); + const { tenantId } = await response.json(); + + if (currentUser?.tenant?.id !== tenantId) { + console.error('[Security] tenant.id mismatch detected - logging out'); + logout(); + } + }; + + verifyTenantId(); +}, [currentUser]); +``` + +--- + +### 3.3 🟡 HIGH - 테넌트 전환 시 캐시 미정리 + +**파일**: `/src/contexts/AuthContext.tsx` + +**현재 로직**: +```typescript +// 테넌트 전환 감지 (Lines 190-201) +useEffect(() => { + const prevTenantId = previousTenantIdRef.current; + const currentTenantId = currentUser?.tenant?.id; + + if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) { + console.log(`[Auth] Tenant changed: ${prevTenantId} → ${currentTenantId}`); + clearTenantCache(prevTenantId); // ✅ 이전 테넌트 캐시 삭제 + } + + previousTenantIdRef.current = currentTenantId || null; +}, [currentUser?.tenant?.id]); + +// 캐시 삭제 함수 (Lines 203-225) +const clearTenantCache = (tenantId: number) => { + const prefix = `mes-${tenantId}-`; // ✅ tenant.id 기반 키 삭제 + + Object.keys(localStorage).forEach(key => { + if (key.startsWith(prefix)) { + localStorage.removeItem(key); + } + }); +} +``` + +**문제점**: +1. ⚠️ **sessionStorage 미정리**: masterDataStore가 사용하는 `page_config_*` 캐시가 남음 + - `page_config_item-master`, `page_config_quotation` 등 + - tenant.id가 키에 포함되지 않아 이전 테넌트 데이터 혼합 가능 + +2. ⚠️ **Zustand Store 미초기화**: `itemStore`, `masterDataStore` 메모리 캐시가 남음 + +**데이터 오염 시나리오**: +``` +[사용자 A - tenant 282] +1. 품목기준관리 조회 → sessionStorage에 저장 + - page_config_item-master = { sections: [...] } + +[사용자 B - tenant 300으로 전환] +2. clearTenantCache(282) 호출 + - ✅ localStorage: 'mes-282-*' 삭제됨 + - ❌ sessionStorage: 'page_config_*' 남아있음 + +3. 사용자 B가 품목기준관리 접근 + - masterDataStore.fetchPageConfig('item-master') 호출 + - sessionStorage에서 이전 캐시 반환 (tenant 282 데이터) + - ❌ 사용자 B가 사용자 A의 설정을 보게 됨 +``` + +**개선 권고**: +```typescript +const clearTenantCache = (tenantId: number) => { + if (typeof window === 'undefined') return; + + const prefix = `mes-${tenantId}-`; + + // ✅ localStorage 정리 + Object.keys(localStorage).forEach(key => { + if (key.startsWith(prefix)) { + localStorage.removeItem(key); + } + }); + + // ✅ sessionStorage 정리 (추가) + Object.keys(sessionStorage).forEach(key => { + // tenant.id 기반 키 삭제 + if (key.startsWith(prefix)) { + sessionStorage.removeItem(key); + } + // tenant.id가 없는 공통 캐시도 삭제 + if (key.startsWith('page_config_')) { + sessionStorage.removeItem(key); + console.log(`[Cache] Cleared sessionStorage: ${key}`); + } + }); + + // ✅ Zustand Store 초기화 (추가) + const { reset: resetItemStore } = useItemStore.getState(); + const { reset: resetMasterDataStore } = useMasterDataStore.getState(); + resetItemStore(); + resetMasterDataStore(); + console.log('[Cache] Reset Zustand stores'); +}; +``` + +--- + +### 3.4 🟡 HIGH - sessionStorage 캐시에 tenant.id 미포함 + +**파일**: `/src/stores/masterDataStore.ts` + +```typescript +const STORAGE_PREFIX = 'page_config_'; // ❌ tenant.id 없음 + +function setConfigToSessionStorage(pageType: PageType, config: PageConfig): void { + window.sessionStorage.setItem( + `${STORAGE_PREFIX}${pageType}`, // 예: 'page_config_item-master' + JSON.stringify(config) + ); +} + +function getConfigFromSessionStorage(pageType: PageType): PageConfig | null { + const cachedData = window.sessionStorage.getItem(`${STORAGE_PREFIX}${pageType}`); + // ❌ tenant.id 검증 없음 + return cachedData ? JSON.parse(cachedData) : null; +} +``` + +**취약점**: +- 서로 다른 테넌트의 사용자가 같은 브라우저 세션에서 번갈아 로그인하면 +- 이전 테넌트의 캐시를 그대로 사용하게 됨 + +**개선 권고**: +```typescript +// ✅ TenantAwareCache 패턴 적용 +const STORAGE_PREFIX = (tenantId: number) => `mes-${tenantId}-page_config_`; + +function setConfigToSessionStorage( + tenantId: number, + pageType: PageType, + config: PageConfig +): void { + const key = `${STORAGE_PREFIX(tenantId)}${pageType}`; + const cacheData = { + tenantId, + data: config, + timestamp: Date.now(), + }; + window.sessionStorage.setItem(key, JSON.stringify(cacheData)); +} + +function getConfigFromSessionStorage( + tenantId: number, + pageType: PageType +): PageConfig | null { + const key = `${STORAGE_PREFIX(tenantId)}${pageType}`; + const cached = window.sessionStorage.getItem(key); + if (!cached) return null; + + const parsed = JSON.parse(cached); + + // ✅ tenant.id 검증 + if (parsed.tenantId !== tenantId) { + window.sessionStorage.removeItem(key); + return null; + } + + // ✅ TTL 검증 + if (Date.now() - parsed.timestamp > 600000) { // 10분 + window.sessionStorage.removeItem(key); + return null; + } + + return parsed.data; +} +``` + +--- + +### 3.5 🟡 MEDIUM - 로그아웃 시 캐시 미정리 + +**파일**: `/src/contexts/AuthContext.tsx` + +```typescript +const logout = () => { + if (currentUser?.tenant?.id) { + clearTenantCache(currentUser.tenant.id); // ✅ 캐시 정리 + } + setCurrentUser(null); + localStorage.removeItem('mes-currentUser'); + console.log('[Auth] Logged out and cleared tenant cache'); +}; +``` + +**문제점**: +- ✅ localStorage 정리는 수행 +- ⚠️ sessionStorage 정리 누락 (clearTenantCache가 sessionStorage 미처리) +- ⚠️ Zustand Store 초기화 누락 + +**개선 권고**: 3.3과 동일 + +--- + +### 3.6 🟢 LOW - JWT 디코딩 없이 tenant.id 추출 불가 + +**파일**: `/src/app/api/proxy/[...path]/route.ts` + +```typescript +async function proxyRequest(request: NextRequest, params: { path: string[] }, method: string) { + // 1. HttpOnly 쿠키에서 토큰 읽기 + let token = request.cookies.get('access_token')?.value; + + // ❌ JWT 디코딩 없이 tenant.id 추출 불가 + // ← 현재는 PHP 백엔드에 의존 + + // 2. 백엔드로 프록시 요청 + const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; + const response = await fetch(backendUrl, { + headers: { + 'Authorization': `Bearer ${token}`, // ← tenant.id 검증 없이 전달 + }, + }); + + return response; +} +``` + +**취약점**: +- JWT를 디코딩하지 않아 토큰 내 tenant.id를 확인할 수 없음 +- URL의 tenant.id와 토큰의 tenant.id 일치 여부를 검증할 수 없음 + +**개선 권고**: +```typescript +import jwt from 'jsonwebtoken'; + +async function proxyRequest(request: NextRequest, params: { path: string[] }, method: string) { + const token = request.cookies.get('access_token')?.value; + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // ✅ JWT 디코딩 (서명 검증 없이 페이로드만 읽기) + const decoded = jwt.decode(token) as { tenant_id: number }; + + // ✅ URL에 tenant.id가 포함된 경우 검증 + const urlMatch = params.path.join('/').match(/^tenants\/(\d+)\//); + if (urlMatch) { + const urlTenantId = parseInt(urlMatch[1], 10); + if (decoded.tenant_id !== urlTenantId) { + console.warn(`[Security] tenant.id mismatch: token=${decoded.tenant_id}, url=${urlTenantId}`); + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + // 검증 통과 후 백엔드 호출 + const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; + return fetch(backendUrl, { + headers: { 'Authorization': `Bearer ${token}` }, + }); +} +``` + +--- + +### 3.7 🟢 LOW - middleware.ts에서 tenant.id 검증 없음 + +**파일**: `/src/middleware.ts` + +```typescript +function checkAuthentication(request: NextRequest): { + isAuthenticated: boolean; + authMode: 'sanctum' | 'bearer' | 'api-key' | null; +} { + const accessToken = request.cookies.get('access_token'); + if (accessToken && accessToken.value) { + return { isAuthenticated: true, authMode: 'bearer' }; // ← tenant.id 확인 안함 + } + // ... +} +``` + +**취약점**: +- middleware는 토큰 존재 여부만 확인 +- 토큰이 유효한 tenant.id를 포함하는지 검증하지 않음 + +**영향**: 🟢 **Low** +- middleware는 인증 여부만 확인하는 역할 +- 실제 데이터 접근은 API 계층에서 방어 + +**개선 권고**: 현재 구조에서는 불필요 (API 계층에서 처리) + +--- + +## 4. 개선 권고사항 우선순위 + +### 4.1 🔴 즉시 조치 필요 (1주 이내) + +#### 1. API 엔드포인트에서 tenant.id 검증 추가 +**대상**: `/src/app/api/tenants/[tenantId]/**/route.ts` 전체 + +**작업**: +```typescript +// 공통 유틸리티 함수 생성: /src/lib/api/tenant-validator.ts +import jwt from 'jsonwebtoken'; + +export function validateTenantId( + token: string | undefined, + urlTenantId: string +): { valid: boolean; error?: string } { + if (!token) { + return { valid: false, error: 'Unauthorized' }; + } + + const decoded = jwt.decode(token) as { tenant_id: number }; + if (!decoded || !decoded.tenant_id) { + return { valid: false, error: 'Invalid token' }; + } + + if (decoded.tenant_id !== parseInt(urlTenantId, 10)) { + console.warn(`[Security] tenant.id mismatch: token=${decoded.tenant_id}, url=${urlTenantId}`); + return { valid: false, error: 'Forbidden - tenant.id mismatch' }; + } + + return { valid: true }; +} + +// 모든 tenant API에 적용 +export async function GET(request: NextRequest, { params }: { params: Promise<{ tenantId: string }> }) { + const { tenantId } = await params; + const token = request.cookies.get('access_token')?.value; + + const validation = validateTenantId(token, tenantId); + if (!validation.valid) { + return NextResponse.json({ error: validation.error }, { + status: validation.error === 'Unauthorized' ? 401 : 403 + }); + } + + // 검증 통과 후 백엔드 호출 + return proxyToPhpBackend(request, `/api/v1/tenants/${tenantId}/...`); +} +``` + +**영향**: IDOR 공격 차단 + +--- + +#### 2. tenant.id를 HttpOnly 쿠키에 저장 +**대상**: `/src/app/api/auth/login/route.ts` + +**작업**: +```typescript +export async function POST(request: NextRequest) { + // ... 기존 로그인 로직 ... + + const data: BackendLoginResponse = await backendResponse.json(); + + // ✅ tenant.id를 HttpOnly 쿠키에 저장 + const tenantIdCookie = [ + `tenant_id=${data.tenant.id}`, + 'HttpOnly', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + `Max-Age=${data.expires_in || 7200}`, + ].join('; '); + + response.headers.append('Set-Cookie', accessTokenCookie); + response.headers.append('Set-Cookie', refreshTokenCookie); + response.headers.append('Set-Cookie', tenantIdCookie); // ← 추가 + + return response; +} +``` + +**영향**: 클라이언트 조작 방지 + +--- + +#### 3. 테넌트 전환 시 sessionStorage 및 Zustand Store 초기화 +**대상**: `/src/contexts/AuthContext.tsx` + +**작업**: +```typescript +const clearTenantCache = (tenantId: number) => { + if (typeof window === 'undefined') return; + + const prefix = `mes-${tenantId}-`; + + // localStorage 정리 + Object.keys(localStorage).forEach(key => { + if (key.startsWith(prefix)) { + localStorage.removeItem(key); + } + }); + + // ✅ sessionStorage 정리 (추가) + Object.keys(sessionStorage).forEach(key => { + if (key.startsWith(prefix) || key.startsWith('page_config_')) { + sessionStorage.removeItem(key); + } + }); + + // ✅ Zustand Store 초기화 (추가) + try { + const { reset: resetItemStore } = useItemStore.getState(); + const { reset: resetMasterDataStore } = useMasterDataStore.getState(); + resetItemStore(); + resetMasterDataStore(); + console.log('[Cache] Reset all stores'); + } catch (error) { + console.error('[Cache] Store reset failed:', error); + } +}; +``` + +**영향**: 데이터 오염 방지 + +--- + +### 4.2 🟡 단기 조치 (1개월 이내) + +#### 4. masterDataStore를 TenantAwareCache 패턴으로 리팩토링 +**대상**: `/src/stores/masterDataStore.ts` + +**작업**: +1. sessionStorage 키에 tenant.id 포함 + ```typescript + const STORAGE_PREFIX = (tenantId: number) => `mes-${tenantId}-page_config_`; + ``` + +2. 캐시 데이터에 tenant.id 포함 + ```typescript + interface CachedPageConfig { + tenantId: number; + data: PageConfig; + timestamp: number; + } + ``` + +3. tenant.id 검증 로직 추가 (TenantAwareCache 참고) + +**영향**: 캐시 격리 보장 + +--- + +#### 5. API 프록시에서 JWT 디코딩 및 tenant.id 검증 +**대상**: `/src/app/api/proxy/[...path]/route.ts` + +**작업**: +```typescript +async function proxyRequest(request: NextRequest, params: { path: string[] }, method: string) { + const token = request.cookies.get('access_token')?.value; + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // ✅ JWT 디코딩 + const decoded = jwt.decode(token) as { tenant_id: number }; + + // ✅ URL에 tenant.id가 있으면 검증 + const urlMatch = params.path.join('/').match(/^tenants\/(\d+)\//); + if (urlMatch) { + const urlTenantId = parseInt(urlMatch[1], 10); + if (decoded.tenant_id !== urlTenantId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + // 백엔드 호출 + const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; + return fetch(backendUrl, { + headers: { 'Authorization': `Bearer ${token}` }, + }); +} +``` + +**영향**: 심층 방어 (Defense in Depth) + +--- + +#### 6. AuthContext에서 tenant.id 주기적 검증 +**대상**: `/src/contexts/AuthContext.tsx` + +**작업**: +```typescript +// 5분마다 tenant.id 검증 +useEffect(() => { + const verifyTenantId = async () => { + try { + const response = await fetch('/api/auth/verify-tenant'); + if (!response.ok) throw new Error('Verification failed'); + + const { tenantId } = await response.json(); + if (currentUser?.tenant?.id !== tenantId) { + console.error('[Security] tenant.id mismatch - logging out'); + logout(); + } + } catch (error) { + console.error('[Security] Tenant verification error:', error); + } + }; + + const interval = setInterval(verifyTenantId, 5 * 60 * 1000); // 5분 + return () => clearInterval(interval); +}, [currentUser]); +``` + +**영향**: 런타임 조작 탐지 + +--- + +### 4.3 🟢 장기 개선 (3개월 이내) + +#### 7. Backend에서 tenant.id 자동 추출 및 URL에서 제거 +**대상**: PHP Backend 및 프론트엔드 전체 + +**개념**: +- JWT 토큰에서 tenant.id를 추출하여 자동으로 쿼리에 적용 +- URL에서 tenant.id를 제거하여 클라이언트 조작 불가능하게 변경 + +**Before**: +``` +GET /api/tenants/282/item-master-config +``` + +**After**: +``` +GET /api/item-master-config +Authorization: Bearer {token_with_tenant_id} +``` + +**Backend 변경**: +```php +// Laravel Middleware +class TenantScopeMiddleware { + public function handle($request, Closure $next) { + $tenantId = auth()->user()->tenant_id; + + // 모든 쿼리에 tenant.id 자동 적용 + \DB::table('items')->where('tenant_id', $tenantId); + + return $next($request); + } +} +``` + +**영향**: 근본적인 보안 개선 (Zero Trust 원칙) + +--- + +#### 8. 로그 및 모니터링 강화 +**대상**: 전체 시스템 + +**작업**: +1. **tenant.id 불일치 로그 수집** + ```typescript + if (decoded.tenant_id !== urlTenantId) { + await logSecurityEvent({ + type: 'TENANT_ID_MISMATCH', + userId: decoded.user_id, + tokenTenantId: decoded.tenant_id, + urlTenantId: urlTenantId, + timestamp: Date.now(), + ip: request.headers.get('x-forwarded-for'), + }); + } + ``` + +2. **비정상 접근 패턴 탐지** + - 짧은 시간에 다른 tenant.id로 여러 요청 + - 존재하지 않는 tenant.id 접근 시도 + +3. **알림 설정** + - tenant.id 불일치 5회 이상 → 관리자 알림 + - 403 Forbidden 10회 이상 → 계정 일시 정지 + +**영향**: 공격 조기 탐지 및 대응 + +--- + +## 5. 보안 체크리스트 + +### 5.1 즉시 확인 항목 (개발팀) + +- [ ] **PHP 백엔드 tenant.id 검증 확인** + - [ ] 모든 API 엔드포인트에서 JWT의 tenant.id와 요청 데이터의 tenant.id 일치 여부 확인 + - [ ] Eloquent Model에 `tenant_id` 스코프 적용 확인 + - [ ] 테스트: 다른 tenant.id로 API 호출 시 403 반환 확인 + +- [ ] **클라이언트 사이드 tenant.id 조작 테스트** + - [ ] 브라우저 콘솔에서 localStorage의 tenant.id 변경 후 API 호출 + - [ ] 네트워크 탭에서 403 Forbidden 응답 확인 + - [ ] PHP 백엔드 로그에서 tenant.id 불일치 경고 확인 + +- [ ] **캐시 오염 테스트** + - [ ] 사용자 A 로그인 → 데이터 조회 + - [ ] 사용자 B (다른 tenant)로 전환 + - [ ] sessionStorage, localStorage, Zustand Store 초기화 확인 + - [ ] 사용자 B가 사용자 A의 데이터를 볼 수 없음을 확인 + +### 5.2 보안 검증 스크립트 + +```javascript +// 브라우저 콘솔에서 실행하여 tenant.id 격리 테스트 + +// 1. 현재 tenant.id 확인 +const currentUser = JSON.parse(localStorage.getItem('mes-currentUser')); +console.log('현재 tenant.id:', currentUser?.tenant?.id); + +// 2. tenant.id 조작 시도 +const fakeUser = { ...currentUser, tenant: { ...currentUser.tenant, id: 999 } }; +localStorage.setItem('mes-currentUser', JSON.stringify(fakeUser)); +console.log('조작된 tenant.id:', 999); + +// 3. API 호출 테스트 +fetch('/api/tenants/999/item-master-config') + .then(res => { + console.log('응답 상태:', res.status); + if (res.status === 403) { + console.log('✅ PASS: 403 Forbidden - tenant.id 검증 정상'); + } else { + console.error('❌ FAIL: tenant.id 검증 없음 - 보안 위험!'); + } + return res.json(); + }) + .then(data => console.log('응답 데이터:', data)) + .catch(err => console.error('에러:', err)); + +// 4. 원래 상태로 복구 +localStorage.setItem('mes-currentUser', JSON.stringify(currentUser)); +console.log('원래 tenant.id로 복구:', currentUser?.tenant?.id); +``` + +--- + +## 6. 결론 + +### 6.1 핵심 보안 위험 요약 + +| 위험 | 심각도 | 영향 | 현재 방어 | 권장 조치 | +|------|--------|------|----------|----------| +| URL의 tenant.id 조작 (IDOR) | 🔴 Critical | 타 테넌트 데이터 접근 | PHP 백엔드만 검증 | Next.js 프록시에서 검증 추가 | +| localStorage tenant.id 조작 | 🔴 Critical | API 요청 시 tenant.id 변조 | 없음 | HttpOnly 쿠키로 이동 | +| 테넌트 전환 시 캐시 오염 | 🟡 High | 이전 테넌트 데이터 노출 | 부분적 (localStorage만) | sessionStorage + Zustand 초기화 | +| sessionStorage에 tenant.id 미포함 | 🟡 High | 캐시 격리 실패 | 없음 | TenantAwareCache 패턴 적용 | +| 로그아웃 시 캐시 미정리 | 🟡 Medium | 데이터 잔류 | 부분적 | 전체 캐시 정리 | +| JWT 디코딩 없이 검증 불가 | 🟢 Low | 심층 방어 부족 | PHP 백엔드 | JWT 디코딩 추가 | + +### 6.2 보안 강화 로드맵 + +**Week 1-2 (긴급)**: +1. API 엔드포인트 tenant.id 검증 추가 +2. tenant.id를 HttpOnly 쿠키로 이동 +3. 캐시 정리 로직 개선 + +**Month 1 (단기)**: +4. masterDataStore 리팩토링 +5. API 프록시 JWT 디코딩 +6. AuthContext 검증 로직 추가 + +**Month 3 (장기)**: +7. Backend tenant.id 자동 추출 +8. 로그 및 모니터링 시스템 구축 + +### 6.3 최종 권고 + +현재 시스템은 **PHP 백엔드에만 의존**하는 단일 실패 지점 구조입니다. **심층 방어(Defense in Depth)** 원칙에 따라: + +1. ✅ **프론트엔드에서 1차 검증** (Next.js 프록시) +2. ✅ **백엔드에서 최종 검증** (PHP API) +3. ✅ **런타임 모니터링** (로그 및 알림) + +이 3단계 방어를 구축해야 합니다. + +**즉시 조치하지 않으면**: +- 공격자가 tenant.id를 조작하여 타 테넌트 데이터에 접근 가능 +- 테넌트 전환 시 데이터 오염으로 사용자 경험 저하 +- 규정 위반 (GDPR, ISO 27001) 및 법적 책임 발생 가능 + +--- + +**작성자**: Security Engineer Agent +**검토 요청**: 시스템 아키텍트, Backend 팀, DevOps 팀 +**다음 조치**: 이슈 트래커에 우선순위별 태스크 등록 diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 37a522a1..128222dd 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-09) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-16) ## ⭐ 빠른 참조 @@ -60,7 +60,12 @@ claudedocs/ | 파일 | 설명 | |------|------| -| `[NEXT-2025-12-09] item-crud-session-context.md` | ⭐ **세션 체크포인트** - 백엔드 field_key 통일 대기, 다음 작업 정리 | +| `[PLAN-2025-12-16] dynamicitemform-hook-extraction.md` | 🔴 **NEW** - DynamicItemForm 훅 분리 계획서 (2161줄 → 900줄 목표, 6 Phase) | +| `[FIX-2025-12-16] options-details-duplicate-bug.md` | options vs item_details 중복 저장 버그 (bending_details 값 덮어쓰기 문제 해결) | +| `[IMPL-2025-12-15] backend-item-api-migration.md` | 백엔드 품목 API 통합 (product/material → items), group_id 파라미터, **향후 동적 변경 예정** | +| `[NEXT-2025-12-13] item-file-upload-session-context.md` | ⭐ **세션 체크포인트** - 파일 업로드 UI 개선 완료, 백엔드 대기 중, DynamicItemForm 분리 예정 | +| `[NEXT-2025-12-12] item-crud-session-context.md` | 📁 이전 세션 - BOM/파일 연동 완료, 파일 업로드 동적화 작업 추가 | +| `[DESIGN-2025-12-12] item-master-form-builder-roadmap.md` | 🆕 **로드맵** - Low-Code Form Builder 확장 설계 (노션 스타일 블록 시스템) | | `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | 📋 DynamicItemForm 품목별 분리 계획 (Phase 2: 컴포넌트 구조 설계) | | `[REF] item-code-hardcoding.md` | ⭐ **핵심** - 품목관리 하드코딩 내역 종합 (품목유형/코드자동생성/전개도/BOM) | | `[IMPL-2025-12-02] item-code-auto-generation.md` | 품목코드 자동생성 구현 상세 | @@ -125,7 +130,8 @@ claudedocs/ | 파일 | 설명 | |------|------| -| `[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | ⭐ **NEW** - Radix UI Select 버그 해결 (Edit 모드 값 표시 안됨 → key prop 강제 리마운트) | +| `[GUIDE] large-file-handling-strategy.md` | 🔴 **NEW** - 대용량 파일 처리 전략 (100MB+ CAD 도면, 청크 업로드, 스트리밍 다운로드) | +| `[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | ⭐ **핵심** - Radix UI Select 버그 해결 (Edit 모드 값 표시 안됨 → key prop 강제 리마운트) | | `i18n-usage-guide.md` | 다국어 사용 가이드 | | `form-validation-guide.md` | 폼 유효성 검사 | | `CSS-MIGRATION-WORKFLOW.md` | CSS 마이그레이션 워크플로우 | diff --git a/claudedocs/guides/[GUIDE] large-file-handling-strategy.md b/claudedocs/guides/[GUIDE] large-file-handling-strategy.md new file mode 100644 index 00000000..771ba18b --- /dev/null +++ b/claudedocs/guides/[GUIDE] large-file-handling-strategy.md @@ -0,0 +1,458 @@ +# 대용량 파일 처리 전략 + +> CAD 도면, 이미지 등 100MB 이상 대용량 파일 업로드/다운로드 최적화 가이드 + +## 현재 방식의 문제점 + +```typescript +// fileDownload.ts - 현재 코드 +const blob = await response.blob(); // ❌ 100MB 전체를 메모리에 올림 +const url = URL.createObjectURL(blob); // ❌ 메모리 추가 사용 +``` + +| 파일 크기 | 예상 메모리 사용 | 문제점 | +|-----------|------------------|--------| +| 10MB | ~20MB | 문제 없음 | +| 50MB | ~100MB | 모바일에서 느려짐 | +| 100MB | ~200MB | 브라우저 경고 | +| 500MB+ | ~1GB | 크래시 가능 | + +--- + +## 다운로드 전략 + +### 1. 직접 URL 방식 (권장 - 가장 간단) + +백엔드가 `Content-Disposition: attachment` 헤더를 제공하면 브라우저가 직접 스트리밍 다운로드 처리. + +```typescript +/** + * 대용량 파일 다운로드 - 직접 URL 방식 + * 메모리 사용: 거의 없음 (브라우저가 스트리밍 처리) + */ +export function downloadLargeFile(fileId: number, fileName?: string): void { + const downloadUrl = `/api/proxy/files/${fileId}/download`; + + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = fileName || ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} +``` + +**장점:** +- 구현 매우 간단 +- 메모리 사용 없음 +- 브라우저 내장 다운로드 UI 사용 + +**요구사항:** +- 백엔드 `Content-Disposition: attachment; filename="파일명"` 헤더 필요 + +--- + +### 2. iframe 방식 (폴백) + +```typescript +/** + * iframe을 통한 다운로드 + * 새 탭 차단 정책 우회 가능 + */ +export function downloadViaIframe(fileId: number): void { + const downloadUrl = `/api/proxy/files/${fileId}/download`; + + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = downloadUrl; + document.body.appendChild(iframe); + + // 다운로드 시작 후 정리 + setTimeout(() => { + document.body.removeChild(iframe); + }, 10000); +} +``` + +--- + +### 3. File System Access API (최신 브라우저) + +스트리밍으로 직접 디스크에 저장. 메모리 사용 최소화. + +```typescript +/** + * File System Access API를 사용한 스트리밍 다운로드 + * 지원: Chrome 86+, Edge 86+, Opera 72+ + * 미지원: Firefox, Safari + */ +export async function downloadWithStream( + fileId: number, + fileName: string +): Promise { + // 지원 확인 + if (!('showSaveFilePicker' in window)) { + // 폴백: 직접 URL 방식 + return downloadLargeFile(fileId, fileName); + } + + try { + // 저장 위치 선택 다이얼로그 + const handle = await (window as any).showSaveFilePicker({ + suggestedName: fileName, + types: [{ + description: 'Files', + accept: { '*/*': [] } + }] + }); + + const writable = await handle.createWritable(); + const response = await fetch(`/api/proxy/files/${fileId}/download`); + + if (!response.body) throw new Error('No response body'); + + // 스트리밍으로 직접 디스크에 저장 + await response.body.pipeTo(writable); + + } catch (error) { + if ((error as Error).name === 'AbortError') { + return; // 사용자 취소 + } + throw error; + } +} +``` + +**장점:** +- 메모리 사용 거의 없음 +- 사용자가 저장 위치 선택 가능 +- 진행률 표시 가능 + +--- + +### 4. 진행률 표시가 필요한 경우 + +```typescript +/** + * 다운로드 진행률 표시 + * 주의: 전체 파일을 메모리에 올림 (대용량 비권장) + */ +export async function downloadWithProgress( + fileId: number, + fileName: string, + onProgress: (percent: number) => void +): Promise { + const response = await fetch(`/api/proxy/files/${fileId}/download`); + + const contentLength = response.headers.get('Content-Length'); + const total = contentLength ? parseInt(contentLength, 10) : 0; + + if (!response.body) throw new Error('No response body'); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + received += value.length; + + if (total > 0) { + onProgress((received / total) * 100); + } + } + + // 청크 병합 → Blob 생성 + const blob = new Blob(chunks); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + + URL.revokeObjectURL(url); +} +``` + +--- + +## 업로드 전략 + +### 1. 기본 업로드 (진행률 표시) + +```typescript +/** + * XMLHttpRequest로 업로드 진행률 표시 + */ +export function uploadWithProgress( + file: File, + url: string, + onProgress: (percent: number) => void +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('file', file); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + onProgress((e.loaded / e.total) * 100); + } + }); + + xhr.addEventListener('load', () => { + resolve(new Response(xhr.response, { status: xhr.status })); + }); + + xhr.addEventListener('error', () => { + reject(new Error('Upload failed')); + }); + + xhr.open('POST', url); + xhr.send(formData); + }); +} +``` + +--- + +### 2. 청크 업로드 (100MB+ 권장) + +대용량 파일을 작은 조각으로 나눠 업로드. 실패 시 해당 청크만 재시도. + +```typescript +const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB + +interface ChunkUploadOptions { + file: File; + itemId: number; + onProgress?: (percent: number) => void; + onChunkComplete?: (chunkIndex: number, totalChunks: number) => void; +} + +/** + * 청크 업로드 + * - 5MB 단위로 분할 + * - 실패 시 자동 재시도 (최대 3회) + * - 네트워크 끊김에 강함 + */ +export async function uploadLargeFile({ + file, + itemId, + onProgress, + onChunkComplete, +}: ChunkUploadOptions): Promise { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const uploadId = `upload_${Date.now()}_${crypto.randomUUID()}`; + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('chunkIndex', String(chunkIndex)); + formData.append('totalChunks', String(totalChunks)); + formData.append('uploadId', uploadId); + formData.append('fileName', file.name); + formData.append('fileSize', String(file.size)); + + await uploadChunkWithRetry(itemId, formData); + + onProgress?.(((chunkIndex + 1) / totalChunks) * 100); + onChunkComplete?.(chunkIndex, totalChunks); + } + + // 청크 병합 요청 + await fetch(`/api/proxy/items/${itemId}/files/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uploadId, + fileName: file.name, + fileSize: file.size, + }), + }); +} + +async function uploadChunkWithRetry( + itemId: number, + formData: FormData, + maxRetries = 3 +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(`/api/proxy/items/${itemId}/files/chunk`, { + method: 'POST', + body: formData, + }); + + if (response.ok) return; + throw new Error(`Upload failed: ${response.status}`); + + } catch (error) { + if (attempt === maxRetries - 1) throw error; + + // 지수 백오프: 1초, 2초, 4초... + await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))); + } + } +} +``` + +**백엔드 요구사항:** +- `POST /api/v1/items/{id}/files/chunk` - 청크 수신 +- `POST /api/v1/items/{id}/files/complete` - 청크 병합 + +--- + +### 3. 이어받기 지원 (Resumable Upload) + +```typescript +interface ResumableUploadState { + uploadId: string; + fileName: string; + fileSize: number; + completedChunks: number[]; +} + +/** + * 업로드 상태 저장 (localStorage) + */ +function saveUploadState(state: ResumableUploadState): void { + localStorage.setItem(`upload_${state.uploadId}`, JSON.stringify(state)); +} + +/** + * 업로드 상태 복원 + */ +function getUploadState(uploadId: string): ResumableUploadState | null { + const saved = localStorage.getItem(`upload_${uploadId}`); + return saved ? JSON.parse(saved) : null; +} + +/** + * 이어받기 가능한 업로드 + */ +export async function resumableUpload( + file: File, + itemId: number, + existingUploadId?: string +): Promise { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + + // 기존 상태 복원 또는 새로 시작 + let state: ResumableUploadState; + if (existingUploadId) { + const saved = getUploadState(existingUploadId); + if (saved && saved.fileSize === file.size) { + state = saved; + } else { + state = { + uploadId: crypto.randomUUID(), + fileName: file.name, + fileSize: file.size, + completedChunks: [], + }; + } + } else { + state = { + uploadId: crypto.randomUUID(), + fileName: file.name, + fileSize: file.size, + completedChunks: [], + }; + } + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + // 이미 완료된 청크는 스킵 + if (state.completedChunks.includes(chunkIndex)) { + continue; + } + + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + // ... 업로드 로직 ... + + // 완료 후 상태 저장 + state.completedChunks.push(chunkIndex); + saveUploadState(state); + } + + // 완료 후 상태 정리 + localStorage.removeItem(`upload_${state.uploadId}`); +} +``` + +--- + +## 파일 크기별 권장 전략 + +| 파일 크기 | 다운로드 | 업로드 | +|-----------|----------|--------| +| ~10MB | 기존 Blob 방식 OK | 기본 FormData | +| 10-50MB | 직접 URL 방식 | 진행률 표시 | +| 50-100MB | 직접 URL 방식 | 청크 업로드 | +| 100MB+ | File System API | 청크 + 이어받기 | + +--- + +## 구현 우선순위 + +| 순위 | 기능 | 난이도 | 효과 | 백엔드 수정 | +|------|------|--------|------|-------------| +| 1 | 다운로드 - 직접 URL | 쉬움 | 높음 | 불필요 | +| 2 | 업로드 진행률 표시 | 쉬움 | 중간 | 불필요 | +| 3 | 청크 업로드 | 중간 | 높음 | 필요 | +| 4 | File System API | 중간 | 중간 | 불필요 | +| 5 | 이어받기 | 어려움 | 높음 | 필요 | + +--- + +## 빠른 적용: fileDownload.ts 개선 + +```typescript +// src/lib/utils/fileDownload.ts + +/** + * 파일 다운로드 (대용량 지원) + * - 브라우저가 직접 스트리밍 다운로드 처리 + * - 메모리 사용 없음 + */ +export function downloadFileById(fileId: number, fileName?: string): void { + const downloadUrl = `/api/proxy/files/${fileId}/download`; + + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = fileName || ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +/** + * 기존 Blob 방식 (소용량 + 파일명 추출 필요 시) + */ +export async function downloadFileByIdWithBlob( + fileId: number, + fileName?: string +): Promise { + // ... 기존 코드 유지 ... +} +``` + +--- + +## 참고 자료 + +- [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) +- [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) +- [tus.io - Resumable Upload Protocol](https://tus.io/) +- [Uppy - JavaScript File Uploader](https://uppy.io/) \ No newline at end of file diff --git a/claudedocs/item-master/[DESIGN-2025-12-12] item-master-form-builder-roadmap.md b/claudedocs/item-master/[DESIGN-2025-12-12] item-master-form-builder-roadmap.md new file mode 100644 index 00000000..0dfafb01 --- /dev/null +++ b/claudedocs/item-master/[DESIGN-2025-12-12] item-master-form-builder-roadmap.md @@ -0,0 +1,421 @@ +# 품목기준관리 → Low-Code Form Builder 확장 로드맵 + +> 작성일: 2025-12-12 +> 상태: 기획/설계 단계 +> 목표: 노션/Airtable 수준의 유연한 폼 빌더 구현 + +--- + +## 📊 현재 시스템 대응력 분석 + +### 현재 대응 가능한 시나리오 + +| 시나리오 | 지원 여부 | 구현 방식 | +|---------|----------|----------| +| 업종별 다른 품목 양식 | ✅ | 페이지별 item_type (FG/PT/SM/RM/CS) | +| 필드 선택적 표시 | ✅ | 섹션/필드 조합으로 구성 | +| 필드 유형 다양화 | ✅ | 6가지 타입 (text, number, dropdown, checkbox, date, textarea) | +| 필수/선택 필드 구분 | ✅ | is_required 플래그 | +| 조건부 필드 표시 | ✅ | display_condition + useConditionalDisplay | +| 템플릿 재사용 | ✅ | 섹션 템플릿 시스템 | +| BOM 구성 | ✅ | DynamicBOMSection | +| 검증 규칙 커스터마이징 | ✅ | validation_rules (min/max/pattern) | +| 단위 옵션 공유 | ✅ | unitOptions API | + +### 🟢 잘 대응하는 상황들 + +**1. 제조업 표준 시나리오** +``` +완제품(FG): 품명, 규격, 단위, BOM 구성 +부품(PT): 품명, 재질, 벤딩 다이어그램 +원자재(RM): 품명, 단가, 공급업체 +``` + +**2. 조건부 필드 시나리오** +``` +"품목유형이 '제품'이면 → 중량, 치수, 색상 필드 표시" +"품목유형이 '원자재'면 → 재질코드, 공급업체 필드 표시" +``` + +**3. 다중 테넌트 기본 격리** +``` +테넌트 A: 전자부품 → 전압, 저항, 핀 수 필드 +테넌트 B: 식품 → 유통기한, 보관온도, 칼로리 필드 +``` + +### 🟡 제한적으로 대응하는 상황들 + +| 상황 | 현재 한계 | 워크어라운드 | +|-----|---------|------------| +| 복합 조건 (A AND B) | 단일 필드 조건만 지원 | 별도 조건 여러 개 설정 | +| 범위 조건 (값 > 100) | `===` 동등 비교만 지원 | 드롭다운으로 범위 선택 | +| 중첩 BOM | 1단계 BOM만 지원 | 수동 관리 필요 | +| 필드 간 교차 검증 | 지원 안 함 | 커스텀 훅 구현 필요 | + +### 🔴 현재 대응 불가능한 예외 상황들 + +#### 1. 복합 조건 로직 +``` +❌ "품목유형='제품' AND 가격>100000 이면 승인필드 표시" +❌ "재질이 '금속' OR '플라스틱'이면 가공정보 표시" +``` + +#### 2. 동적 필드 생성 +``` +❌ "BOM 라인 수에 따라 동적으로 필드 생성" +❌ "첨부파일 수에 따라 설명 필드 추가" +``` + +#### 3. 필드 간 연동 +``` +❌ "단가 × 수량 = 합계 자동계산" +❌ "시작일 < 종료일 교차 검증" +``` + +#### 4. 권한 기반 필드 제어 +``` +❌ "관리자만 가격 수정 가능" +❌ "승인 후 품목코드 수정 불가" +``` + +#### 5. 워크플로우 연동 +``` +❌ "상태가 '승인대기'면 수정 버튼 비활성화" +❌ "저장 시 자동 결재 요청" +``` + +### 📊 대응력 요약 + +| 영역 | 현재 대응률 | 목표 대응률 | +|-----|-----------|-----------| +| 표준 제조업 | 90% | 95% | +| 서비스업 | 70% | 85% | +| 유통/물류 | 75% | 90% | +| 복잡한 승인 워크플로우 | 20% | 70% | +| 동적 계산 필드 | 0% | 60% | + +--- + +## 🎯 확장 설계 + +### 1. 필드 타입 확장 + +#### 현재 (6종) +```typescript +type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; +``` + +#### 확장안 (15종+) +```typescript +type FieldType = + // 기본 (기존) + | 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea' + // 파일/미디어 + | 'file' // 단일 파일 업로드 + | 'files' // 다중 파일 업로드 + | 'image' // 이미지 전용 (미리보기) + | 'signature' // 서명 캔버스 + // 고급 입력 + | 'richtext' // WYSIWYG 에디터 + | 'code' // 코드 에디터 (모나코) + | 'color' // 색상 선택기 + | 'rating' // 별점/평점 + | 'slider' // 슬라이더 + // 관계/참조 + | 'lookup' // 다른 테이블 참조 (품목 검색처럼) + | 'user' // 사용자 선택 + | 'multiselect' // 다중 선택 드롭다운 + // 레이아웃 + | 'divider' // 구분선 + | 'heading' // 제목 텍스트 + | 'spacer'; // 여백 +``` + +--- + +### 2. 섹션 디자인 옵션 + +#### 현재 섹션 구조 +```typescript +interface ItemSectionResponse { + section_name: string; + section_type: 'BASIC' | 'BOM'; + // 디자인 옵션 없음 ❌ +} +``` + +#### 확장안: 섹션 스타일링 +```typescript +interface SectionDesign { + // 레이아웃 + layout: 'vertical' | 'horizontal' | 'grid'; + columns?: 1 | 2 | 3 | 4; // 그리드 컬럼 수 + gap?: 'none' | 'sm' | 'md' | 'lg'; + + // 접기/펼치기 + collapsible: boolean; + defaultCollapsed: boolean; + + // 스타일 + variant: 'default' | 'card' | 'bordered' | 'ghost'; + backgroundColor?: string; // 배경색 + borderColor?: string; // 테두리색 + + // 아이콘/헤더 + icon?: string; // Lucide 아이콘명 + headerStyle?: 'default' | 'accent' | 'minimal'; + + // 조건부 스타일 + conditionalStyles?: { + condition: DisplayCondition; + styles: Partial; + }[]; +} +``` + +#### UI 예시 +``` +┌─ 📦 기본정보 ─────────────────────────────┐ +│ [품명] [규격] [단위] ← 3컬럼 그리드 │ +│ [설명 ] │ +└───────────────────────────────────────────┘ + +▼ 📋 상세규격 (접기/펼치기) + [두께] [길이] [너비] [무게] + +┌─ 🔧 BOM 구성 ─────────────────────────────┐ ← 카드 스타일 +│ │ 품목 │ 수량 │ 단가 │ 합계 │ │ +│ │ ... │ ... │ ... │ ... │ │ +└───────────────────────────────────────────┘ +``` + +--- + +### 3. 노션 스타일 블록 시스템 + +#### 블록 타입 추가 +```typescript +type BlockType = + // 콘텐츠 블록 + | 'section' // 필드 그룹 (기존) + | 'callout' // 강조 박스 (아이콘 + 배경색) + | 'quote' // 인용구 + | 'toggle' // 토글 리스트 + // 미디어 블록 + | 'embed' // 외부 콘텐츠 (YouTube, 지도) + | 'table' // 간단한 테이블 + // 데이터 블록 + | 'database' // 인라인 데이터베이스 (BOM 확장) + | 'formula' // 계산 필드 블록 +``` + +#### 블록 속성 +```typescript +interface BlockProperties { + // 공통 + id: string; + type: BlockType; + + // 노션 스타일 속성 + color?: 'default' | 'gray' | 'brown' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink' | 'red'; + backgroundColor?: string; + + // Callout 전용 + icon?: string | '💡' | '⚠️' | '📌' | '✅'; + + // 중첩 가능 + children?: Block[]; +} +``` + +--- + +### 4. 데이터 구조 변경 + +#### API 스키마 확장 +```typescript +// fields 테이블 확장 +interface ItemFieldRequest { + // 기존 필드들... + field_type: ExtendedFieldType; // 확장된 타입 + + // 새 필드들 + properties: { + // 기존 + unit?: string; + precision?: number; + + // 파일 관련 + accept?: string; // "image/*", ".pdf,.doc" + maxSize?: number; // bytes + maxFiles?: number; // 다중 업로드 시 + + // 참조 관련 + lookupTable?: string; // "items", "users", "suppliers" + lookupDisplayField?: string; // "item_name" + lookupValueField?: string; // "id" + lookupFilters?: object; // { item_type: "RM" } + + // 계산 관련 + formula?: string; // "qty * unit_price" + dependencies?: string[]; // ["qty", "unit_price"] + + // 레이아웃 관련 + colspan?: number; // 그리드에서 차지할 컬럼 수 + width?: 'auto' | 'full' | '1/2' | '1/3' | '1/4'; + }; +} + +// sections 테이블 확장 +interface ItemSectionRequest { + // 기존 필드들... + + // 새 필드들 + design: SectionDesign; +} +``` + +--- + +## 🚀 구현 로드맵 + +### Phase 1: 필수 필드 타입 (2주) +``` +✅ file - 파일 업로드 (현재 하드코딩 → 동적화) +✅ image - 이미지 업로드 + 미리보기 +✅ multiselect - 다중 선택 +✅ lookup - 참조 필드 (품목 검색 일반화) +✅ divider - 구분선 +✅ heading - 제목 텍스트 +``` + +### Phase 2: 섹션 디자인 (1주) +``` +✅ collapsible - 접기/펼치기 +✅ columns - 2/3/4 컬럼 레이아웃 +✅ variant - card/bordered/ghost 스타일 +✅ icon - 섹션 아이콘 +``` + +### Phase 3: 고급 필드 (2주) +``` +✅ richtext - WYSIWYG (TipTap 또는 Plate) +✅ signature - 서명 캔버스 +✅ rating - 별점 +✅ slider - 범위 슬라이더 +✅ formula - 계산 필드 +``` + +### Phase 4: 노션 블록 시스템 (3주) +``` +✅ callout - 강조 박스 +✅ toggle - 토글 리스트 +✅ database - 인라인 데이터베이스 +✅ drag-drop - 블록 순서 변경 +✅ slash-cmd - "/" 명령어로 블록 추가 +``` + +--- + +## 🔧 조건 로직 강화 (권장 우선순위) + +### 현재 +```typescript +{ expectedValue: "Product" } +``` + +### 개선안: AND/OR 연산자 추가 +```typescript +{ + operator: "AND", + conditions: [ + { fieldKey: "item_type", operator: "===", value: "Product" }, + { fieldKey: "price", operator: ">", value: 100000 } + ] +} +``` + +### 계산 필드 추가 +```typescript +interface ComputedField { + field_key: string; + formula: "multiply(qty, unit_price)"; // 또는 expression + dependencies: ["qty", "unit_price"]; + readonly: true; +} +``` + +### 필드 권한 모델 +```typescript +interface FieldPermission { + fieldId: number; + role: 'admin' | 'editor' | 'viewer'; + permissions: ['view', 'edit', 'lock']; + conditionOnStatus?: string; // "승인완료" 상태면 편집 불가 +} +``` + +### 워크플로우 연동 +```typescript +interface WorkflowTrigger { + event: 'create' | 'update' | 'status_change'; + condition: DisplayCondition; // 기존 조건 재사용 + action: 'notify' | 'request_approval' | 'auto_update'; +} +``` + +--- + +## 🎨 관리 UI 개선안 + +### 필드 추가 다이얼로그 +``` +┌─ 필드 추가 ─────────────────────────────────┐ +│ │ +│ 📝 텍스트 │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │단문│ │장문│ │숫자│ │이메일│ │ +│ └────┘ └────┘ └────┘ └────┘ │ +│ │ +│ 📋 선택 │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │단일│ │다중│ │체크│ │라디오│ │ +│ └────┘ └────┘ └────┘ └────┘ │ +│ │ +│ 📎 미디어 │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │파일│ │이미지│ │서명│ │첨부│ │ +│ └────┘ └────┘ └────┘ └────┘ │ +│ │ +│ 🔗 관계 │ +│ ┌────┐ ┌────┐ ┌────┐ │ +│ │참조│ │사용자│ │링크│ │ +│ └────┘ └────┘ └────┘ │ +│ │ +│ 📐 레이아웃 │ +│ ┌────┐ ┌────┐ ┌────┐ │ +│ │구분선│ │제목│ │여백│ │ +│ └────┘ └────┘ └────┘ │ +│ │ +└──────────────────────────────────────────────┘ +``` + +--- + +## 💡 즉시 적용 가능한 개선 (Quick Wins) + +1. **복합 조건 지원**: `display_condition`에 `operator` 필드 추가 +2. **자동 저장**: 30초 간격 autosave 구현 +3. **필드 잠금**: `readonly` 플래그 + 상태 연동 +4. **감사 로그**: 필드 변경 이력 추적 + +--- + +## 📚 관련 문서 + +| 문서 | 위치 | +|------|------| +| 소스 파일 분리 계획 | `[PLAN-2025-11-27] item-form-component-separation.md` | +| 시스템 분석 | `[ANALYSIS] item-master-data-management.md` | +| API 명세 | `[API-2025-11-24] item-management-dynamic-api-spec.md` | +| 프론트엔드 설계 | `[DESIGN-2025-11-24] item-management-dynamic-frontend.md` | diff --git a/claudedocs/item-master/[FIX-2025-12-16] options-details-duplicate-bug.md b/claudedocs/item-master/[FIX-2025-12-16] options-details-duplicate-bug.md new file mode 100644 index 00000000..9aaf81b7 --- /dev/null +++ b/claudedocs/item-master/[FIX-2025-12-16] options-details-duplicate-bug.md @@ -0,0 +1,179 @@ +# [FIX-2025-12-16] options vs item_details 중복 저장 버그 + +## 문제 현상 + +- 품목 수정 시 `bending_details` 값이 111 → 1110으로 저장했는데, 다시 조회하면 111로 표시됨 +- 입력한 최신값이 아닌 이전에 저장된 값이 표시되는 문제 + +## 근본 원인 + +### 백엔드 저장 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 백엔드 저장 구조 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [동적 필드] ─────────────────────▶ options (JSON) │ +│ (품목기준관리에서 생성) [{label, value}, ...] │ +│ │ +│ [고정 필드] ─────────────────────▶ item_details (컬럼) │ +│ (하드코딩: 시방서, 인정서, 전개도 등) bending_details 등 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 문제 발생 지점 + +**백엔드 `ItemService.php`의 `getKnownFields()` (85-109줄)** + +```php +private function getKnownFields(): array +{ + // 1. 기본 고정 필드 (items 테이블 컬럼) + $baseFields = [ + 'id', 'tenant_id', 'item_type', 'code', 'name', 'unit', ... + ]; + + // 2. ItemField에서 source_table='items' AND storage_type='column' 인 것만 조회 + $columnFields = ItemField::where('source_table', 'items') // ⚠️ items만! + ->where('storage_type', 'column') + ... + + // ❌ item_details 테이블 컬럼 누락! + // ❌ SystemFields 상수 미사용! +} +``` + +**결과**: `bending_details` 등 `item_details` 컬럼이 "알려진 필드"로 인식되지 않음 + +### 데이터 흐름 + +``` +저장 시: + 요청: { bending_details: 111 } + → item_details.bending_details = 111 (extractDetailData로 저장) + → options: [{label: "bending_details", value: "111"}] (동적 필드로 잘못 인식) + +수정 시 (값을 1110으로 변경): + 요청: { bending_details: 1110 } + → item_details.bending_details = 1110 (최신값) + → options: [{label: "bending_details", value: "1110"}] (기존 값 덮어쓰기) + +⚠️ 문제: options 병합 시 이전 값이 남아있는 경우가 있음 + +조회 시 (프론트엔드): + API 응답: { + bending_details: 1110, // ← item_details에서 가져온 최신값 + options: [{label: "bending_details", value: "111"}] // ← 첫 저장 시 값! + } + + 프론트엔드 매핑: + 1. formData.bending_details = 1110 (API 응답에서) + 2. options 순회: formData.bending_details = "111" ← 덮어쓰기! ❌ +``` + +## 해결 방법 + +### A. 프론트엔드 수정 (즉시 적용) ✅ + +**파일**: `src/app/[locale]/(protected)/items/[id]/edit/page.tsx` + +**변경 내용**: + +1. `excludeKeys`에 `'details'` 추가 +2. `details` 객체 펼치기 로직 추가 (최신값 매핑) +3. `options` 순회 시 `item_details` 필드 제외 + +```typescript +// 1. details는 아래에서 펼쳐서 추가 +const excludeKeys = [..., 'details']; + +// 2. details 객체가 있으면 펼쳐서 추가 (item_details 테이블 필드) +const details = (data as Record).details; +if (details && typeof details === 'object') { + const detailExcludeKeys = ['id', 'item_id', 'created_at', 'updated_at']; + Object.entries(details).forEach(([key, value]) => { + if (!detailExcludeKeys.includes(key) && value !== null && value !== undefined) { + formData[key] = value; + } + }); +} + +// 3. options 순회 시 item_details 필드 제외 +const detailsFieldsInOptions = [ + 'files', 'bending_details', 'bending_diagram', + 'specification_file', 'certification_file', +]; +if (data.options && Array.isArray(data.options)) { + data.options.forEach((opt) => { + if (opt.label && opt.value && !detailsFieldsInOptions.includes(opt.label)) { + formData[opt.label] = opt.value; + } + }); +} +``` + +### B. 백엔드 수정 (근본 해결, 권장) + +**파일**: `/app/Services/ItemService.php` - `getKnownFields()` + +**수정 요청**: + +```php +// 방안 A: extractDetailData() 필드들을 knownFields에 추가 +private function getKnownFields(): array +{ + $baseFields = [...]; + + // item_details 테이블 필드 추가 + $detailFields = [ + 'bending_diagram', 'bending_details', + 'specification_file', 'certification_file', ... + ]; + + return array_unique(array_merge($baseFields, $columnFields, $detailFields, $apiFields)); +} + +// 방안 B: SystemFields 상수 활용 +use App\Constants\SystemFields; + +private function getKnownFields(): array +{ + return array_unique(array_merge( + $baseFields, + $columnFields, + SystemFields::getAllReservedKeysForGroup(SystemFields::GROUP_ITEM_MASTER), + $apiFields + )); +} +``` + +## 관련 파일 + +| 파일 | 역할 | +|------|------| +| `sam-api/app/Services/ItemService.php` | getKnownFields(), extractDetailData() | +| `sam-api/app/Constants/SystemFields.php` | 시스템 필드 정의 (bending_details 포함) | +| `sam-api/app/Models/Items/ItemDetail.php` | item_details 테이블 모델 | +| `sam-next/.../edit/page.tsx` | mapApiResponseToFormData() | + +## 영향 범위 + +- `bending_details` (전개도 상세) +- `bending_diagram` (전개도 이미지) +- `specification_file` (시방서) +- `certification_file` (인정서) +- `files` (첨부파일) +- 기타 `item_details` 테이블의 고정 컬럼들 + +## 향후 계획 + +- 전개도 상세 입력 수치를 품목기준관리에서 동적 필드로 등록하는 형태로 변경 예정 +- 그때는 `options`에 저장되는 것이 정상 +- 현재는 하드코딩된 고정 필드이므로 `item_details`에만 저장되어야 함 + +## 참고 + +- 유사 버그: `files` 필드도 같은 문제가 있었음 (2025-12-15 수정) +- `[GUIDE] radix-ui-select-controlled-mode-bug.md` - Radix UI Select 관련 버그 \ No newline at end of file diff --git a/claudedocs/item-master/[IMPL-2025-12-15] backend-item-api-migration.md b/claudedocs/item-master/[IMPL-2025-12-15] backend-item-api-migration.md new file mode 100644 index 00000000..f2130bfc --- /dev/null +++ b/claudedocs/item-master/[IMPL-2025-12-15] backend-item-api-migration.md @@ -0,0 +1,166 @@ +# 백엔드 품목 API 통합 마이그레이션 + +## 개요 + +| 항목 | 내용 | +|------|------| +| **작성일** | 2025-12-15 | +| **배경** | 백엔드에서 product/material 분리 구조 → 통합 구조로 변경 | +| **영향** | 프론트엔드 품목 목록 API 호출 방식 변경 | + +--- + +## 백엔드 변경사항 요약 + +### 주요 커밋 +``` +aaf7979 fix: ItemsFileController 파일 API 오류 수정 +18ef35a refactor: 레거시 product_id/material_id 컬럼 삭제 +4f3b218 feat: Phase 5 - 참조 테이블 모델 item_id 마이그레이션 +20ad6da fix: P0 Critical 이슈 수정 - 삭제된 Product/Material 참조 제거 +039fd62 refactor: products/materials 테이블 및 관련 코드 삭제 +9cc7cd1 fix: ItemService newQuery()에 item_type 필터 추가 +d1afa6e feat: ItemService 동적 테이블 라우팅 구현 +``` + +### 핵심 변경 +1. `products`, `materials` 테이블 삭제 → `items` 테이블로 통합 +2. `ItemService`가 `item_type` 기반 **동적 테이블 라우팅** 구현 +3. API 호출 시 `item_type` 또는 `group_id` **필수** + +--- + +## 프론트엔드 수정 (2025-12-15) + +### 수정 파일 +- `src/hooks/useItemList.ts` + +### 변경 내용 + +#### 1. 품목 목록 조회 (`fetchItems`) + +| 조회 타입 | 변경 전 | 변경 후 | +|----------|---------|---------| +| 전체 조회 | 파라미터 없음 | `group_id=1` | +| 타입별 조회 | `type=FG` | `type=FG` (유지) | + +```typescript +// 변경 후 코드 +if (filters.type && filters.type !== 'all') { + // 특정 타입 조회: type 파라미터 사용 + params.append('type', filters.type); +} else { + // 전체 조회: group_id=1 (품목관리 그룹) + params.append('group_id', '1'); +} +``` + +#### 2. 전체 통계 조회 (`fetchTotalStats`) + +| 조회 타입 | 변경 전 | 변경 후 | +|----------|---------|---------| +| 전체 | `/api/proxy/items?size=1` | `/api/proxy/items?group_id=1&size=1` | +| 타입별 | `/api/proxy/items?type=FG&size=1` | (유지) | + +--- + +## 현재 하드코딩 값 (향후 동적 변경 예정) + +### 하드코딩된 값 목록 + +| 항목 | 현재 값 | 위치 | 비고 | +|------|---------|------|------| +| `group_id` | `1` | `useItemList.ts` | 품목관리 그룹 고정 | +| `ItemType` | `'FG' \| 'PT' \| 'SM' \| 'RM' \| 'CS'` | `src/types/item.ts` | 품목 유형 고정 | +| 통계 타입 | 5개 고정 (FG, PT, SM, RM, CS) | `useItemList.ts` | TotalStats 인터페이스 | + +### 향후 동적 변경 계획 + +백엔드 품목기준관리에서 **그룹(Group)** 상위 개념이 추가되면: + +1. **init API 응답**에 다음 정보 포함 필요: + ```typescript + { + groupId: number, // 현재 페이지의 group_id + itemTypes: string[], // 이 그룹에서 사용하는 item_type 목록 + // 예: ['FG', 'PT', 'SM', 'RM', 'CS'] + } + ``` + +2. **프론트엔드 수정 필요**: + - `useItemList.ts`: 하드코딩된 `group_id=1` 제거 → API 응답값 사용 + - `src/types/item.ts`: `ItemType` 타입을 동적으로 처리 + - `TotalStats`: 고정 타입 대신 동적 item_type 목록 기반으로 변경 + +3. **영향 범위**: + - 품목 목록 페이지 + - 품목 통계 (타입별 카운트) + - 품목 필터 드롭다운 + - 품목 생성/수정 폼의 타입 선택 + +--- + +## API 요청 형식 정리 + +### 품목 목록 조회 + +```bash +# 전체 조회 (품목관리 그룹) +GET /api/proxy/items?group_id=1 + +# 타입별 조회 +GET /api/proxy/items?type=FG +GET /api/proxy/items?type=PT +GET /api/proxy/items?type=SM +GET /api/proxy/items?type=RM +GET /api/proxy/items?type=CS + +# 검색 + 페이지네이션 +GET /api/proxy/items?group_id=1&search=검색어&page=1&size=20 +GET /api/proxy/items?type=FG&search=검색어&page=1&size=20 +``` + +### 품목 상세/수정/삭제 (item_type 필수) + +```bash +# 단건 조회 +GET /api/proxy/items/{id}?item_type=FG + +# 수정 +PUT /api/proxy/items/{id} +Body: { item_type: 'FG', ... } + +# 삭제 +DELETE /api/proxy/items/{id}?item_type=FG +``` + +--- + +## 체크리스트 + +### 완료 (2025-12-15) +- [x] `useItemList.ts` - 전체 조회 시 `group_id=1` 추가 +- [x] `useItemList.ts` - 통계 조회 시 `group_id=1` 추가 +- [x] 문서 작성 + +### 백엔드 대기 +- [ ] 백엔드 `group_id` 파라미터 처리 구현 확인 +- [ ] init API에 `groupId`, `itemTypes` 정보 추가 요청 + +### 향후 작업 (동적 변경 시) +- [ ] `useItemList.ts` - `group_id` 하드코딩 제거 → Context/API 응답값 사용 +- [ ] `src/types/item.ts` - `ItemType` 동적 처리 +- [ ] `TotalStats` 인터페이스 동적 타입 지원 +- [ ] 품목 필터 드롭다운 동적 렌더링 + +--- + +## 참고 + +### 관련 파일 +- `src/hooks/useItemList.ts` - 품목 목록 훅 +- `src/types/item.ts` - ItemType 타입 정의 +- `src/lib/api/item-master.ts` - 품목기준관리 API + +### 관련 문서 +- `claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md` - 테넌트 데이터 격리 \ No newline at end of file diff --git a/claudedocs/item-master/[NEXT-2025-12-10] item-crud-session-context.md b/claudedocs/item-master/[NEXT-2025-12-10] item-crud-session-context.md new file mode 100644 index 00000000..838823bf --- /dev/null +++ b/claudedocs/item-master/[NEXT-2025-12-10] item-crud-session-context.md @@ -0,0 +1,119 @@ +# 품목관리 세션 체크포인트 + +> 작성일: 2025-12-10 +> 이전 세션: 2025-12-09 +> 상태: ✅ 프론트엔드 수정 완료 + +--- + +## 🎯 오늘의 작업 목표 + +### 백엔드 변경 사항 (완료됨) + +**field_key 통일 방식:** +- **기존**: 프론트엔드에서 `unit` → `98_unit` 변환 후 저장 +- **변경**: 백엔드에서 field_key를 그대로 저장/반환 + - 기존 레거시 데이터(`98_unit` 형식)도 그대로 동작 + - 신규 등록 시 `unit`으로 등록하면 `unit`으로 저장 + - 중복 field_key는 백엔드에서 자동 처리 (suffix 추가 또는 사용자 변경) + +**핵심 포인트**: 프론트엔드에서 변환 없이, 백엔드가 주는 값 그대로 사용! + +--- + +## ✅ 완료된 작업 + +### 1. 수정 페이지 `mapApiResponseToFormData` 개선 + +**파일**: `src/app/[locale]/(protected)/items/[id]/edit/page.tsx` + +**변경 내용**: +- 하드코딩된 필드 매핑 제거 (약 90줄 → 50줄) +- 백엔드 응답의 모든 필드를 그대로 formData에 복사 +- 시스템 필드만 제외 (`id`, `tenant_id`, `created_at`, `updated_at`, `deleted_at` 등) + +```typescript +// 변경 후: 백엔드 응답을 그대로 사용 +function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { + const formData: DynamicFormData = {}; + const excludeKeys = ['id', 'tenant_id', 'category_id', 'category', + 'created_at', 'updated_at', 'deleted_at', 'component_lines', 'bom']; + + Object.entries(data).forEach(([key, value]) => { + if (!excludeKeys.includes(key) && value !== null && value !== undefined) { + formData[key] = value; + } + }); + + // attributes, options 처리... + return formData; +} +``` + +### 2. item_type 파라미터 수정 + +**변경 파일**: +- `src/app/[locale]/(protected)/items/[id]/page.tsx` (상세 페이지) +- `src/app/[locale]/(protected)/items/[id]/edit/page.tsx` (수정 페이지) + +**변경 내용**: +- 기존: `item_type=MATERIAL` +- 변경: `item_type=SM` / `item_type=RM` / `item_type=CS` (실제 코드 전달) + +```typescript +// 변경 후 +queryParams.append('item_type', itemType); // SM, RM, CS 그대로 전달 +``` + +### 3. 삭제 API item_type 파라미터 추가 + +**파일**: `src/components/items/ItemListClient.tsx` + +**변경 내용**: +- 단건 삭제: `?item_type=${itemToDelete.itemType}` 추가 +- 일괄 삭제: `?item_type=${item?.itemType}` 추가 + +### 4. 빌드 검증 + +```bash +npm run build # ✅ 성공 +``` + +--- + +## 📋 테스트 체크리스트 + +### 등록 테스트 +- [ ] FG(제품) 등록 → 데이터 표시 확인 +- [ ] PT-조립부품 등록 → 데이터 표시 확인 +- [ ] PT-절곡부품 등록 → 데이터 표시 확인 +- [ ] SM/RM/CS 등록 → 데이터 표시 확인 + +### 수정 테스트 +- [ ] 수정 페이지 진입 → 모든 필드 데이터 로드 확인 +- [ ] 드롭다운 값 정상 표시 확인 +- [ ] 수정 후 저장 → 값 유지 확인 + +### 삭제 테스트 +- [ ] 단건 삭제 (SM/RM/CS) +- [ ] 일괄 삭제 (SM/RM/CS) + +--- + +## 🔄 코드 변경 요약 + +| 파일 | 변경 내용 | +|------|----------| +| `items/[id]/page.tsx` | item_type 파라미터: MATERIAL → 실제 코드 | +| `items/[id]/edit/page.tsx` | mapApiResponseToFormData 간소화, item_type 파라미터 수정 | +| `ItemListClient.tsx` | 삭제 API에 item_type 파라미터 추가 (단건/일괄) | + +--- + +## 📚 관련 문서 + +| 문서 | 위치 | +|------|------| +| 이전 세션 컨텍스트 | `[NEXT-2025-12-09] item-crud-session-context.md` | +| DynamicForm 분리 계획 | `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | +| Radix UI 버그 해결 | `claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | \ No newline at end of file diff --git a/claudedocs/item-master/[NEXT-2025-12-12] item-crud-session-context.md b/claudedocs/item-master/[NEXT-2025-12-12] item-crud-session-context.md new file mode 100644 index 00000000..5808ea2a --- /dev/null +++ b/claudedocs/item-master/[NEXT-2025-12-12] item-crud-session-context.md @@ -0,0 +1,205 @@ +# 품목관리 세션 체크포인트 + +> 작성일: 2025-12-12 +> 이전 세션: 2025-12-10 +> 상태: ✅ 프론트엔드 작업 완료 (백엔드 API 대기) + +--- + +## 🎯 오늘의 작업 목표 + +### 전개도 상세 입력 (폭 합계 연동) - ✅ 완료 +### BOM 테이블 UI 수정 - ✅ 완료 +### BOM 데이터 전송/로드 - ✅ 완료 +### 파일 업로드 오류 수정 - ✅ 완료 + +--- + +## ✅ 완료된 작업 + +### 1. BOM API 연동 완료 + +**변경 사항**: +- `child_item_type` 필드 추가 (PRODUCT/MATERIAL 구분) +- BOM 저장 형식: `{ child_item_id, child_item_type, quantity }` 최소 필드만 저장 +- 품목 유형 매핑: FG/PT → PRODUCT, SM/RM/CS → MATERIAL + +**수정 파일**: +- `types.ts`: BOMLine에 `childItemType` 필드 추가 +- `DynamicBOMSection.tsx`: `getChildItemType()` 헬퍼 함수 추가 +- `index.tsx`: BOM 전송 형식 간소화 + +### 2. 수정 화면 BOM 로드 버그 수정 + +**문제**: `mapApiResponseToFormData`가 `bom`을 제외하여 `initialData`에 BOM이 없었음 + +**해결**: `initialBomLines` prop으로 BOM 데이터 별도 전달 + +**수정 파일**: +- `types.ts`: `DynamicItemFormProps`에 `initialBomLines?: BOMLine[]` 추가 +- `edit/page.tsx`: API 응답에서 BOM 추출 → `initialBomLines` state → prop 전달 +- `index.tsx`: `initialBomLines` prop 수신 → useEffect로 `setBomLines()` 호출 + +### 3. BOM key 값 중복 에러 수정 + +**문제**: BOM 항목에 id가 없을 때 빈 문자열로 key 중복 발생 + +**해결**: `page.tsx`의 `mapApiResponseToItemMaster`에서 fallback key 생성 +```typescript +id: String(bomItem.id || bomItem.child_item_id || `bom-${index}`) +``` + +### 4. 이미지 업로드 500 에러 수정 + +**문제**: `bending_diagram`에 Base64 이미지 데이터가 JSON 본문에 포함되어 백엔드 500 에러 + +**해결**: API 호출 전 base64 이미지 데이터 제거 (파일은 별도 API로 업로드) + +**수정 파일**: +- `edit/page.tsx`: base64 이미지 필드 제거 로직 추가 +- `create/page.tsx`: 동일하게 적용 + +```typescript +// API 호출 전 이미지 데이터 제거 +if (submitData.bending_diagram?.startsWith('data:')) delete submitData.bending_diagram; +if (submitData.specification_file?.startsWith('data:')) delete submitData.specification_file; +if (submitData.certification_file?.startsWith('data:')) delete submitData.certification_file; +``` + +### 5. bending_details 배열 전송 오류 수정 + +**문제**: `JSON.stringify()`로 문자열 전송 → 백엔드에서 "배열이어야 합니다" 오류 + +**해결**: PHP가 이해하는 배열 형태로 FormData 전송 + +**수정 파일**: `src/lib/api/items.ts` + +```typescript +// 기존 (문자열) +formData.append('bending_details', JSON.stringify(options.bendingDetails)); + +// 수정 (배열 형태) +options.bendingDetails.forEach((detail, index) => { + Object.entries(detail).forEach(([key, value]) => { + formData.append(`bending_details[${index}][${key}]`, String(value)); + }); +}); +``` + +### 6. 제품 정보 섹션 빈 카드 숨김 + +**문제**: FG 품목 상세에서 "제품 정보" 섹션이 내용 없이 빈 카드로 표시 + +**해결**: 내용이 있을 때만 섹션 표시 + +**수정 파일**: `ItemDetailClient.tsx` + +```typescript +// 기존 +{item.itemType === 'FG' && ( + +// 수정 +{item.itemType === 'FG' && (item.productCategory || item.lotAbbreviation || item.note) && ( +``` + +--- + +## ⏳ 백엔드 대기 사항 + +### 1. 파일 다운로드 인증 문제 🔴 + +**현재 문제**: +- 파일 다운로드 URL(`/storage/{id}`)에 직접 접근 시 `"Unauthorized. Invalid or missing API key"` 에러 +- 브라우저에서 `다운로드` 클릭 시 API 키가 없어서 401 에러 +- 시방서(PDF), 인정서(PDF), 전개도(이미지) 모두 동일한 문제 + +**수정 요청 옵션**: +1. **옵션 A (권장)**: Signed URL 방식 - 임시 토큰이 포함된 URL 생성 (만료 시간 설정) +2. **옵션 B**: 파일 다운로드 엔드포인트를 public으로 변경 (인증 불필요) +3. **옵션 C**: 프론트엔드 프록시 경유 (Next.js API route에서 API 키 추가) + +### 2. 품목 조회 시 파일 URL 미반환 문제 🔴 + +**현재 문제**: +- `bending_diagram`, `specification_file`, `certification_file` 필드에 **file_id(숫자)**만 반환됨 +- 프론트엔드에서 이미지/PDF를 표시하려면 **실제 다운로드 URL**이 필요 +- 현재 file_id만 있어서 파일을 불러올 수 없음 + +**수정 요청**: +품목 조회 응답에서 file_id와 함께 실제 URL도 반환: + +```json +{ + "id": 813, + "bending_diagram": 123, + "bending_diagram_url": "/api/v1/files/download/xxx", + "specification_file": 456, + "specification_file_url": "/api/v1/files/download/yyy", + "certification_file": 789, + "certification_file_url": "/api/v1/files/download/zzz" +} +``` + +--- + +## 🔄 코드 변경 요약 + +| 파일 | 변경 내용 | +|------|----------| +| `types.ts` | BOMLine에 `childItemType` 추가, DynamicItemFormProps에 `initialBomLines` 추가 | +| `DynamicBOMSection.tsx` | `getChildItemType()` 헬퍼 함수, 품목 선택 시 childItemType 설정 | +| `index.tsx` | BOM 전송 형식 간소화, initialBomLines prop 처리 | +| `edit/page.tsx` | initialBomLines 전달, base64 이미지 제거 로직 | +| `create/page.tsx` | base64 이미지 제거 로직 | +| `items/[id]/page.tsx` | BOM key fallback 처리 | +| `ItemDetailClient.tsx` | 제품 정보 섹션 조건부 표시 | +| `lib/api/items.ts` | bending_details 배열 형태로 전송 | + +--- + +## 📋 다음 세션 TODO + +### 백엔드 API 완료 후 +- [ ] 파일 다운로드 URL 처리 (백엔드 응답 형식에 맞춰 적용) +- [ ] 전개도 이미지 표시 테스트 +- [ ] 시방서/인정서 PDF 다운로드 테스트 + +### DynamicItemForm 분할 작업 🎯 +- [ ] `index.tsx` 파일 분할 (현재 2000줄+ → 500줄 이하로) +- [ ] 섹션별 컴포넌트 분리: + - `DynamicFormHeader.tsx` - 헤더/제목 + - `DynamicFormActions.tsx` - 저장/취소 버튼 + - `DynamicBendingSection.tsx` - 전개도 섹션 (기존) + - `DynamicFileSection.tsx` - 파일 업로드 섹션 + - `useDynamicForm.ts` - 메인 로직 훅 +- [ ] 상태 관리 정리 (props drilling 최소화) + +### 파일 업로드 필드 동적화 🆕 +> 참고: `[DESIGN-2025-12-12] item-master-form-builder-roadmap.md` + +**현재 문제**: +- 파일 업로드가 `FileUpload.tsx`로 하드코딩되어 있음 +- 품목기준관리에서 파일 필드를 동적으로 추가할 수 없음 + +**목표**: +- 새 필드 타입 `file`, `files`, `image` 추가 +- 품목기준관리에서 파일 업로드 필드 동적 생성 가능 + +**구현 작업**: +- [ ] `field_type`에 `file` | `files` | `image` 타입 추가 (API 스키마) +- [ ] `FileField.tsx` 컴포넌트 생성 (DynamicItemForm/fields/) +- [ ] `ImageField.tsx` 컴포넌트 생성 (미리보기 포함) +- [ ] `DynamicFieldRenderer.tsx`에 file/image 케이스 추가 +- [ ] `properties` 확장: `{ accept, maxSize, maxFiles }` +- [ ] 품목기준관리 UI에 파일 필드 타입 옵션 추가 +- [ ] 기존 하드코딩된 FileUpload 컴포넌트 동적 필드로 마이그레이션 + +--- + +## 📚 관련 문서 + +| 문서 | 위치 | +|------|------| +| 이전 세션 컨텍스트 | `[NEXT-2025-12-10] item-crud-session-context.md` | +| DynamicForm 분리 계획 | `[PLAN-2025-12-08] dynamic-form-separation-plan.md` | +| Radix UI 버그 해결 | `claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | \ No newline at end of file diff --git a/claudedocs/item-master/[NEXT-2025-12-13] item-file-upload-session-context.md b/claudedocs/item-master/[NEXT-2025-12-13] item-file-upload-session-context.md new file mode 100644 index 00000000..c3dc636d --- /dev/null +++ b/claudedocs/item-master/[NEXT-2025-12-13] item-file-upload-session-context.md @@ -0,0 +1,96 @@ +# 품목관리 파일 업로드 세션 컨텍스트 + +## 세션 정보 +- **날짜**: 2025-12-13 +- **커밋**: c026130 - feat: 품목관리 파일 업로드 기능 개선 + +## 완료된 작업 + +### 1. 파일 업로드 API 파라미터 추가 +- `src/lib/api/items.ts`의 `uploadItemFile` 함수에 `fieldKey`, `fileId` 파라미터 추가 +- FormData에 `field_key`, `file_id` 필드 append + +### 2. 타입 정의 추가 +- `src/types/item.ts`에 `ItemFile`, `ItemFiles` 인터페이스 추가 +```typescript +export interface ItemFile { + id: number; + file_name: string; + file_path: string; +} + +export interface ItemFiles { + bending_diagram?: ItemFile[]; + specification?: ItemFile[]; + certification?: ItemFile[]; +} +``` + +### 3. DynamicItemForm 파일 데이터 파싱 +- 새 API 구조 (`files` 객체) 지원 +- 기존 API 구조 폴백 유지 (하위 호환) +- 파일 ID 상태 추가: `existingSpecificationFileId`, `existingCertificationFileId`, `existingBendingDiagramFileId` + +### 4. 시방서/인정서 파일 UI 개선 +- 기존: 파일명 표시 + 별도 파일 선택 input +- 변경: 파일 있으면 `[파일명] [⬇️] [✏️] [🗑️]` 버튼 UI +- 파일 없으면 기존 파일 선택 UI 표시 + +## 진행 중 (백엔드 대기) + +### 파일 업로드 500 에러 +- **증상**: POST `/api/proxy/items/{id}/files` → 500 에러 +- **원인**: 백엔드에서 `field_key`, `file_id` 파라미터 처리 미구현 +- **Next.js 프록시 로그**: +``` +📎 File field: file = 230601_test.pdf (70976 bytes) +📎 Form field: type = certification +📎 Form field: field_key = certification_file +📎 Form field: file_id = 0 +🔵 Response status: 500 +``` +- **상태**: 프론트엔드 준비 완료, 백엔드 수정 대기 중 + +## 다음 세션 TODO + +### 1. DynamicItemForm index.tsx 분리 작업 +- 현재 2000줄+ → 500줄 이하 목표 +- 컴포넌트 분리: + - FormHeader + - ValidationAlert + - DynamicSectionRenderer + - 파일 업로드 섹션 + - BOM 섹션 +- hooks/utils 정리 + +### 2. 파일 업로드 테스트 (백엔드 완료 후) +- 신규 품목 등록 → 파일 업로드 → 수정 페이지 확인 +- 다운로드/수정/삭제 버튼 동작 검증 +- 파일 덮어쓰기 (file_id: 0) 동작 확인 + +## API 구조 참고 + +### 새 API 응답 구조 (조회) +```json +{ + "files": { + "bending_diagram": [{ "id": 1, "file_name": "벤딩도.pdf", "file_path": "/uploads/..." }], + "specification": [{ "id": 2, "file_name": "규격서.pdf", "file_path": "/uploads/..." }], + "certification": [{ "id": 3, "file_name": "인정서.pdf", "file_path": "/uploads/..." }] + } +} +``` + +### 파일 업로드 요청 (FormData) +``` +file: [File] +type: specification | certification | bending_diagram +field_key: specification_file | certification_file | bending_diagram +file_id: 0 (덮어쓰기) | 1, 2, 3... (추가) +``` + +## 주요 파일 위치 +- 타입: `src/types/item.ts` +- API: `src/lib/api/items.ts` +- 폼: `src/components/items/DynamicItemForm/index.tsx` +- 프록시: `src/app/api/proxy/[...path]/route.ts` \ No newline at end of file diff --git a/claudedocs/item-master/[PLAN-2025-12-16] dynamicitemform-hook-extraction.md b/claudedocs/item-master/[PLAN-2025-12-16] dynamicitemform-hook-extraction.md new file mode 100644 index 00000000..5b21a94c --- /dev/null +++ b/claudedocs/item-master/[PLAN-2025-12-16] dynamicitemform-hook-extraction.md @@ -0,0 +1,258 @@ +# DynamicItemForm 훅 분리 계획서 + +## 개요 + +| 항목 | 내용 | +|------|------| +| 대상 파일 | `src/components/items/DynamicItemForm/index.tsx` | +| 현재 줄 수 | 2,161줄 | +| 목표 줄 수 | ~900줄 이하 | +| 작업 유형 | 로직 분리 (기능 변경 없음) | +| 예상 리스크 | 낮음 | + +--- + +## Phase 1: 컴포넌트 분리 (~386줄 감소) + +### 1.1 FormHeader 컴포넌트 분리 +- [ ] `components/FormHeader.tsx` 파일 생성 +- [ ] FormHeader 함수 이동 (56-107줄, ~51줄) +- [ ] Props 타입 정의 +- [ ] index.tsx에서 import 및 사용 +- [ ] 빌드 확인 + +### 1.2 ValidationAlert 컴포넌트 분리 +- [ ] `components/ValidationAlert.tsx` 파일 생성 +- [ ] ValidationAlert 함수 이동 (112-141줄, ~30줄) +- [ ] Props 타입 정의 +- [ ] index.tsx에서 import 및 사용 +- [ ] 빌드 확인 + +### 1.3 DynamicSectionRenderer 삭제 +- [ ] 현재 사용 여부 최종 확인 +- [ ] 미사용 확인 시 코드 삭제 (146-227줄, ~82줄) +- [ ] 빌드 확인 + +### 1.4 FileUploadFields 컴포넌트 분리 +- [ ] `components/FileUploadFields.tsx` 파일 생성 +- [ ] 시방서/인정서 업로드 JSX 이동 (1771-1963줄, ~193줄) +- [ ] Props 타입 정의 (파일 상태, 핸들러 등) +- [ ] index.tsx에서 import 및 사용 +- [ ] 빌드 확인 + +### 1.5 DuplicateCodeDialog 컴포넌트 분리 +- [ ] `components/DuplicateCodeDialog.tsx` 파일 생성 +- [ ] AlertDialog JSX 이동 (2137-2158줄, ~30줄) +- [ ] Props 타입 정의 +- [ ] index.tsx에서 import 및 사용 +- [ ] 빌드 확인 + +**Phase 1 완료 후 예상:** ~1,775줄 + +--- + +## Phase 2: 품목코드 생성 훅 분리 (~300줄 감소) + +### 2.1 useItemCodeGeneration 훅 생성 +- [ ] `hooks/useItemCodeGeneration.ts` 파일 생성 +- [ ] 타입 정의 (입력/출력) + +### 2.2 품목코드 관련 useMemo 이동 +- [ ] `hasAutoItemCode`, `itemNameKey`, `allSpecificationKeys`, `statusFieldKey` useMemo 이동 (622-674줄) +- [ ] `activeSpecificationKey` useMemo 이동 (678-708줄) +- [ ] `autoGeneratedItemCode` useMemo 이동 (1234-1254줄) +- [ ] 빌드 확인 + +### 2.3 절곡부품 품목코드 로직 이동 +- [ ] `bendingFieldKeys`, `autoBendingItemCode`, `allCategoryKeysWithIds` useMemo 이동 (837-967줄) +- [ ] 빌드 확인 + +### 2.4 조립부품 품목코드 로직 이동 +- [ ] `hasAssemblyFields`, `assemblyFieldKeys`, `autoAssemblyItemName`, `autoAssemblySpec` useMemo 이동 (1051-1136줄) +- [ ] 빌드 확인 + +### 2.5 구매부품 품목코드 로직 이동 +- [ ] `purchasedFieldKeys`, `autoPurchasedItemCode` useMemo 이동 (1140-1227줄) +- [ ] 빌드 확인 + +### 2.6 index.tsx 연결 +- [ ] useItemCodeGeneration 훅 import +- [ ] 기존 useMemo 코드 제거 +- [ ] 훅 반환값으로 대체 +- [ ] 빌드 확인 +- [ ] 기능 테스트 (품목코드 자동생성 동작 확인) + +**Phase 2 완료 후 예상:** ~1,475줄 + +--- + +## Phase 3: 필드 탐지 훅 분리 (~200줄 감소) + +### 3.1 useFieldDetection 훅 생성 +- [ ] `hooks/useFieldDetection.ts` 파일 생성 +- [ ] 타입 정의 + +### 3.2 부품 유형 필드 탐지 로직 이동 +- [ ] `partTypeFieldKey`, `selectedPartType`, `isBendingPart`, `isAssemblyPart`, `isPurchasedPart` useMemo 이동 (711-759줄) +- [ ] 빌드 확인 + +### 3.3 BOM 체크박스 필드 탐지 로직 이동 +- [ ] `bomRequiredFieldKey` useMemo 이동 (998-1047줄) +- [ ] 빌드 확인 + +### 3.4 index.tsx 연결 +- [ ] useFieldDetection 훅 import +- [ ] 기존 useMemo 코드 제거 +- [ ] 훅 반환값으로 대체 +- [ ] 빌드 확인 +- [ ] 기능 테스트 (조건부 필드 표시 확인) + +**Phase 3 완료 후 예상:** ~1,275줄 + +--- + +## Phase 4: 부품 유형 처리 훅 분리 (~150줄 감소) + +### 4.1 usePartTypeHandling 훅 생성 +- [ ] `hooks/usePartTypeHandling.ts` 파일 생성 +- [ ] 타입 정의 + +### 4.2 부품 유형 변경 useEffect 이동 +- [ ] `prevPartTypeRef` 및 부품 유형 변경 감지 useEffect 이동 (762-833줄) +- [ ] 빌드 확인 + +### 4.3 품목명 변경 시 종류 초기화 useEffect 이동 +- [ ] `prevItemNameValueRef` 및 품목명 변경 감지 useEffect 이동 (972-996줄) +- [ ] 빌드 확인 + +### 4.4 index.tsx 연결 +- [ ] usePartTypeHandling 훅 import +- [ ] 기존 useEffect 코드 제거 +- [ ] 훅 호출로 대체 +- [ ] 빌드 확인 +- [ ] 기능 테스트 (부품 유형 변경 시 필드 초기화 확인) + +**Phase 4 완료 후 예상:** ~1,125줄 + +--- + +## Phase 5: 파일 처리 훅 분리 (~150줄 감소) + +### 5.1 useFileHandling 훅 생성 +- [ ] `hooks/useFileHandling.ts` 파일 생성 +- [ ] 타입 정의 + +### 5.2 파일 상태 및 useEffect 이동 +- [ ] 파일 관련 state 선언 이동 (274-286줄) +- [ ] 파일 정보 로드 useEffect 이동 (294-406줄 중 파일 관련 부분) +- [ ] `getDownloadUrl` 함수 이동 (418-422줄) +- [ ] `handleDeleteFile` 함수 이동 (425-488줄) +- [ ] 빌드 확인 + +### 5.3 index.tsx 연결 +- [ ] useFileHandling 훅 import +- [ ] 기존 코드 제거 +- [ ] 훅 반환값으로 대체 +- [ ] 빌드 확인 +- [ ] 기능 테스트 (파일 업로드/삭제/다운로드 확인) + +**Phase 5 완료 후 예상:** ~975줄 + +--- + +## Phase 6: 최종 정리 및 검증 + +### 6.1 코드 정리 +- [ ] 불필요한 import 제거 +- [ ] 타입 정리 (중복 제거) +- [ ] 주석 정리 + +### 6.2 hooks/index.ts 업데이트 +- [ ] 새로운 훅들 export 추가 + +### 6.3 최종 검증 +- [ ] 빌드 성공 확인 +- [ ] 타입 에러 없음 확인 +- [ ] ESLint 경고 확인 + +### 6.4 기능 테스트 체크리스트 +- [ ] FG(제품) 등록/수정 테스트 +- [ ] PT(부품) - 절곡부품 등록/수정 테스트 +- [ ] PT(부품) - 조립부품 등록/수정 테스트 +- [ ] PT(부품) - 구매부품 등록/수정 테스트 +- [ ] SM(부자재) 등록/수정 테스트 +- [ ] RM(원자재) 등록/수정 테스트 +- [ ] CS(소모품) 등록/수정 테스트 +- [ ] BOM 추가/수정 테스트 +- [ ] 파일 업로드/다운로드/삭제 테스트 +- [ ] 품목코드 자동생성 테스트 +- [ ] 조건부 필드 표시 테스트 + +--- + +## 최종 파일 구조 + +``` +src/components/items/DynamicItemForm/ +├── index.tsx (~900줄, 메인 컴포넌트) +├── components/ +│ ├── FormHeader.tsx (~60줄) +│ ├── ValidationAlert.tsx (~40줄) +│ ├── FileUploadFields.tsx (~200줄) +│ └── DuplicateCodeDialog.tsx (~40줄) +├── hooks/ +│ ├── index.ts (기존 + 새 훅 export) +│ ├── useFormStructure.ts (기존) +│ ├── useDynamicFormState.ts (기존) +│ ├── useConditionalDisplay.ts (기존) +│ ├── useItemCodeGeneration.ts (~300줄, 신규) +│ ├── useFieldDetection.ts (~200줄, 신규) +│ ├── usePartTypeHandling.ts (~150줄, 신규) +│ └── useFileHandling.ts (~150줄, 신규) +├── fields/ (기존) +├── sections/ (기존) +├── types/ (기존) +└── utils/ (기존) +``` + +--- + +## 리스크 및 롤백 계획 + +### 리스크 평가 +| 리스크 | 확률 | 영향 | 대응 | +|--------|------|------|------| +| 타입 에러 | 중 | 낮음 | Phase별 빌드 확인 | +| 의존성 순환 | 낮음 | 중 | import 구조 검토 | +| 런타임 에러 | 낮음 | 높음 | Phase별 기능 테스트 | +| 성능 저하 | 매우 낮음 | 낮음 | 로직 변경 없음 | + +### 롤백 계획 +- 각 Phase는 독립적으로 롤백 가능 +- Git 커밋을 Phase별로 분리 +- 문제 발생 시 해당 Phase만 revert + +--- + +## 작업 시간 예상 + +| Phase | 예상 시간 | 누적 | +|-------|----------|------| +| Phase 1 | 30분 | 30분 | +| Phase 2 | 45분 | 1시간 15분 | +| Phase 3 | 30분 | 1시간 45분 | +| Phase 4 | 30분 | 2시간 15분 | +| Phase 5 | 30분 | 2시간 45분 | +| Phase 6 | 30분 | 3시간 15분 | + +**총 예상 시간: 약 3시간 15분** + +--- + +## 승인 + +- [ ] 계획 검토 완료 +- [ ] 작업 착수 승인 + +**작성일:** 2025-12-16 +**작성자:** Claude Code \ No newline at end of file diff --git a/claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md b/claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md new file mode 100644 index 00000000..ed261489 --- /dev/null +++ b/claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md @@ -0,0 +1,324 @@ +~# 테넌트 데이터 격리 보안 강화 계획서 + +## 개요 + +| 항목 | 내용 | +|------|------| +| **목적** | 테넌트 간 데이터 오염/유출 방지 | +| **배경** | 로그아웃 후 캐시 잔존, 캐시 키 tenant.id 미포함 문제 발견 | +| **범위** | 프론트엔드 캐시 관리, API 프록시 검증 | +| **작성일** | 2025-12-12 | + +--- + +## 우선순위 및 구현 범위 + +| 우선순위 | 항목 | 이유 | 예상 공수 | 상태 | +|:---:|------|------|:---:|:---:| +| 1 (필수) | 로그아웃 시 캐시 완전 정리 | 백엔드가 막을 수 없음 | 1시간 | ✅ 완료 (2025-12-14) | +| 2 (필수) | 캐시 키에 tenant.id 추가 | 백엔드가 막을 수 없음 | 2시간 | ⬜ | +| 3 (권장) | API 프록시 tenant.id 검증 | 이중 방어 (Defense in Depth) | 2시간 | ⬜ | +| 4 (선택) | tenant.id 주기적 검증 | 추가 안전장치 | 1시간 | ⬜ | + +--- + +## 1. 로그아웃 시 캐시 완전 정리 + +### 1.1 현재 문제 + +``` +로그아웃 시: +✅ HttpOnly 쿠키 삭제 (access_token, refresh_token) +❌ sessionStorage 캐시 잔존 (page_config_*) +❌ Zustand 메모리 캐시 잔존 (itemStore, masterDataStore) +❌ localStorage 사용자 데이터 잔존 (mes-currentUser) +``` + +### 1.2 구현 계획 + +#### 파일: `src/lib/auth/logout.ts` (신규 생성) + +```typescript +/** + * 완전한 로그아웃 수행 + * - Zustand 스토어 초기화 + * - sessionStorage 캐시 삭제 + * - localStorage 사용자 데이터 삭제 + * - 서버 로그아웃 API 호출 + */ +export async function performFullLogout(): Promise { + // 1. Zustand 스토어 초기화 + // 2. sessionStorage 우리 앱 캐시만 삭제 + // 3. localStorage 사용자 데이터 삭제 + // 4. 서버 로그아웃 API 호출 + // 5. 로그인 페이지로 리다이렉트 +} +``` + +#### 수정 파일 목록 + +| 파일 | 수정 내용 | +|------|----------| +| `src/lib/auth/logout.ts` | 신규 생성 - 통합 로그아웃 함수 | +| `src/contexts/AuthContext.tsx` | logout 함수에서 performFullLogout 호출 | +| `src/stores/masterDataStore.ts` | reset() 함수가 sessionStorage도 정리하도록 수정 | +| `src/stores/itemStore.ts` | reset() 함수 확인 (메모리만 사용 시 수정 불필요) | +| `src/layouts/AuthenticatedLayout.tsx` | handleLogout에서 AuthContext.logout() 호출 (기존 DashboardLayout.tsx) | + +### 1.3 삭제 대상 캐시 + +| 저장소 | Prefix | 설명 | +|--------|--------|------| +| sessionStorage | `page_config_*` | 페이지 구성 캐시 | +| sessionStorage | `mes-*` | 테넌트 캐시 (TenantAwareCache) | +| localStorage | `mes-currentUser` | 현재 사용자 정보 | +| localStorage | `mes-users` | 사용자 목록 | +| Zustand | itemStore | 품목 메모리 캐시 | +| Zustand | masterDataStore | 페이지 구성 메모리 캐시 | + +### 1.4 주의사항 + +```typescript +// ❌ 잘못된 구현: 전체 삭제 (다른 앱 데이터 삭제 위험) +sessionStorage.clear(); + +// ✅ 올바른 구현: 우리 앱 prefix만 삭제 +const prefixes = ['page_config_', 'mes-']; +Object.keys(sessionStorage).forEach(key => { + if (prefixes.some(p => key.startsWith(p))) { + sessionStorage.removeItem(key); + } +}); +``` + +--- + +## 2. 캐시 키에 tenant.id 추가 + +### 2.1 현재 문제 + +```typescript +// 현재: tenant.id 없음 +const key = `page_config_item-master`; + +// 문제: 다른 tenant 사용자가 같은 브라우저 사용 시 캐시 충돌 +// tenant 100 사용자 → page_config_item-master 저장 +// tenant 200 사용자 → 같은 키에서 tenant 100 데이터 읽음 ⚠️ +``` + +### 2.2 구현 계획 + +#### 변경 전/후 비교 + +| 구분 | 변경 전 | 변경 후 | +|------|---------|---------| +| 캐시 키 형식 | `page_config_item-master` | `page_config_282_item-master` | +| tenant.id 소스 | 없음 | 파라미터로 전달 | + +#### 수정 파일 목록 + +| 파일 | 수정 내용 | +|------|----------| +| `src/stores/masterDataStore.ts` | 캐시 키에 tenantId 포함 | +| `src/components/*/` | fetchPageConfig 호출 시 tenantId 전달 | + +### 2.3 tenant.id 전달 방식 결정 + +#### 옵션 비교 + +| 옵션 | 장점 | 단점 | 권장 | +|------|------|------|:---:| +| A. 파라미터 전달 | 명시적, 추적 용이 | 호출부 전부 수정 필요 | ✅ | +| B. Zustand 상태 추가 | 한 곳에서 관리 | store 간 의존성 발생 | | +| C. TenantAwareCache 활용 | 이미 구현됨 | 생성자에 tenantId 필요 | ✅ | + +#### 권장: 옵션 A + C 조합 + +```typescript +// masterDataStore.ts +import { TenantAwareCache } from '@/lib/cache/TenantAwareCache'; + +fetchPageConfig: async (pageType: PageType, tenantId: number) => { + const cache = new TenantAwareCache(tenantId, sessionStorage, CACHE_TTL); + + // 캐시 조회 (tenant.id 자동 검증) + const cached = cache.get(`page_config_${pageType}`); + if (cached) return cached; + + // API 조회 후 캐시 저장 + const config = await fetchPageConfigByType(pageType); + if (config) { + cache.set(`page_config_${pageType}`, config); + } + return config; +} +``` + +### 2.4 마이그레이션 전략 + +```typescript +// 배포 시 기존 캐시 자동 정리 (일회성) +function migrateOldCache() { + const oldPatterns = [ + 'page_config_item-master', + 'page_config_quotation', + 'page_config_sales-order', + 'page_config_formula', + 'page_config_pricing', + ]; + + oldPatterns.forEach(key => { + if (sessionStorage.getItem(key)) { + sessionStorage.removeItem(key); + console.log(`[Migration] Removed old cache: ${key}`); + } + }); +} +``` + +--- + +## 3. API 프록시 tenant.id 검증 (권장) + +### 3.1 현재 문제 + +```typescript +// src/app/api/proxy/[...path]/route.ts +const backendUrl = `${API_URL}/api/v1/${params.path.join('/')}`; +// → URL에 tenantId가 있어도 검증 없이 백엔드로 전달 +``` + +### 3.2 구현 계획 + +```typescript +// JWT 디코딩 라이브러리 설치 +npm install jwt-decode + +// 프록시에서 검증 추가 +import { jwtDecode } from 'jwt-decode'; + +async function proxyRequest(...) { + const token = request.cookies.get('access_token')?.value; + const decoded = jwtDecode<{ tenant_id: number }>(token); + + // URL에서 tenantId 추출 + const urlTenantMatch = pathStr.match(/tenants\/(\d+)/); + if (urlTenantMatch) { + const urlTenantId = parseInt(urlTenantMatch[1]); + + // 불일치 시 차단 + if (urlTenantId !== decoded.tenant_id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } +} +``` + +### 3.3 수정 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `package.json` | jwt-decode 의존성 추가 | +| `src/app/api/proxy/[...path]/route.ts` | tenant.id 검증 로직 추가 | + +--- + +## 4. tenant.id 주기적 검증 (선택) + +### 4.1 구현 계획 + +```typescript +// src/contexts/AuthContext.tsx +useEffect(() => { + const validateTenant = async () => { + const response = await fetch('/api/auth/check'); + const data = await response.json(); + + if (data.tenant?.id !== currentUser?.tenant?.id) { + console.error('Tenant mismatch! Forcing logout.'); + logout(); + } + }; + + const interval = setInterval(validateTenant, 5 * 60 * 1000); // 5분 + return () => clearInterval(interval); +}, [currentUser?.tenant?.id]); +``` + +--- + +## 체크리스트 + +### Phase 1: 로그아웃 캐시 정리 (필수) ✅ 완료 (2025-12-14) + +- [x] `src/lib/auth/logout.ts` 생성 + - [x] Zustand 스토어 초기화 함수 호출 (`resetZustandStores`) + - [x] sessionStorage prefix 기반 삭제 (`clearSessionStorageCache`) + - [x] localStorage 사용자 데이터 삭제 (`clearLocalStorageCache`) + - [x] 서버 로그아웃 API 호출 (`callLogoutAPI`) + - [x] 리다이렉트 옵션 지원 (`redirectTo` 파라미터) +- [x] `src/stores/masterDataStore.ts` 수정 + - [x] reset() 함수에 sessionStorage 정리 추가 +- [x] `src/contexts/AuthContext.tsx` 수정 + - [x] logout 함수에서 performFullLogout 호출 + - [x] logout 함수 타입 `Promise`로 변경 +- [x] `src/layouts/AuthenticatedLayout.tsx` 수정 (2025-12-14 추가) + - [x] 직접 API 호출 → AuthContext.logout() 호출로 변경 + - [x] 파일명 DashboardLayout.tsx → AuthenticatedLayout.tsx 변경 +- [x] 테스트 ✅ 완료 (2025-12-14) + - [x] 로그아웃 후 sessionStorage 비어있는지 확인 + - [x] 로그아웃 후 Zustand DevTools에서 초기화 확인 + - [x] 다른 계정 로그인 시 이전 데이터 안 보이는지 확인 + +### Phase 2: 캐시 키 tenant.id 추가 (필수) + +- [ ] `src/stores/masterDataStore.ts` 수정 + - [ ] TenantAwareCache import + - [ ] fetchPageConfig 시그니처에 tenantId 추가 + - [ ] 캐시 키 생성 시 tenantId 포함 + - [ ] getConfigFromSessionStorage 함수 수정 + - [ ] setConfigToSessionStorage 함수 수정 +- [ ] 호출부 수정 + - [ ] fetchPageConfig 호출하는 모든 컴포넌트에서 tenantId 전달 +- [ ] 마이그레이션 + - [ ] 기존 형식 캐시 자동 정리 로직 추가 +- [ ] 테스트 + - [ ] sessionStorage에 tenant.id 포함된 키 생성 확인 + - [ ] 다른 tenant 캐시와 격리되는지 확인 + +### Phase 3: API 프록시 검증 (권장) + +- [ ] `npm install jwt-decode` 실행 +- [ ] `src/app/api/proxy/[...path]/route.ts` 수정 + - [ ] JWT 디코딩 함수 추가 + - [ ] URL tenant.id 추출 로직 추가 + - [ ] 불일치 시 403 반환 로직 추가 +- [ ] 테스트 + - [ ] 정상 요청 통과 확인 + - [ ] URL 조작 시 403 반환 확인 + +### Phase 4: 주기적 검증 (선택) + +- [ ] `src/contexts/AuthContext.tsx` 수정 + - [ ] 5분 간격 검증 useEffect 추가 +- [ ] 테스트 + - [ ] localStorage 조작 시 강제 로그아웃 확인 + +--- + +## 예상 부작용 및 대응 + +| 부작용 | 심각도 | 대응 | +|--------|:------:|------| +| 재로그인 시 캐시 miss로 느려짐 | 🟢 낮음 | 수용 (로그아웃은 빈번하지 않음) | +| 기존 캐시 무효화 | 🟢 낮음 | 마이그레이션 로직으로 자동 정리 | +| 호출부 수정 필요 | 🟡 중간 | 점진적 적용 가능 | + +--- + +## 완료 기준 + +1. ✅ 로그아웃 후 sessionStorage, Zustand 캐시 완전 삭제 +2. ✅ 다른 tenant 사용자 로그인 시 이전 데이터 노출 없음 +3. ✅ 캐시 키에 tenant.id 포함되어 격리됨 +4. ✅ (권장) API 프록시에서 tenant.id 불일치 시 차단 \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx index c43be0ad..68f84f63 100644 --- a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx @@ -79,6 +79,7 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { 'id', 'tenant_id', 'category_id', 'category', 'created_at', 'updated_at', 'deleted_at', 'component_lines', 'bom', + 'details', // details는 아래에서 펼쳐서 추가 ]; // 백엔드 응답의 모든 필드를 그대로 복사 @@ -88,6 +89,18 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { } }); + // details 객체가 있으면 펼쳐서 추가 (item_details 테이블 필드) + // 2025-12-16: details 내의 최신 값을 최상위로 매핑 + const details = (data as Record).details as Record | undefined; + if (details && typeof details === 'object') { + const detailExcludeKeys = ['id', 'item_id', 'created_at', 'updated_at']; + Object.entries(details).forEach(([key, value]) => { + if (!detailExcludeKeys.includes(key) && value !== null && value !== undefined) { + formData[key] = value as DynamicFormData[string]; + } + }); + } + // attributes 객체가 있으면 펼쳐서 추가 (조립부품 등의 동적 필드) const attributes = (data.attributes || {}) as Record; Object.entries(attributes).forEach(([key, value]) => { @@ -102,9 +115,14 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { // Material(SM, RM, CS) options 필드 매핑 // 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨 // 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용 + // 2025-12-16: item_details 테이블 필드는 제외 (details에서 이미 매핑됨, options의 오래된 값이 덮어쓰는 버그 방지) + const detailsFieldsInOptions = [ + 'files', 'bending_details', 'bending_diagram', + 'specification_file', 'certification_file', + ]; if (data.options && Array.isArray(data.options)) { (data.options as Array<{ label: string; value: string }>).forEach((opt) => { - if (opt.label && opt.value) { + if (opt.label && opt.value && !detailsFieldsInOptions.includes(opt.label)) { formData[opt.label] = opt.value; } }); @@ -158,17 +176,16 @@ export default function EditItemPage() { return; } - // 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달 + // 2025-12-15: 백엔드에서 id만으로 조회 가능 (item_type 불필요) const isMaterial = isMaterialType(urlItemType); const queryParams = new URLSearchParams(); - if (isMaterial) { - queryParams.append('item_type', urlItemType); // SM, RM, CS 그대로 전달 - } else { + if (!isMaterial) { queryParams.append('include_bom', 'true'); } console.log('[EditItem] Fetching:', { urlItemId, urlItemType, isMaterial }); - response = await fetch(`/api/proxy/items/${urlItemId}?${queryParams.toString()}`); + const queryString = queryParams.toString(); + response = await fetch(`/api/proxy/items/${urlItemId}${queryString ? `?${queryString}` : ''}`); if (!response.ok) { if (response.status === 404) { @@ -191,6 +208,7 @@ export default function EditItemPage() { console.log('specification:', apiData.specification); console.log('unit:', apiData.unit); console.log('is_active:', apiData.is_active); + console.log('files:', (apiData as any).files); // 파일 데이터 확인 console.log('전체:', apiData); console.log('=============================================================='); @@ -207,30 +225,43 @@ export default function EditItemPage() { console.log('specification:', formData['specification']); console.log('unit:', formData['unit']); console.log('is_active:', formData['is_active']); + console.log('files:', formData['files']); // 파일 데이터 확인 console.log('전체:', formData); console.log('=========================================================='); setInitialData(formData); - // BOM 데이터 별도 처리 (백엔드 expandBomData 응답 형식) - const bomData = apiData.bom as Array> | undefined; - if (bomData && Array.isArray(bomData) && bomData.length > 0) { - const mappedBomLines: BOMLine[] = bomData.map((b, index) => ({ - id: (b.id as string) || `bom-${Date.now()}-${index}`, - childItemId: b.child_item_id ? String(b.child_item_id) : undefined, - childItemType: (b.child_item_type as 'PRODUCT' | 'MATERIAL') || 'PRODUCT', - childItemCode: (b.child_item_code as string) || '', - childItemName: (b.child_item_name as string) || '', - specification: (b.specification as string) || '', - material: (b.material as string) || '', - quantity: (b.quantity as number) ?? 1, - unit: (b.unit as string) || 'EA', - unitPrice: (b.unit_price as number) ?? 0, - note: (b.note as string) || '', - isBending: (b.is_bending as boolean) ?? false, - bendingDiagram: (b.bending_diagram as string) || undefined, - })); - setInitialBomLines(mappedBomLines); - console.log('[EditItem] BOM 데이터 로드:', mappedBomLines.length, '건', mappedBomLines); + // BOM 데이터 별도 API 호출 (expandBomItems로 품목 정보 포함) + // GET /api/proxy/items/{id}/bom - 품목 정보가 확장된 BOM 데이터 반환 + if (!isMaterialType(urlItemType)) { + try { + const bomResponse = await fetch(`/api/proxy/items/${urlItemId}/bom`); + const bomResult = await bomResponse.json(); + + if (bomResult.success && bomResult.data && Array.isArray(bomResult.data)) { + const expandedBomData = bomResult.data as Array>; + + const mappedBomLines: BOMLine[] = expandedBomData.map((b, index) => ({ + id: (b.id as string) || `bom-${Date.now()}-${index}`, + childItemId: b.child_item_id ? String(b.child_item_id) : undefined, + childItemType: (b.child_item_type as 'PRODUCT' | 'MATERIAL') || 'PRODUCT', + childItemCode: (b.child_item_code as string) || '', + childItemName: (b.child_item_name as string) || '', + specification: (b.specification as string) || '', + material: (b.material as string) || '', + quantity: (b.quantity as number) ?? 1, + unit: (b.unit as string) || 'EA', + unitPrice: (b.unit_price as number) ?? 0, + note: (b.note as string) || '', + isBending: (b.is_bending as boolean) ?? false, + bendingDiagram: (b.bending_diagram as string) || undefined, + })); + + setInitialBomLines(mappedBomLines); + console.log('[EditItem] BOM 데이터 로드 (expanded):', mappedBomLines.length, '건', mappedBomLines); + } + } catch (bomErr) { + console.error('[EditItem] BOM 조회 실패:', bomErr); + } } } else { setError(result.message || '품목 정보를 불러올 수 없습니다.'); @@ -271,10 +302,11 @@ export default function EditItemPage() { // console.log('itemType:', itemType, 'isMaterial:', isMaterial); // console.log('data:', JSON.stringify(data, null, 2)); // console.log('===================================================='); - const updateUrl = isMaterial - ? `/api/proxy/products/materials/${itemId}` - : `/api/proxy/items/${itemId}`; - const method = isMaterial ? 'PATCH' : 'PUT'; + + // 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용 + // /products/materials 라우트 삭제됨 (products/materials 테이블 삭제) + const updateUrl = `/api/proxy/items/${itemId}?item_type=${itemType}`; + const method = 'PUT'; // console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')'); @@ -282,7 +314,8 @@ export default function EditItemPage() { // - FG(제품): 품목코드 = 품목명 // - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙) // - Material(SM, RM, CS): material_code = 품목명-규격 - let submitData = { ...data }; + // 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation) + let submitData = { ...data, item_type: itemType }; if (itemType === 'FG') { // FG는 품목명이 품목코드가 되므로 name 값으로 code 설정 diff --git a/src/app/[locale]/(protected)/items/[id]/page.tsx b/src/app/[locale]/(protected)/items/[id]/page.tsx index 5350d05f..94b0fdcf 100644 --- a/src/app/[locale]/(protected)/items/[id]/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/page.tsx @@ -22,6 +22,12 @@ const MATERIAL_TYPES = ['SM', 'RM', 'CS']; function mapApiResponseToItemMaster(data: Record): ItemMaster { // attributes 객체 추출 (조립부품 등의 동적 필드가 여기에 저장됨) const attributes = (data.attributes || {}) as Record; + // details 객체 추출 (PT 부품의 상세 정보가 여기에 저장됨) + const details = (data.details || {}) as Record; + + console.log('[mapApiResponseToItemMaster] data.details:', data.details); + console.log('[mapApiResponseToItemMaster] details.part_type:', details.part_type); + console.log('[mapApiResponseToItemMaster] details.bending_details:', details.bending_details); return { id: String(data.id || ''), @@ -53,9 +59,9 @@ function mapApiResponseToItemMaster(data: Record): ItemMaster { isFinal: Boolean(data.is_final ?? false), createdAt: String(data.created_at || data.createdAt || ''), updatedAt: data.updated_at ? String(data.updated_at) : undefined, - // 부품 관련 - data와 attributes 둘 다에서 찾음 - partType: (data.part_type || attributes.part_type) ? ((data.part_type || attributes.part_type) as PartType) : undefined, - partUsage: (data.part_usage || attributes.part_usage) ? ((data.part_usage || attributes.part_usage) as PartUsage) : undefined, + // 부품 관련 - details, data, attributes 순으로 찾음 + partType: (details.part_type || data.part_type || attributes.part_type) ? ((details.part_type || data.part_type || attributes.part_type) as PartType) : undefined, + partUsage: (details.part_usage || data.part_usage || attributes.part_usage) ? ((details.part_usage || data.part_usage || attributes.part_usage) as PartUsage) : undefined, installationType: (data.installation_type || attributes.installation_type) ? String(data.installation_type || attributes.installation_type) : undefined, assemblyType: (data.assembly_type || attributes.assembly_type) ? String(data.assembly_type || attributes.assembly_type) : undefined, assemblyLength: (data.assembly_length || attributes.assembly_length || attributes.length) ? String(data.assembly_length || attributes.assembly_length || attributes.length) : undefined, @@ -77,13 +83,60 @@ function mapApiResponseToItemMaster(data: Record): ItemMaster { isBending: Boolean(bomItem.is_bending ?? false), })) : undefined, // 파일 관련 필드 (PT - 절곡/조립 부품) - bendingDiagram: data.bending_diagram ? String(data.bending_diagram) : undefined, - bendingDetails: Array.isArray(data.bending_details) ? data.bending_details : undefined, - // 파일 관련 필드 (FG - 제품) - specificationFile: data.specification_file ? String(data.specification_file) : undefined, - specificationFileName: data.specification_file_name ? String(data.specification_file_name) : undefined, - certificationFile: data.certification_file ? String(data.certification_file) : undefined, - certificationFileName: data.certification_file_name ? String(data.certification_file_name) : undefined, + // bending_diagram: data 또는 attributes에서 찾음 + bendingDiagram: (() => { + const diagram = data.bending_diagram || attributes.bending_diagram; + return diagram ? String(diagram) : undefined; + })(), + // bending_diagram 파일 ID (프록시 이미지 로드용) + bendingDiagramFileId: (() => { + const files = data.files as { bending_diagram?: Array<{ id: number }> } | undefined; + const arr = files?.bending_diagram; + if (arr && arr.length > 0) return arr[arr.length - 1].id; + return undefined; + })(), + // bending_details: details.bending_details에서 찾음 (API 응답 구조) + bendingDetails: (() => { + const bendingDetails = details.bending_details || data.bending_details || attributes.bending_details; + return Array.isArray(bendingDetails) ? bendingDetails : undefined; + })(), + // 파일 관련 필드 (FG - 제품) - 배열의 마지막 파일 = 최신 파일 + specificationFile: (() => { + const files = data.files as { specification_file?: Array<{ file_path: string }> } | undefined; + const arr = files?.specification_file; + if (arr && arr.length > 0) return arr[arr.length - 1].file_path; + return undefined; + })(), + specificationFileName: (() => { + const files = data.files as { specification_file?: Array<{ file_name: string }> } | undefined; + const arr = files?.specification_file; + if (arr && arr.length > 0) return arr[arr.length - 1].file_name; + return undefined; + })(), + specificationFileId: (() => { + const files = data.files as { specification_file?: Array<{ id: number }> } | undefined; + const arr = files?.specification_file; + if (arr && arr.length > 0) return arr[arr.length - 1].id; + return undefined; + })(), + certificationFile: (() => { + const files = data.files as { certification_file?: Array<{ file_path: string }> } | undefined; + const arr = files?.certification_file; + if (arr && arr.length > 0) return arr[arr.length - 1].file_path; + return undefined; + })(), + certificationFileName: (() => { + const files = data.files as { certification_file?: Array<{ file_name: string }> } | undefined; + const arr = files?.certification_file; + if (arr && arr.length > 0) return arr[arr.length - 1].file_name; + return undefined; + })(), + certificationFileId: (() => { + const files = data.files as { certification_file?: Array<{ id: number }> } | undefined; + const arr = files?.certification_file; + if (arr && arr.length > 0) return arr[arr.length - 1].id; + return undefined; + })(), certificationNumber: data.certification_number ? String(data.certification_number) : undefined, certificationStartDate: data.certification_start_date ? String(data.certification_start_date) : undefined, certificationEndDate: data.certification_end_date ? String(data.certification_end_date) : undefined, @@ -127,17 +180,16 @@ export default function ItemDetailPage() { return; } - // 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달 + // 2025-12-15: 백엔드에서 id만으로 조회 가능 (item_type 불필요) const isMaterial = MATERIAL_TYPES.includes(itemType); const queryParams = new URLSearchParams(); - if (isMaterial) { - queryParams.append('item_type', itemType); // SM, RM, CS 그대로 전달 - } else { + if (!isMaterial) { queryParams.append('include_bom', 'true'); } console.log('[ItemDetail] Fetching:', { itemId, itemType, isMaterial }); - response = await fetch(`/api/proxy/items/${itemId}?${queryParams.toString()}`); + const queryString = queryParams.toString(); + response = await fetch(`/api/proxy/items/${itemId}${queryString ? `?${queryString}` : ''}`); if (!response.ok) { if (response.status === 404) { @@ -154,7 +206,38 @@ export default function ItemDetailPage() { console.log('[ItemDetail] API Response:', result); if (result.success && result.data) { - const mappedItem = mapApiResponseToItemMaster(result.data); + let mappedItem = mapApiResponseToItemMaster(result.data); + + // BOM 데이터 별도 API 호출 (expandBomItems로 품목 정보 포함) + // GET /api/proxy/items/{id}/bom - 품목 정보가 확장된 BOM 데이터 반환 + if (!isMaterial) { + try { + const bomResponse = await fetch(`/api/proxy/items/${itemId}/bom`); + const bomResult = await bomResponse.json(); + + if (bomResult.success && bomResult.data && Array.isArray(bomResult.data)) { + const expandedBomData = bomResult.data as Array>; + + mappedItem = { + ...mappedItem, + bom: expandedBomData.map((bomItem, index) => ({ + id: String(bomItem.id || bomItem.child_item_id || `bom-${index}`), + childItemCode: String(bomItem.child_item_code || ''), + childItemName: String(bomItem.child_item_name || ''), + quantity: Number(bomItem.quantity || 1), + unit: String(bomItem.unit || 'EA'), + unitPrice: bomItem.unit_price ? Number(bomItem.unit_price) : undefined, + quantityFormula: bomItem.quantity_formula ? String(bomItem.quantity_formula) : undefined, + isBending: Boolean(bomItem.is_bending ?? false), + })), + }; + console.log('[ItemDetail] BOM 데이터 로드 (expanded):', mappedItem.bom?.length, '건'); + } + } catch (bomErr) { + console.error('[ItemDetail] BOM 조회 실패:', bomErr); + } + } + setItem(mappedItem); } else { setError(result.message || '품목 정보를 불러올 수 없습니다.'); diff --git a/src/app/[locale]/(protected)/items/create/page.tsx b/src/app/[locale]/(protected)/items/create/page.tsx index fe9d93e5..8cb71899 100644 --- a/src/app/[locale]/(protected)/items/create/page.tsx +++ b/src/app/[locale]/(protected)/items/create/page.tsx @@ -29,8 +29,10 @@ export default function CreateItemPage() { delete submitData.spec; } - // Material(SM, RM, CS)인 경우 수정 페이지와 동일하게 transformMaterialDataForSave 사용 + // 2025-12-15: item_type은 Request Body에서 필수 (ItemService.store validation) + // product_type과 item_type을 동일하게 설정 const itemType = submitData.product_type as string; + submitData.item_type = itemType; // API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용) // bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨) diff --git a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx index 3de688e9..749f3544 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx @@ -21,12 +21,11 @@ import { cookies } from 'next/headers'; // 품목 API 응답 타입 (GET /api/v1/items) interface ItemApiData { id: number; - item_type: 'PRODUCT' | 'MATERIAL'; + item_type: string; // FG, PT, SM, RM, CS (품목 유형) code: string; name: string; unit: string; category_id: number | null; - type_code: string; // FG, PT, SM, RM, CS created_at: string; deleted_at: string | null; } @@ -151,7 +150,7 @@ async function getItemsList(): Promise { const headers = await getApiHeaders(); const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?size=100`, + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`, { method: 'GET', headers, @@ -165,7 +164,6 @@ async function getItemsList(): Promise { } const result: ItemsApiResponse = await response.json(); - console.log('[PricingPage] Items API Response count:', result.data?.data?.length || 0); if (!result.success || !result.data?.data) { console.warn('[PricingPage] No items data in response'); @@ -252,7 +250,7 @@ function mergeItemsWithPricing( itemId: String(item.id), itemCode: item.code, itemName: item.name, - itemType: mapItemType(item.type_code), + itemType: mapItemType(item.item_type), specification: undefined, // items API에서는 specification 미제공 unit: item.unit || 'EA', purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined, @@ -272,7 +270,7 @@ function mergeItemsWithPricing( itemId: String(item.id), itemCode: item.code, itemName: item.name, - itemType: mapItemType(item.type_code), + itemType: mapItemType(item.item_type), specification: undefined, unit: item.unit || 'EA', purchasePrice: undefined, diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index 072ab0dd..049e2369 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -249,16 +249,46 @@ async function proxyRequest( } // 6. 응답 데이터 읽기 - const responseData = await backendResponse.text(); console.log('🔵 [PROXY] Response status:', backendResponse.status); + const responseContentType = backendResponse.headers.get('content-type') || 'application/json'; - // 7. 클라이언트로 응답 전달 - const clientResponse = new NextResponse(responseData, { - status: backendResponse.status, - headers: { - 'Content-Type': backendResponse.headers.get('content-type') || 'application/json', - }, - }); + // 7. 바이너리 파일 vs 텍스트/JSON 구분 + // 파일 다운로드 (PDF, 이미지, 등)는 바이너리로 처리해야 손상되지 않음 + const isBinaryResponse = + responseContentType.includes('application/pdf') || + responseContentType.includes('application/octet-stream') || + responseContentType.includes('image/') || + responseContentType.includes('application/zip') || + responseContentType.includes('application/vnd') || + responseContentType.includes('application/msword') || + responseContentType.includes('application/x-'); + + let clientResponse: NextResponse; + + if (isBinaryResponse) { + // 바이너리 파일: arrayBuffer로 읽어서 그대로 전달 + console.log('📄 [PROXY] Binary response detected:', responseContentType); + const binaryData = await backendResponse.arrayBuffer(); + + clientResponse = new NextResponse(binaryData, { + status: backendResponse.status, + headers: { + 'Content-Type': responseContentType, + 'Content-Disposition': backendResponse.headers.get('content-disposition') || '', + 'Content-Length': backendResponse.headers.get('content-length') || '', + }, + }); + } else { + // JSON/텍스트: text로 읽어서 전달 + const responseData = await backendResponse.text(); + + clientResponse = new NextResponse(responseData, { + status: backendResponse.status, + headers: { + 'Content-Type': responseContentType, + }, + }); + } // 8. 토큰이 갱신되었으면 새 쿠키 설정 if (newTokens && newTokens.accessToken) { diff --git a/src/components/items/DynamicItemForm/index.tsx b/src/components/items/DynamicItemForm/index.tsx index a57b74c0..90bf96e3 100644 --- a/src/components/items/DynamicItemForm/index.tsx +++ b/src/components/items/DynamicItemForm/index.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { useRouter } from 'next/navigation'; -import { Package, Save, X, FileText, Trash2, Download, Pencil } from 'lucide-react'; +import { Package, Save, X, FileText, Trash2, Download, Pencil, Upload } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -38,6 +38,7 @@ import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFiel import type { ItemType, BendingDetail } from '@/types/item'; import type { ItemFieldResponse } from '@/types/item-master-api'; import { uploadItemFile, deleteItemFile, ItemFileType, checkItemCodeDuplicate, DuplicateCheckResult } from '@/lib/api/items'; +import { downloadFileById } from '@/lib/utils/fileDownload'; import { DuplicateCodeError } from '@/lib/api/error-handler'; import { AlertDialog, @@ -293,43 +294,96 @@ export default function DynamicItemForm({ // initialData에서 기존 파일 정보 및 전개도 상세 데이터 로드 (edit 모드) useEffect(() => { if (mode === 'edit' && initialData) { - // 새 API 구조: files 객체에서 파일 정보 추출 + // files 객체에서 파일 정보 추출 (단수: specification_file, certification_file) + // 2025-12-15: files가 JSON 문자열로 올 수 있으므로 파싱 처리 // eslint-disable-next-line @typescript-eslint/no-explicit-any - const files = (initialData as any).files as { + let filesRaw = (initialData as any).files; + + // JSON 문자열인 경우 파싱 + if (typeof filesRaw === 'string') { + try { + filesRaw = JSON.parse(filesRaw); + console.log('[DynamicItemForm] files JSON 문자열 파싱 완료'); + } catch (e) { + console.error('[DynamicItemForm] files JSON 파싱 실패:', e); + filesRaw = undefined; + } + } + + const files = filesRaw as { bending_diagram?: Array<{ id: number; file_name: string; file_path: string }>; - specification?: Array<{ id: number; file_name: string; file_path: string }>; - certification?: Array<{ id: number; file_name: string; file_path: string }>; + specification_file?: Array<{ id: number; file_name: string; file_path: string }>; + certification_file?: Array<{ id: number; file_name: string; file_path: string }>; } | undefined; - // 전개도 파일 (새 API 구조 우선, 기존 구조 폴백) - if (files?.bending_diagram?.[0]) { - const bendingFile = files.bending_diagram[0]; + // 2025-12-15: 파일 로드 디버깅 + console.log('[DynamicItemForm] 파일 로드 시작'); + console.log('[DynamicItemForm] initialData.files (raw):', (initialData as any).files); + console.log('[DynamicItemForm] filesRaw 타입:', typeof filesRaw); + console.log('[DynamicItemForm] files 변수:', files); + console.log('[DynamicItemForm] specification_file:', files?.specification_file); + console.log('[DynamicItemForm] certification_file:', files?.certification_file); + + // 전개도 파일 (배열의 마지막 파일 = 최신 파일을 가져옴) + // 2025-12-15: .at(-1) 대신 slice(-1)[0] 사용 (ES2022 이전 호환성) + const bendingFileArr = files?.bending_diagram; + const bendingFile = bendingFileArr && bendingFileArr.length > 0 + ? bendingFileArr[bendingFileArr.length - 1] + : undefined; + if (bendingFile) { + console.log('[DynamicItemForm] bendingFile 전체 객체:', bendingFile); + console.log('[DynamicItemForm] bendingFile 키 목록:', Object.keys(bendingFile)); setExistingBendingDiagram(bendingFile.file_path); - setExistingBendingDiagramFileId(bendingFile.id); + // API에서 id 또는 file_id로 올 수 있음 + const bendingFileId = (bendingFile as Record).id || (bendingFile as Record).file_id; + console.log('[DynamicItemForm] bendingFile ID 추출:', { id: (bendingFile as Record).id, file_id: (bendingFile as Record).file_id, final: bendingFileId }); + setExistingBendingDiagramFileId(bendingFileId as number); } else if (initialData.bending_diagram) { setExistingBendingDiagram(initialData.bending_diagram as string); } - // 시방서 파일 (새 API 구조 우선, 기존 구조 폴백) - if (files?.specification?.[0]) { - const specFile = files.specification[0]; + // 시방서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴) + // 2025-12-15: .at(-1) 대신 배열 인덱스 사용 (ES2022 이전 호환성) + const specFileArr = files?.specification_file; + const specFile = specFileArr && specFileArr.length > 0 + ? specFileArr[specFileArr.length - 1] + : undefined; + console.log('[DynamicItemForm] specFile 전체 객체:', specFile); + console.log('[DynamicItemForm] specFile 키 목록:', specFile ? Object.keys(specFile) : 'undefined'); + if (specFile?.file_path) { setExistingSpecificationFile(specFile.file_path); - setExistingSpecificationFileName(specFile.file_name); - setExistingSpecificationFileId(specFile.id); - } else if (initialData.specification_file) { - setExistingSpecificationFile(initialData.specification_file as string); - setExistingSpecificationFileName((initialData.specification_file_name as string) || '시방서'); + setExistingSpecificationFileName(specFile.file_name || '시방서'); + // API에서 id 또는 file_id로 올 수 있음 + const specFileId = (specFile as Record).id || (specFile as Record).file_id; + console.log('[DynamicItemForm] specFile ID 추출:', { id: (specFile as Record).id, file_id: (specFile as Record).file_id, final: specFileId }); + setExistingSpecificationFileId(specFileId as number || null); + } else { + // 파일이 없으면 상태 초기화 (이전 값 제거) + setExistingSpecificationFile(''); + setExistingSpecificationFileName(''); + setExistingSpecificationFileId(null); } - // 인정서 파일 (새 API 구조 우선, 기존 구조 폴백) - if (files?.certification?.[0]) { - const certFile = files.certification[0]; + // 인정서 파일 (배열의 마지막 파일 = 최신 파일을 가져옴) + // 2025-12-15: .at(-1) 대신 배열 인덱스 사용 (ES2022 이전 호환성) + const certFileArr = files?.certification_file; + const certFile = certFileArr && certFileArr.length > 0 + ? certFileArr[certFileArr.length - 1] + : undefined; + console.log('[DynamicItemForm] certFile 전체 객체:', certFile); + console.log('[DynamicItemForm] certFile 키 목록:', certFile ? Object.keys(certFile) : 'undefined'); + if (certFile?.file_path) { setExistingCertificationFile(certFile.file_path); - setExistingCertificationFileName(certFile.file_name); - setExistingCertificationFileId(certFile.id); - } else if (initialData.certification_file) { - setExistingCertificationFile(initialData.certification_file as string); - setExistingCertificationFileName((initialData.certification_file_name as string) || '인정서'); + setExistingCertificationFileName(certFile.file_name || '인정서'); + // API에서 id 또는 file_id로 올 수 있음 + const certFileId = (certFile as Record).id || (certFile as Record).file_id; + console.log('[DynamicItemForm] certFile ID 추출:', { id: (certFile as Record).id, file_id: (certFile as Record).file_id, final: certFileId }); + setExistingCertificationFileId(certFileId as number || null); + } else { + // 파일이 없으면 상태 초기화 (이전 값 제거) + setExistingCertificationFile(''); + setExistingCertificationFileName(''); + setExistingCertificationFileId(null); } // 전개도 상세 데이터 로드 (bending_details) @@ -342,15 +396,18 @@ export default function DynamicItemForm({ if (details.length > 0) { // BendingDetail 형식으로 변환 + // 2025-12-16: 명시적 Number() 변환 추가 - TypeScript 타입 캐스팅은 런타임 변환을 하지 않음 + // 백엔드에서 문자열로 올 수 있으므로 명시적 숫자 변환 필수 const mappedDetails: BendingDetail[] = details.map((d: Record, index: number) => ({ id: (d.id as string) || `detail-${Date.now()}-${index}`, - no: (d.no as number) || index + 1, - input: (d.input as number) ?? 0, - elongation: (d.elongation as number) ?? -1, - calculated: (d.calculated as number) ?? 0, - sum: (d.sum as number) ?? 0, - shaded: (d.shaded as boolean) ?? false, - aAngle: d.aAngle as number | undefined, + no: Number(d.no) || index + 1, + input: Number(d.input) || 0, + // elongation은 0이 유효한 값이므로 NaN 체크 필요 + elongation: !isNaN(Number(d.elongation)) ? Number(d.elongation) : -1, + calculated: Number(d.calculated) || 0, + sum: Number(d.sum) || 0, + shaded: Boolean(d.shaded), + aAngle: d.aAngle !== undefined ? Number(d.aAngle) : undefined, })); setBendingDetails(mappedDetails); @@ -373,19 +430,49 @@ export default function DynamicItemForm({ } }, [mode, initialBomLines]); - // Storage 경로를 전체 URL로 변환 - const getStorageUrl = (path: string | undefined): string | null => { - if (!path) return null; - if (path.startsWith('http://') || path.startsWith('https://')) { - return path; + // 파일 다운로드 핸들러 (Blob 방식) + const handleFileDownload = async (fileId: number | null, fileName?: string) => { + if (!fileId) return; + try { + await downloadFileById(fileId, fileName); + } catch (error) { + console.error('[DynamicItemForm] 다운로드 실패:', error); + alert('파일 다운로드에 실패했습니다.'); } - const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - return `${apiUrl}/storage/${path}`; }; // 파일 삭제 핸들러 const handleDeleteFile = async (fileType: ItemFileType) => { - if (!propItemId) return; + console.log('[DynamicItemForm] handleDeleteFile 호출:', { + fileType, + propItemId, + existingBendingDiagramFileId, + existingSpecificationFileId, + existingCertificationFileId, + }); + + if (!propItemId) { + console.error('[DynamicItemForm] propItemId가 없습니다'); + return; + } + + // 파일 ID 가져오기 + let fileId: number | null = null; + if (fileType === 'bending_diagram') { + fileId = existingBendingDiagramFileId; + } else if (fileType === 'specification') { + fileId = existingSpecificationFileId; + } else if (fileType === 'certification') { + fileId = existingCertificationFileId; + } + + console.log('[DynamicItemForm] 삭제할 파일 ID:', fileId); + + if (!fileId) { + console.error('[DynamicItemForm] 파일 ID를 찾을 수 없습니다:', fileType); + alert('파일 ID를 찾을 수 없습니다.'); + return; + } const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' : fileType === 'specification' ? '시방서 파일을' : '인정서 파일을'; @@ -394,18 +481,21 @@ export default function DynamicItemForm({ try { setIsDeletingFile(fileType); - await deleteItemFile(propItemId, fileType); + await deleteItemFile(propItemId, fileId, selectedItemType || 'FG'); // 상태 업데이트 if (fileType === 'bending_diagram') { setExistingBendingDiagram(''); setBendingDiagram(''); + setExistingBendingDiagramFileId(null); } else if (fileType === 'specification') { setExistingSpecificationFile(''); setExistingSpecificationFileName(''); + setExistingSpecificationFileId(null); } else if (fileType === 'certification') { setExistingCertificationFile(''); setExistingCertificationFileName(''); + setExistingCertificationFileId(null); } alert('파일이 삭제되었습니다.'); @@ -896,6 +986,35 @@ export default function DynamicItemForm({ }; }, [structure, selectedItemType, isBendingPart, formData, itemNameKey]); + // 2025-12-16: bendingDetails 로드 후 폭 합계를 formData에 동기화 + // bendingFieldKeys.widthSum이 결정된 후에 실행되어야 함 + const bendingWidthSumSyncedRef = useRef(false); + useEffect(() => { + // edit 모드이고, bendingDetails가 있고, widthSum 필드 키가 결정되었을 때만 실행 + if (mode !== 'edit' || bendingDetails.length === 0 || !bendingFieldKeys.widthSum) { + return; + } + + // 이미 동기화했으면 스킵 (중복 실행 방지) + if (bendingWidthSumSyncedRef.current) { + return; + } + + const totalSum = bendingDetails.reduce((acc, detail) => { + return acc + detail.input + detail.elongation; + }, 0); + + const sumString = totalSum.toString(); + console.log('[DynamicItemForm] bendingDetails 폭 합계 → formData 동기화:', { + widthSumKey: bendingFieldKeys.widthSum, + totalSum, + bendingDetailsCount: bendingDetails.length, + }); + + setFieldValue(bendingFieldKeys.widthSum, sumString); + bendingWidthSumSyncedRef.current = true; + }, [mode, bendingDetails, bendingFieldKeys.widthSum, setFieldValue]); + // 2025-12-04: 품목명 변경 시 종류 필드 값 초기화 // 품목명(A)→종류(A1) 선택 후 품목명(B)로 변경 시, 이전 종류(A1) 값이 남아있어서 // 새 종류(B1) 선택해도 이전 값이 품목코드에 적용되는 버그 수정 @@ -1206,7 +1325,8 @@ export default function DynamicItemForm({ console.log('[DynamicItemForm] 전개도 파일 업로드 시작:', bendingDiagramFile.name); await uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram', { fieldKey: 'bending_diagram', - fileId: 0, + // 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록 + fileId: existingBendingDiagramFileId ?? undefined, bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({ angle: d.aAngle || 0, length: d.input || 0, @@ -1226,7 +1346,8 @@ export default function DynamicItemForm({ console.log('[DynamicItemForm] 시방서 파일 업로드 시작:', specificationFile.name); await uploadItemFile(itemId, specificationFile, 'specification', { fieldKey: 'specification_file', - fileId: 0, + // 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록 + fileId: existingSpecificationFileId ?? undefined, }); console.log('[DynamicItemForm] 시방서 파일 업로드 성공'); } catch (error) { @@ -1252,7 +1373,8 @@ export default function DynamicItemForm({ await uploadItemFile(itemId, certificationFile, 'certification', { fieldKey: 'certification_file', - fileId: 0, + // 수정 모드: 기존 파일 ID가 있으면 덮어쓰기, 없으면 새 파일 등록 + fileId: existingCertificationFileId ?? undefined, certificationNumber: certNumber, certificationStartDate: certStartDate, certificationEndDate: certEndDate, @@ -1708,15 +1830,14 @@ export default function DynamicItemForm({ {existingSpecificationFileName} - handleFileDownload(existingSpecificationFileId, existingSpecificationFileName)} className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-blue-600" title="다운로드" > - +