feat: 품목관리 기능 개선 및 문서화 업데이트
- 품목 상세/수정 페이지 파일 다운로드 기능 개선 - 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 <noreply@anthropic.com>
This commit is contained in:
958
claudedocs/[SECURITY-2025-12-12] tenant-data-isolation-audit.md
Normal file
958
claudedocs/[SECURITY-2025-12-12] tenant-data-isolation-audit.md
Normal file
@@ -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<T>(key: string): T | null {
|
||||
const parsed: CachedData<T> = 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 팀
|
||||
**다음 조치**: 이슈 트래커에 우선순위별 태스크 등록
|
||||
@@ -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 마이그레이션 워크플로우 |
|
||||
|
||||
458
claudedocs/guides/[GUIDE] large-file-handling-strategy.md
Normal file
458
claudedocs/guides/[GUIDE] large-file-handling-strategy.md
Normal file
@@ -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<void> {
|
||||
// 지원 확인
|
||||
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<void> {
|
||||
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<Response> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// ... 기존 코드 유지 ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [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/)
|
||||
@@ -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<SectionDesign>;
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
#### 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` |
|
||||
@@ -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<string, unknown>).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 관련 버그
|
||||
@@ -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` - 테넌트 데이터 격리
|
||||
@@ -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` |
|
||||
@@ -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"` 에러
|
||||
- 브라우저에서 `<a href="url">다운로드</a>` 클릭 시 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` |
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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<void> {
|
||||
// 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<PageConfig>(`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<void>`로 변경
|
||||
- [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 불일치 시 차단
|
||||
@@ -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<string, unknown>).details as Record<string, unknown> | 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<string, unknown>;
|
||||
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<Record<string, unknown>> | 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<Record<string, unknown>>;
|
||||
|
||||
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 설정
|
||||
|
||||
@@ -22,6 +22,12 @@ const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
|
||||
function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
|
||||
// attributes 객체 추출 (조립부품 등의 동적 필드가 여기에 저장됨)
|
||||
const attributes = (data.attributes || {}) as Record<string, unknown>;
|
||||
// details 객체 추출 (PT 부품의 상세 정보가 여기에 저장됨)
|
||||
const details = (data.details || {}) as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>): 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<string, unknown>): 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<Record<string, unknown>>;
|
||||
|
||||
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 || '품목 정보를 불러올 수 없습니다.');
|
||||
|
||||
@@ -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에 포함시키면 안됨)
|
||||
|
||||
@@ -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<ItemApiData[]> {
|
||||
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<ItemApiData[]> {
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>).id || (bendingFile as Record<string, unknown>).file_id;
|
||||
console.log('[DynamicItemForm] bendingFile ID 추출:', { id: (bendingFile as Record<string, unknown>).id, file_id: (bendingFile as Record<string, unknown>).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<string, unknown>).id || (specFile as Record<string, unknown>).file_id;
|
||||
console.log('[DynamicItemForm] specFile ID 추출:', { id: (specFile as Record<string, unknown>).id, file_id: (specFile as Record<string, unknown>).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<string, unknown>).id || (certFile as Record<string, unknown>).file_id;
|
||||
console.log('[DynamicItemForm] certFile ID 추출:', { id: (certFile as Record<string, unknown>).id, file_id: (certFile as Record<string, unknown>).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<string, unknown>, 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({
|
||||
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<span className="truncate">{existingSpecificationFileName}</span>
|
||||
</div>
|
||||
<a
|
||||
href={getStorageUrl(existingSpecificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</button>
|
||||
<label
|
||||
htmlFor="specification_file_edit"
|
||||
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-gray-600 cursor-pointer"
|
||||
@@ -1747,10 +1868,37 @@ export default function DynamicItemForm({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : specificationFile ? (
|
||||
/* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-blue-50 rounded-md border border-blue-200 text-sm">
|
||||
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<span className="truncate">{specificationFile.name}</span>
|
||||
<span className="text-xs text-blue-500">(새 파일)</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
title="취소"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
|
||||
<Input
|
||||
/* 파일 없는 경우: 파일 선택 버튼 */
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="specification_file"
|
||||
className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-gray-500">PDF 파일을 선택하세요</span>
|
||||
</label>
|
||||
<input
|
||||
id="specification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
@@ -1759,13 +1907,8 @@ export default function DynamicItemForm({
|
||||
setSpecificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
className="hidden"
|
||||
/>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택된 파일: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1781,15 +1924,14 @@ export default function DynamicItemForm({
|
||||
<FileText className="h-4 w-4 text-green-600 shrink-0" />
|
||||
<span className="truncate">{existingCertificationFileName}</span>
|
||||
</div>
|
||||
<a
|
||||
href={getStorageUrl(existingCertificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(existingCertificationFileId, existingCertificationFileName)}
|
||||
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-green-600"
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</button>
|
||||
<label
|
||||
htmlFor="certification_file_edit"
|
||||
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-gray-600 cursor-pointer"
|
||||
@@ -1820,10 +1962,37 @@ export default function DynamicItemForm({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : certificationFile ? (
|
||||
/* 새 파일 선택된 경우: 커스텀 UI로 파일명 표시 */
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-green-50 rounded-md border border-green-200 text-sm">
|
||||
<FileText className="h-4 w-4 text-green-600 shrink-0" />
|
||||
<span className="truncate">{certificationFile.name}</span>
|
||||
<span className="text-xs text-green-500">(새 파일)</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
title="취소"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 새 파일 업로드 (기존 파일 없거나, 새 파일 선택 중) */}
|
||||
<Input
|
||||
/* 파일 없는 경우: 파일 선택 버튼 */
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="certification_file"
|
||||
className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-gray-500">PDF 파일을 선택하세요</span>
|
||||
</label>
|
||||
<input
|
||||
id="certification_file"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
@@ -1832,13 +2001,8 @@ export default function DynamicItemForm({
|
||||
setCertificationFile(file);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="cursor-pointer"
|
||||
className="hidden"
|
||||
/>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택된 파일: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1937,6 +2101,10 @@ export default function DynamicItemForm({
|
||||
widthSumFieldKey={bendingFieldKeys.widthSum}
|
||||
setValue={(key, value) => setFieldValue(key, value)}
|
||||
isSubmitting={isSubmitting}
|
||||
existingBendingDiagram={existingBendingDiagram}
|
||||
existingBendingDiagramFileId={existingBendingDiagramFileId}
|
||||
onDeleteExistingFile={() => handleDeleteFile('bending_diagram')}
|
||||
isDeletingFile={isDeletingFile === 'bending_diagram'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1956,6 +2124,10 @@ export default function DynamicItemForm({
|
||||
widthSumFieldKey={bendingFieldKeys.widthSum}
|
||||
setValue={(key, value) => setFieldValue(key, value)}
|
||||
isSubmitting={isSubmitting}
|
||||
existingBendingDiagram={existingBendingDiagram}
|
||||
existingBendingDiagramFileId={existingBendingDiagramFileId}
|
||||
onDeleteExistingFile={() => handleDeleteFile('bending_diagram')}
|
||||
isDeletingFile={isDeletingFile === 'bending_diagram'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function DynamicBOMSection({
|
||||
const params = new URLSearchParams();
|
||||
params.append('search', query);
|
||||
params.append('size', '20');
|
||||
params.append('group_id', '1'); // 전체 품목 조회 (품목관리 그룹)
|
||||
|
||||
const response = await fetch(`/api/proxy/items?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -61,7 +62,20 @@ function formatItemCodeForAssembly(item: ItemMaster): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage 경로를 전체 URL로 변환
|
||||
* 파일 다운로드 핸들러 (Blob 방식)
|
||||
*/
|
||||
async function handleFileDownload(fileId: number | undefined, fileName?: string): Promise<void> {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
await downloadFileById(fileId, fileName);
|
||||
} catch (error) {
|
||||
console.error('[ItemDetailClient] 다운로드 실패:', error);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage 경로를 전체 URL로 변환 (전개도 이미지용)
|
||||
* - 이미 전체 URL인 경우 그대로 반환
|
||||
* - 상대 경로인 경우 API URL + /storage/ 붙여서 반환
|
||||
*/
|
||||
@@ -371,18 +385,42 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-6 pt-0">
|
||||
{/* 전개도 이미지 */}
|
||||
{item.bendingDiagram ? (
|
||||
{/* 전개도 이미지 - 파일 ID가 있으면 프록시로 로드 */}
|
||||
{(item.bendingDiagramFileId || item.bendingDiagram) ? (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">전개도 이미지</Label>
|
||||
<div className="mt-2 p-2 md:p-4 border rounded-lg bg-gray-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getStorageUrl(item.bendingDiagram) || ''}
|
||||
src={item.bendingDiagramFileId
|
||||
? `/api/proxy/files/${item.bendingDiagramFileId}/download`
|
||||
: getStorageUrl(item.bendingDiagram) || ''
|
||||
}
|
||||
alt="전개도"
|
||||
className="max-w-full h-auto max-h-64 md:max-h-96 mx-auto border rounded"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<p class="text-center text-gray-500 py-8">이미지를 불러올 수 없습니다</p>'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{item.bendingDiagramFileId && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleFileDownload(item.bendingDiagramFileId, '전개도_이미지')}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 md:py-8 text-xs md:text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
@@ -496,15 +534,14 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.specificationFileName || '시방서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.specificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(item.specificationFileId, item.specificationFileName)}
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 시방서가 없습니다.</p>
|
||||
@@ -520,15 +557,14 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.certificationFileName || '인정서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.certificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDownload(item.certificationFileId, item.certificationFileName)}
|
||||
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 인정서가 없습니다.</p>
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileImage, Plus, Trash2, X } from 'lucide-react';
|
||||
import { FileImage, Plus, Trash2, X, Download, Loader2 } from 'lucide-react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
|
||||
export interface BendingDiagramSectionProps {
|
||||
selectedPartType: string;
|
||||
@@ -26,6 +27,16 @@ export interface BendingDiagramSectionProps {
|
||||
widthSumFieldKey?: string;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
isSubmitting: boolean;
|
||||
/** 기존 전개도 이미지 URL (수정 모드) */
|
||||
existingBendingDiagram?: string;
|
||||
/** 기존 전개도 파일명 */
|
||||
existingBendingDiagramFileName?: string;
|
||||
/** 기존 전개도 파일 ID (삭제용) */
|
||||
existingBendingDiagramFileId?: number | null;
|
||||
/** 기존 파일 삭제 콜백 */
|
||||
onDeleteExistingFile?: () => void;
|
||||
/** 파일 삭제 중 상태 */
|
||||
isDeletingFile?: boolean;
|
||||
}
|
||||
|
||||
export default function BendingDiagramSection({
|
||||
@@ -42,7 +53,27 @@ export default function BendingDiagramSection({
|
||||
widthSumFieldKey,
|
||||
setValue,
|
||||
isSubmitting,
|
||||
existingBendingDiagram,
|
||||
existingBendingDiagramFileName,
|
||||
existingBendingDiagramFileId,
|
||||
onDeleteExistingFile,
|
||||
isDeletingFile,
|
||||
}: BendingDiagramSectionProps) {
|
||||
// 기존 파일 다운로드 핸들러
|
||||
const handleDownloadExistingFile = async () => {
|
||||
if (!existingBendingDiagramFileId) {
|
||||
alert('파일 ID가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = existingBendingDiagramFileName || '전개도_이미지';
|
||||
await downloadFileById(existingBendingDiagramFileId, fileName);
|
||||
} catch (error) {
|
||||
console.error('[BendingDiagramSection] 파일 다운로드 실패:', error);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
// 폭 합계 업데이트 헬퍼
|
||||
const updateWidthSum = (details: BendingDetail[]) => {
|
||||
const totalSum = details.reduce((acc, d) => {
|
||||
@@ -108,6 +139,76 @@ export default function BendingDiagramSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기존 전개도 이미지 (수정 모드) - 파일 ID가 있으면 프록시로 이미지 로드 */}
|
||||
{existingBendingDiagramFileId && !bendingDiagram && (
|
||||
<div className="p-4 border rounded-lg bg-blue-50 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileImage className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">기존 전개도 이미지</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadExistingFile}
|
||||
className="text-blue-600 border-blue-300 hover:bg-blue-100"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{onDeleteExistingFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDeleteExistingFile}
|
||||
disabled={isDeletingFile}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
{isDeletingFile ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{existingBendingDiagramFileName && (
|
||||
<p className="text-xs text-blue-700 mb-2">
|
||||
파일명: {existingBendingDiagramFileName}
|
||||
</p>
|
||||
)}
|
||||
<div className="border rounded bg-white p-2">
|
||||
<img
|
||||
src={`/api/proxy/files/${existingBendingDiagramFileId}/download`}
|
||||
alt="기존 전개도"
|
||||
className="max-w-full h-auto max-h-96 mx-auto"
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 대체 텍스트 표시
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.parentElement?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<p class="text-center text-gray-500 py-8">이미지를 불러올 수 없습니다</p>'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mt-2">
|
||||
* 새 파일을 업로드하면 기존 파일이 교체됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 선택 방식 */}
|
||||
{bendingDiagramInputMethod === 'file' && (
|
||||
<div>
|
||||
|
||||
@@ -174,15 +174,11 @@ export default function ItemListClient() {
|
||||
try {
|
||||
console.log('[Delete] 삭제 요청:', itemToDelete);
|
||||
|
||||
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용
|
||||
// Products (FG, PT)는 /items 엔드포인트 사용
|
||||
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||
const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType);
|
||||
const deleteUrl = isMaterial
|
||||
? `/api/proxy/products/materials/${itemToDelete.id}?item_type=${itemToDelete.itemType}`
|
||||
: `/api/proxy/items/${itemToDelete.id}`;
|
||||
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
|
||||
// /products/materials 라우트 삭제됨
|
||||
const deleteUrl = `/api/proxy/items/${itemToDelete.id}?item_type=${itemToDelete.itemType}`;
|
||||
|
||||
console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ', itemType:', itemToDelete.itemType, ')');
|
||||
console.log('[Delete] URL:', deleteUrl, '(itemType:', itemToDelete.itemType, ')');
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
@@ -230,7 +226,7 @@ export default function ItemListClient() {
|
||||
};
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
// 2025-12-10: item_type 파라미터를 실제 코드(SM, RM, CS)로 전달
|
||||
// 2025-12-15: 백엔드 동적 테이블 라우팅으로 모든 품목에 item_type 필수
|
||||
const handleBulkDelete = async () => {
|
||||
const itemIds = Array.from(selectedItems);
|
||||
let successCount = 0;
|
||||
@@ -240,11 +236,8 @@ export default function ItemListClient() {
|
||||
try {
|
||||
// 해당 품목의 itemType 찾기
|
||||
const item = items.find((i) => i.id === id);
|
||||
const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
|
||||
// Materials는 /products/materials 엔드포인트 + item_type, Products는 /items 엔드포인트
|
||||
const deleteUrl = isMaterial
|
||||
? `/api/proxy/products/materials/${id}?item_type=${item?.itemType}`
|
||||
: `/api/proxy/items/${id}`;
|
||||
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
|
||||
const deleteUrl = `/api/proxy/items/${id}?item_type=${item?.itemType}`;
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -157,12 +157,12 @@ export function useItemList(): UseItemListResult {
|
||||
try {
|
||||
// 각 유형별로 병렬 조회
|
||||
const [allResponse, fgResponse, ptResponse, smResponse, rmResponse, csResponse] = await Promise.all([
|
||||
fetch('/api/proxy/items?size=1'), // 전체 (size=1로 최소 데이터만)
|
||||
fetch('/api/proxy/items?type=FG&size=1'), // 제품
|
||||
fetch('/api/proxy/items?type=PT&size=1'), // 부품
|
||||
fetch('/api/proxy/items?type=SM&size=1'), // 부자재
|
||||
fetch('/api/proxy/items?type=RM&size=1'), // 원자재
|
||||
fetch('/api/proxy/items?type=CS&size=1'), // 소모품
|
||||
fetch('/api/proxy/items?group_id=1&size=1'), // 전체 (품목관리 그룹)
|
||||
fetch('/api/proxy/items?type=FG&size=1'), // 제품
|
||||
fetch('/api/proxy/items?type=PT&size=1'), // 부품
|
||||
fetch('/api/proxy/items?type=SM&size=1'), // 부자재
|
||||
fetch('/api/proxy/items?type=RM&size=1'), // 원자재
|
||||
fetch('/api/proxy/items?type=CS&size=1'), // 소모품
|
||||
]);
|
||||
|
||||
const [allResult, fgResult, ptResult, smResult, rmResult, csResult] = await Promise.all([
|
||||
@@ -196,9 +196,16 @@ export function useItemList(): UseItemListResult {
|
||||
if (filters.search && filters.search.trim()) {
|
||||
params.append('search', filters.search.trim());
|
||||
}
|
||||
|
||||
// 타입별 조회 vs 전체 조회
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
// 특정 타입 조회: type 파라미터 사용
|
||||
params.append('type', filters.type);
|
||||
} else {
|
||||
// 전체 조회: group_id=1 (품목관리 그룹)
|
||||
params.append('group_id', '1');
|
||||
}
|
||||
|
||||
if (filters.page) {
|
||||
params.append('page', String(filters.page));
|
||||
}
|
||||
|
||||
@@ -434,25 +434,27 @@ export async function uploadItemFile(
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 파일 삭제 (ID 기반, 프록시 사용)
|
||||
* 품목 파일 삭제 (파일 ID 기반, 프록시 사용)
|
||||
*
|
||||
* @param itemId - 품목 ID (숫자)
|
||||
* @param fileType - 파일 유형 (specification, certification, bending_diagram)
|
||||
* @param fileId - 파일 ID (files 테이블의 id)
|
||||
* @param itemType - 품목 유형 (FG, PT, SM 등) - 기본값 'FG'
|
||||
*
|
||||
* @example
|
||||
* await deleteItemFile(123, 'specification');
|
||||
* await deleteItemFile(123, 456, 'FG');
|
||||
*/
|
||||
export async function deleteItemFile(
|
||||
itemId: number,
|
||||
fileType: ItemFileType
|
||||
): Promise<{ file_type: string; deleted: boolean; product: Record<string, unknown> }> {
|
||||
// 프록시 경유: /api/proxy/items/{id}/files/{type} → /api/v1/items/{id}/files/{type}
|
||||
const response = await fetch(`/api/proxy/items/${itemId}/files/${fileType}`, {
|
||||
fileId: number,
|
||||
itemType: string = 'FG'
|
||||
): Promise<{ file_id: number; deleted: boolean }> {
|
||||
// 프록시 경유: /api/proxy/items/{id}/files/{fileId} → /api/v1/items/{id}/files/{fileId}
|
||||
const response = await fetch(`/api/proxy/items/${itemId}/files/${fileId}?item_type=${itemType}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await handleApiResponse<ApiResponse<{ file_type: string; deleted: boolean; product: Record<string, unknown> }>>(response);
|
||||
const data = await handleApiResponse<ApiResponse<{ file_id: number; deleted: boolean }>>(response);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
|
||||
71
src/lib/utils/fileDownload.ts
Normal file
71
src/lib/utils/fileDownload.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 파일 다운로드 유틸리티
|
||||
*
|
||||
* 백엔드 API: GET /api/v1/files/{id}/download
|
||||
* 프록시: GET /api/proxy/files/{id}/download
|
||||
*/
|
||||
|
||||
/**
|
||||
* 파일 ID로 다운로드
|
||||
* @param fileId 파일 ID
|
||||
* @param fileName 저장할 파일명 (선택, 없으면 서버에서 제공하는 이름 사용)
|
||||
*/
|
||||
export async function downloadFileById(fileId: number, fileName?: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/files/${fileId}/download`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`다운로드 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// 파일명이 없으면 Content-Disposition 헤더에서 추출 시도
|
||||
let downloadFileName = fileName;
|
||||
if (!downloadFileName) {
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
downloadFileName = match[1].replace(/['"]/g, '');
|
||||
// URL 디코딩 (한글 파일명 처리)
|
||||
try {
|
||||
downloadFileName = decodeURIComponent(downloadFileName);
|
||||
} catch {
|
||||
// 디코딩 실패 시 그대로 사용
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 그래도 없으면 기본 파일명
|
||||
if (!downloadFileName) {
|
||||
downloadFileName = `file_${fileId}`;
|
||||
}
|
||||
|
||||
// Blob URL 생성 및 다운로드 트리거
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[fileDownload] 다운로드 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 경로로 새 탭에서 열기 (미리보기용)
|
||||
* @param filePath 파일 경로
|
||||
*/
|
||||
export function openFileInNewTab(filePath: string): void {
|
||||
// 백엔드 파일 서빙 URL 구성
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const fileUrl = `${baseUrl}/storage/${filePath}`;
|
||||
window.open(fileUrl, '_blank');
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export interface ItemRevision {
|
||||
|
||||
/**
|
||||
* 품목 개별 파일 정보
|
||||
* API 응답: files.specification[0], files.certification[0] 등
|
||||
* API 응답: files.specification_files[0], files.certification_files[0] 등
|
||||
*/
|
||||
export interface ItemFile {
|
||||
id: number; // 파일 ID (file_id로 사용)
|
||||
@@ -113,8 +113,8 @@ export interface ItemFile {
|
||||
*/
|
||||
export interface ItemFiles {
|
||||
bending_diagram?: ItemFile[]; // 전개도 파일들
|
||||
specification?: ItemFile[]; // 시방서 파일들
|
||||
certification?: ItemFile[]; // 인정서 파일들
|
||||
specification_files?: ItemFile[]; // 시방서 파일들
|
||||
certification_files?: ItemFile[]; // 인정서 파일들
|
||||
}
|
||||
|
||||
// ===== 품목 마스터 (메인) =====
|
||||
@@ -174,6 +174,7 @@ export interface ItemMaster {
|
||||
|
||||
// 절곡품 관련
|
||||
bendingDiagram?: string; // 전개도 이미지 URL
|
||||
bendingDiagramFileId?: number; // 전개도 파일 ID (다운로드/미리보기용)
|
||||
bendingDetails?: BendingDetail[]; // 전개도 상세 데이터
|
||||
material?: string; // 재질 (EGI 1.55T, SUS 1.2T)
|
||||
length?: string; // 길이/목함 (mm)
|
||||
@@ -192,8 +193,10 @@ export interface ItemMaster {
|
||||
certificationEndDate?: string; // 인정 유효기간 종료일
|
||||
specificationFile?: string; // 시방서 파일 URL
|
||||
specificationFileName?: string; // 시방서 파일명
|
||||
specificationFileId?: number; // 시방서 파일 ID (다운로드용)
|
||||
certificationFile?: string; // 인정서 파일 URL
|
||||
certificationFileName?: string; // 인정서 파일명
|
||||
certificationFileId?: number; // 인정서 파일 ID (다운로드용)
|
||||
|
||||
// === 파일 정보 (새 API 구조) ===
|
||||
files?: ItemFiles; // 파일 목록 (타입별 배열)
|
||||
|
||||
Reference in New Issue
Block a user