feat: 단가관리 페이지 마이그레이션 및 HR 관리 기능 추가
## 단가관리 (Pricing Management) - 단가 목록 페이지 (IntegratedListTemplateV2 공통 템플릿 적용) - 단가 등록/수정 폼 (원가/마진 자동 계산) - 이력 조회, 수정 이력, 최종 확정 다이얼로그 - 판매관리 > 단가관리 네비게이션 메뉴 추가 ## HR 관리 (Human Resources) - 사원관리 (목록, 등록, 수정, 상세, CSV 업로드) - 부서관리 (트리 구조) - 근태관리 (기본 구조) ## 품목관리 개선 - Radix UI Select controlled mode 버그 수정 (key prop 적용) - DynamicItemForm 파일 업로드 지원 - 수정 페이지 데이터 로딩 개선 ## 문서화 - 단가관리 마이그레이션 체크리스트 - HR 관리 구현 체크리스트 - Radix UI Select 버그 수정 가이드 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 근태관리 페이지 (Attendance Management)
|
||||
*
|
||||
* 직원 출퇴근 및 근태 정보를 관리하는 시스템
|
||||
* - 근태 목록 조회/검색/필터
|
||||
* - 근태 등록/수정
|
||||
* - 사유 등록 (출장, 휴가, 외근 등)
|
||||
* - 엑셀 다운로드
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { AttendanceManagement } from '@/components/hr/AttendanceManagement';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: '근태관리',
|
||||
description: '직원 출퇴근 및 근태 정보를 관리합니다',
|
||||
};
|
||||
|
||||
export default function AttendanceManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<AttendanceManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 부서관리 페이지 (Department Management)
|
||||
*
|
||||
* 부서 정보를 관리하는 시스템
|
||||
* 무제한 깊이 트리 구조 지원
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { DepartmentManagement } from '@/components/hr/DepartmentManagement';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: '부서관리',
|
||||
description: '부서 정보를 관리합니다',
|
||||
};
|
||||
|
||||
export default function DepartmentManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<DepartmentManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
||||
import type { Employee, EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
const mockEmployee: Employee = {
|
||||
id: '1',
|
||||
name: '김철수',
|
||||
employeeCode: 'EMP001',
|
||||
phone: '010-1234-5678',
|
||||
email: 'kimcs@company.com',
|
||||
status: 'active',
|
||||
hireDate: '2020-03-15',
|
||||
employmentType: 'regular',
|
||||
rank: '과장',
|
||||
gender: 'male',
|
||||
salary: 50000000,
|
||||
bankAccount: {
|
||||
bankName: '국민은행',
|
||||
accountNumber: '123-456-789012',
|
||||
accountHolder: '김철수',
|
||||
},
|
||||
address: {
|
||||
zipCode: '12345',
|
||||
address1: '서울시 강남구 테헤란로 123',
|
||||
address2: '101호',
|
||||
},
|
||||
departmentPositions: [
|
||||
{ id: '1', departmentId: 'd1', departmentName: '개발본부', positionId: 'p1', positionName: '팀장' }
|
||||
],
|
||||
userInfo: { userId: 'kimcs', role: 'manager', accountStatus: 'active' },
|
||||
createdAt: '2020-03-15T00:00:00Z',
|
||||
updatedAt: '2024-01-15T00:00:00Z',
|
||||
};
|
||||
|
||||
export default function EmployeeEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API 연동
|
||||
// const id = params.id;
|
||||
setEmployee(mockEmployee);
|
||||
}, [params.id]);
|
||||
|
||||
const handleSave = (data: EmployeeFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log('Update employee:', params.id, data);
|
||||
router.push(`/ko/hr/employee-management/${params.id}`);
|
||||
};
|
||||
|
||||
if (!employee) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmployeeForm mode="edit" employee={employee} onSave={handleSave} />;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { EmployeeDetail } from '@/components/hr/EmployeeManagement/EmployeeDetail';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
const mockEmployee: Employee = {
|
||||
id: '1',
|
||||
name: '김철수',
|
||||
employeeCode: 'EMP001',
|
||||
phone: '010-1234-5678',
|
||||
email: 'kimcs@company.com',
|
||||
status: 'active',
|
||||
hireDate: '2020-03-15',
|
||||
employmentType: 'regular',
|
||||
rank: '과장',
|
||||
gender: 'male',
|
||||
salary: 50000000,
|
||||
bankAccount: {
|
||||
bankName: '국민은행',
|
||||
accountNumber: '123-456-789012',
|
||||
accountHolder: '김철수',
|
||||
},
|
||||
address: {
|
||||
zipCode: '12345',
|
||||
address1: '서울시 강남구 테헤란로 123',
|
||||
address2: '101호',
|
||||
},
|
||||
departmentPositions: [
|
||||
{ id: '1', departmentId: 'd1', departmentName: '개발본부', positionId: 'p1', positionName: '팀장' }
|
||||
],
|
||||
userInfo: { userId: 'kimcs', role: 'manager', accountStatus: 'active' },
|
||||
createdAt: '2020-03-15T00:00:00Z',
|
||||
updatedAt: '2024-01-15T00:00:00Z',
|
||||
};
|
||||
|
||||
export default function EmployeeDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API 연동
|
||||
// const id = params.id;
|
||||
setEmployee(mockEmployee);
|
||||
}, [params.id]);
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/hr/employee-management/${params.id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
// TODO: API 연동
|
||||
console.log('Delete employee:', params.id);
|
||||
router.push('/ko/hr/employee-management');
|
||||
};
|
||||
|
||||
if (!employee) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmployeeDetail
|
||||
employee={employee}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>사원 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{employee.name}" 사원을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 사원 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { CSVUploadPage } from '@/components/hr/EmployeeManagement/CSVUploadPage';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
export default function EmployeeCSVUploadPage() {
|
||||
const handleUpload = (employees: Employee[]) => {
|
||||
// TODO: API 연동
|
||||
console.log('Upload employees:', employees);
|
||||
};
|
||||
|
||||
return <CSVUploadPage onUpload={handleUpload} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
||||
import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
export default function EmployeeNewPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleSave = (data: EmployeeFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log('Save new employee:', data);
|
||||
router.push('/ko/hr/employee-management');
|
||||
};
|
||||
|
||||
return <EmployeeForm mode="create" onSave={handleSave} />;
|
||||
}
|
||||
38
src/app/[locale]/(protected)/hr/employee-management/page.tsx
Normal file
38
src/app/[locale]/(protected)/hr/employee-management/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 사원관리 페이지 (Employee Management)
|
||||
*
|
||||
* 사원 정보를 관리하는 시스템
|
||||
* - 사원 목록 조회/검색/필터
|
||||
* - 사원 등록/수정/삭제
|
||||
* - CSV 일괄 등록
|
||||
* - 사용자 초대
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { EmployeeManagement } from '@/components/hr/EmployeeManagement';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: '사원관리',
|
||||
description: '사원 정보를 관리합니다',
|
||||
};
|
||||
|
||||
export default function EmployeeManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<EmployeeManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,12 @@ import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
|
||||
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
|
||||
import {
|
||||
MATERIAL_TYPES,
|
||||
isMaterialType,
|
||||
transformMaterialDataForSave,
|
||||
convertOptionsToStandardFields,
|
||||
} from '@/lib/utils/materialTransform';
|
||||
|
||||
/**
|
||||
* API 응답 타입 (백엔드 Product 모델 기준)
|
||||
@@ -38,6 +41,9 @@ interface ItemApiResponse {
|
||||
is_active?: boolean;
|
||||
description?: string;
|
||||
note?: string;
|
||||
remarks?: string; // Material 모델은 remarks 사용
|
||||
material_code?: string; // Material 모델 코드 필드
|
||||
material_type?: string; // Material 모델 타입 필드
|
||||
part_type?: string;
|
||||
part_usage?: string;
|
||||
material?: string;
|
||||
@@ -69,12 +75,15 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
|
||||
// 프론트엔드 폼 필드: item_name, item_code 등 (snake_case)
|
||||
|
||||
// 기본 필드 (백엔드 name → 폼 item_name)
|
||||
const itemName = data.name || data.item_name;
|
||||
// Material의 경우 item_name 필드 사용, Product는 name 필드 사용
|
||||
const itemName = data.item_name || data.name;
|
||||
if (itemName) formData['item_name'] = itemName;
|
||||
if (data.unit) formData['unit'] = data.unit;
|
||||
if (data.specification) formData['specification'] = data.specification;
|
||||
if (data.description) formData['description'] = data.description;
|
||||
// Material은 'remarks', Product는 'note' 사용 → 프론트엔드 폼은 'note' 기대
|
||||
if (data.note) formData['note'] = data.note;
|
||||
if (data.remarks) formData['note'] = data.remarks; // Material remarks → note 매핑
|
||||
formData['is_active'] = data.is_active ?? true;
|
||||
|
||||
// 부품 관련 필드 (PT)
|
||||
@@ -100,12 +109,32 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
|
||||
if (data.certification_start_date) formData['certification_start_date'] = data.certification_start_date;
|
||||
if (data.certification_end_date) formData['certification_end_date'] = data.certification_end_date;
|
||||
|
||||
// 파일 관련 필드 (edit 모드에서 기존 파일 표시용)
|
||||
if (data.bending_diagram) formData['bending_diagram'] = data.bending_diagram;
|
||||
if (data.specification_file) formData['specification_file'] = data.specification_file;
|
||||
if (data.specification_file_name) formData['specification_file_name'] = data.specification_file_name;
|
||||
if (data.certification_file) formData['certification_file'] = data.certification_file;
|
||||
if (data.certification_file_name) formData['certification_file_name'] = data.certification_file_name;
|
||||
|
||||
// Material(SM, RM, CS) options 필드 매핑
|
||||
// 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨
|
||||
// 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용
|
||||
// 2025-12-05: Edit 모드에서 Select 옵션 값 불러오기 위해 추가
|
||||
if (data.options && Array.isArray(data.options)) {
|
||||
(data.options as Array<{ label: string; value: string }>).forEach((opt) => {
|
||||
if (opt.label && opt.value) {
|
||||
formData[opt.label] = opt.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 기타 동적 필드들 (API에서 받은 모든 필드를 포함)
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
// 이미 처리한 특수 필드들 제외 (백엔드 필드명 + 기존 필드명)
|
||||
const excludeKeys = [
|
||||
'id', 'code', 'name', 'product_type', // 백엔드 Product 모델 필드
|
||||
'item_code', 'item_name', 'item_type', // 기존 호환 필드
|
||||
'material_code', 'material_type', 'remarks', // Material 모델 필드 (remarks → note 변환됨)
|
||||
'created_at', 'updated_at', 'deleted_at', 'bom',
|
||||
'tenant_id', 'category_id', 'category', 'component_lines',
|
||||
];
|
||||
@@ -177,6 +206,12 @@ export default function EditItemPage() {
|
||||
|
||||
if (result.success && result.data) {
|
||||
const apiData = result.data as ItemApiResponse;
|
||||
// console.log('========== [EditItem] API 원본 데이터 ==========');
|
||||
// console.log('is_active:', apiData.is_active);
|
||||
// console.log('specification:', apiData.specification);
|
||||
// console.log('unit:', apiData.unit);
|
||||
// console.log('전체 데이터:', JSON.stringify(apiData, null, 2));
|
||||
// console.log('================================================');
|
||||
|
||||
// ID, 품목 유형 저장
|
||||
// Product: product_type, Material: material_type 또는 type_code
|
||||
@@ -187,7 +222,12 @@ export default function EditItemPage() {
|
||||
|
||||
// 폼 데이터로 변환
|
||||
const formData = mapApiResponseToFormData(apiData);
|
||||
// console.log('[EditItem] Mapped form data:', formData);
|
||||
// console.log('========== [EditItem] Mapped form data ==========');
|
||||
// console.log('is_active:', formData['is_active']);
|
||||
// console.log('specification:', formData['specification']);
|
||||
// console.log('unit:', formData['unit']);
|
||||
// console.log('전체 매핑 데이터:', JSON.stringify(formData, null, 2));
|
||||
// console.log('=================================================');
|
||||
setInitialData(formData);
|
||||
} else {
|
||||
setError(result.message || '품목 정보를 불러올 수 없습니다.');
|
||||
@@ -222,6 +262,12 @@ export default function EditItemPage() {
|
||||
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용
|
||||
// Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용
|
||||
const isMaterial = itemType ? MATERIAL_TYPES.includes(itemType) : false;
|
||||
|
||||
// 디버깅: material_code 생성 관련 변수 확인 (필요 시 주석 해제)
|
||||
// console.log('========== [EditItem] handleSubmit 디버깅 ==========');
|
||||
// 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}`;
|
||||
@@ -229,18 +275,25 @@ export default function EditItemPage() {
|
||||
|
||||
// console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')');
|
||||
|
||||
// 수정 시 code/material_code는 변경하지 않음 (UNIQUE 제약조건 위반 방지)
|
||||
// DynamicItemForm에서 자동생성되는 code를 제외해야 함
|
||||
// 품목코드 자동생성 처리
|
||||
// - FG(제품): 품목코드 = 품목명
|
||||
// - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙)
|
||||
// - Material(SM, RM, CS): material_code = 품목명-규격
|
||||
let submitData = { ...data };
|
||||
|
||||
// FG(제품)의 경우: 품목코드 = 품목명이므로, name 변경 시 code도 함께 변경
|
||||
// 다른 타입: code 제외 (UNIQUE 제약조건)
|
||||
if (itemType === 'FG') {
|
||||
// FG는 품목명이 품목코드가 되므로 name 값으로 code 설정
|
||||
submitData.code = submitData.name;
|
||||
} else {
|
||||
delete submitData.code;
|
||||
} else if (itemType === 'PT') {
|
||||
// PT는 DynamicItemForm에서 자동계산한 code를 그대로 사용
|
||||
// (조립: GR-001, 절곡: RM30, 구매: 전동개폐기150KG380V)
|
||||
// code가 없으면 기본값으로 name 사용
|
||||
if (!submitData.code) {
|
||||
submitData.code = submitData.name;
|
||||
}
|
||||
}
|
||||
// Material(SM, RM, CS)은 아래 isMaterial 블록에서 submitData.code를 material_code로 변환
|
||||
// 2025-12-05: delete submitData.code 제거 - DynamicItemForm에서 조합된 code 값을 사용해야 함
|
||||
|
||||
// 공통: spec → specification 필드명 변환 (백엔드 API 규격)
|
||||
if (submitData.spec !== undefined) {
|
||||
@@ -249,29 +302,21 @@ export default function EditItemPage() {
|
||||
}
|
||||
|
||||
if (isMaterial) {
|
||||
// Materials의 경우 추가 필드명 변환
|
||||
// DynamicItemForm에서 오는 데이터: name, product_type 등
|
||||
// Material API가 기대하는 데이터: name, material_type 등
|
||||
submitData = {
|
||||
...submitData,
|
||||
// Material API 필드명 매핑
|
||||
material_type: submitData.product_type || itemType,
|
||||
// 불필요한 필드 제거
|
||||
product_type: undefined,
|
||||
material_code: undefined, // 수정 시 코드 변경 불가
|
||||
};
|
||||
// console.log('[EditItem] Material submitData:', submitData);
|
||||
// Material(SM, RM, CS) 데이터 변환: standard_* → options 배열, specification 생성
|
||||
// 2025-12-05: 공통 유틸 함수 사용
|
||||
submitData = transformMaterialDataForSave(submitData, itemType || 'RM');
|
||||
// console.log('[EditItem] Material 변환 데이터:', submitData);
|
||||
} else {
|
||||
// Products (FG, PT)의 경우
|
||||
// console.log('[EditItem] Product submitData:', submitData);
|
||||
}
|
||||
|
||||
// API 호출
|
||||
console.log('========== [EditItem] PUT 요청 데이터 ==========');
|
||||
console.log('URL:', updateUrl);
|
||||
console.log('Method:', method);
|
||||
console.log('전송 데이터:', JSON.stringify(submitData, null, 2));
|
||||
console.log('================================================');
|
||||
// console.log('========== [EditItem] PUT 요청 데이터 ==========');
|
||||
// console.log('URL:', updateUrl);
|
||||
// console.log('Method:', method);
|
||||
// console.log('전송 데이터:', JSON.stringify(submitData, null, 2));
|
||||
// console.log('================================================');
|
||||
|
||||
const response = await fetch(updateUrl, {
|
||||
method,
|
||||
@@ -282,16 +327,16 @@ export default function EditItemPage() {
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('========== [EditItem] PUT 응답 ==========');
|
||||
console.log('Response:', JSON.stringify(result, null, 2));
|
||||
console.log('==========================================');
|
||||
// console.log('========== [EditItem] PUT 응답 ==========');
|
||||
// console.log('Response:', JSON.stringify(result, null, 2));
|
||||
// console.log('==========================================');
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.message || '품목 수정에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 성공 메시지만 표시 (리다이렉트는 DynamicItemForm에서 처리)
|
||||
// alert 제거 - DynamicItemForm에서 router.push('/items')로 이동
|
||||
// 성공 시 품목 ID 반환 (파일 업로드용)
|
||||
return { id: itemId, ...result.data };
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
@@ -339,6 +384,7 @@ export default function EditItemPage() {
|
||||
<DynamicItemForm
|
||||
mode="edit"
|
||||
itemType={itemType}
|
||||
itemId={itemId ?? undefined}
|
||||
initialData={initialData}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -73,6 +73,17 @@ function mapApiResponseToItemMaster(data: Record<string, unknown>): ItemMaster {
|
||||
quantityFormula: bomItem.quantity_formula ? String(bomItem.quantity_formula) : undefined,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { useState } from 'react';
|
||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||
import { isMaterialType, transformMaterialDataForSave } from '@/lib/utils/materialTransform';
|
||||
|
||||
// 기존 ItemForm (주석처리 - 롤백 시 사용)
|
||||
// import ItemForm from '@/components/items/ItemForm';
|
||||
@@ -21,13 +22,24 @@ export default function CreateItemPage() {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
// 필드명 변환: spec → specification (백엔드 API 규격)
|
||||
const submitData = { ...data };
|
||||
if (submitData.spec !== undefined) {
|
||||
submitData.specification = submitData.spec;
|
||||
delete submitData.spec;
|
||||
}
|
||||
|
||||
// Material(SM, RM, CS)인 경우 수정 페이지와 동일하게 transformMaterialDataForSave 사용
|
||||
const itemType = submitData.product_type as string;
|
||||
|
||||
// API 호출: POST /api/proxy/items
|
||||
// 백엔드에서 product_type에 따라 Product/Material 분기 처리
|
||||
const response = await fetch('/api/proxy/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
@@ -37,7 +49,10 @@ export default function CreateItemPage() {
|
||||
}
|
||||
|
||||
// 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨
|
||||
console.log('[CreateItemPage] 품목 등록 성공:', result.data);
|
||||
// console.log('[CreateItemPage] 품목 등록 성공:', result.data);
|
||||
|
||||
// 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용)
|
||||
return { id: result.data.id, ...result.data };
|
||||
} catch (error) {
|
||||
console.error('[CreateItemPage] 품목 등록 실패:', error);
|
||||
setSubmitError(error instanceof Error ? error.message : '품목 등록에 실패했습니다.');
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 단가 수정 페이지
|
||||
*
|
||||
* 경로: /sales/pricing-management/[id]/edit
|
||||
*/
|
||||
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
|
||||
interface EditPricingPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// TODO: API 연동 시 실제 단가 조회로 교체
|
||||
async function getPricingById(id: string): Promise<PricingData | null> {
|
||||
// 임시 목(Mock) 데이터
|
||||
const mockPricings: Record<string, PricingData> = {
|
||||
'pricing-4': {
|
||||
id: 'pricing-4',
|
||||
itemId: 'item-4',
|
||||
itemCode: 'GR-001',
|
||||
itemName: '가이드레일 130×80',
|
||||
itemType: 'PT',
|
||||
specification: '130×80×2438',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-24',
|
||||
purchasePrice: 45000,
|
||||
processingCost: 5000,
|
||||
loss: 0,
|
||||
roundingRule: 'round',
|
||||
roundingUnit: 1,
|
||||
marginRate: 20,
|
||||
salesPrice: 60000,
|
||||
supplier: '가이드레일 공급사',
|
||||
note: '스크린용 가이드레일',
|
||||
currentRevision: 1,
|
||||
isFinal: false,
|
||||
revisions: [
|
||||
{
|
||||
revisionNumber: 1,
|
||||
revisionDate: '2025-11-20T10:00:00Z',
|
||||
revisionBy: '관리자',
|
||||
revisionReason: '초기 가격 조정',
|
||||
previousData: {
|
||||
id: 'pricing-4',
|
||||
itemId: 'item-4',
|
||||
itemCode: 'GR-001',
|
||||
itemName: '가이드레일 130×80',
|
||||
itemType: 'PT',
|
||||
specification: '130×80×2438',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-15',
|
||||
purchasePrice: 40000,
|
||||
processingCost: 5000,
|
||||
marginRate: 15,
|
||||
salesPrice: 51750,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
status: 'draft',
|
||||
createdAt: '2025-11-15T09:00:00Z',
|
||||
createdBy: '관리자',
|
||||
},
|
||||
},
|
||||
],
|
||||
status: 'active',
|
||||
createdAt: '2025-11-15T09:00:00Z',
|
||||
createdBy: '관리자',
|
||||
updatedAt: '2025-11-24T14:30:00Z',
|
||||
updatedBy: '관리자',
|
||||
},
|
||||
'pricing-5': {
|
||||
id: 'pricing-5',
|
||||
itemId: 'item-5',
|
||||
itemCode: 'CASE-001',
|
||||
itemName: '케이스 철재',
|
||||
itemType: 'PT',
|
||||
specification: '표준형',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-20',
|
||||
purchasePrice: 35000,
|
||||
processingCost: 10000,
|
||||
loss: 0,
|
||||
roundingRule: 'round',
|
||||
roundingUnit: 10,
|
||||
marginRate: 25,
|
||||
salesPrice: 56250,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
revisions: [],
|
||||
status: 'active',
|
||||
createdAt: '2025-11-20T10:00:00Z',
|
||||
createdBy: '관리자',
|
||||
},
|
||||
'pricing-6': {
|
||||
id: 'pricing-6',
|
||||
itemId: 'item-6',
|
||||
itemCode: 'MOTOR-001',
|
||||
itemName: '모터 0.4KW',
|
||||
itemType: 'PT',
|
||||
specification: '0.4KW',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-15',
|
||||
purchasePrice: 120000,
|
||||
processingCost: 10000,
|
||||
loss: 0,
|
||||
roundingRule: 'round',
|
||||
roundingUnit: 100,
|
||||
marginRate: 15,
|
||||
salesPrice: 149500,
|
||||
currentRevision: 2,
|
||||
isFinal: false,
|
||||
revisions: [
|
||||
{
|
||||
revisionNumber: 2,
|
||||
revisionDate: '2025-11-12T10:00:00Z',
|
||||
revisionBy: '관리자',
|
||||
revisionReason: '공급가 변동',
|
||||
previousData: {
|
||||
id: 'pricing-6',
|
||||
itemId: 'item-6',
|
||||
itemCode: 'MOTOR-001',
|
||||
itemName: '모터 0.4KW',
|
||||
itemType: 'PT',
|
||||
specification: '0.4KW',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-10',
|
||||
purchasePrice: 115000,
|
||||
processingCost: 10000,
|
||||
marginRate: 15,
|
||||
salesPrice: 143750,
|
||||
currentRevision: 1,
|
||||
isFinal: false,
|
||||
status: 'active',
|
||||
createdAt: '2025-11-05T09:00:00Z',
|
||||
createdBy: '관리자',
|
||||
},
|
||||
},
|
||||
{
|
||||
revisionNumber: 1,
|
||||
revisionDate: '2025-11-10T10:00:00Z',
|
||||
revisionBy: '관리자',
|
||||
revisionReason: '초기 등록',
|
||||
previousData: {
|
||||
id: 'pricing-6',
|
||||
itemId: 'item-6',
|
||||
itemCode: 'MOTOR-001',
|
||||
itemName: '모터 0.4KW',
|
||||
itemType: 'PT',
|
||||
specification: '0.4KW',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-05',
|
||||
purchasePrice: 110000,
|
||||
processingCost: 10000,
|
||||
marginRate: 15,
|
||||
salesPrice: 138000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
status: 'draft',
|
||||
createdAt: '2025-11-05T09:00:00Z',
|
||||
createdBy: '관리자',
|
||||
},
|
||||
},
|
||||
],
|
||||
status: 'active',
|
||||
createdAt: '2025-11-05T09:00:00Z',
|
||||
createdBy: '관리자',
|
||||
updatedAt: '2025-11-15T11:00:00Z',
|
||||
updatedBy: '관리자',
|
||||
},
|
||||
'pricing-7': {
|
||||
id: 'pricing-7',
|
||||
itemId: 'item-7',
|
||||
itemCode: 'CTL-001',
|
||||
itemName: '제어기 기본형',
|
||||
itemType: 'PT',
|
||||
specification: '기본형',
|
||||
unit: 'EA',
|
||||
effectiveDate: '2025-11-10',
|
||||
purchasePrice: 80000,
|
||||
processingCost: 5000,
|
||||
loss: 0,
|
||||
roundingRule: 'round',
|
||||
roundingUnit: 1000,
|
||||
marginRate: 20,
|
||||
salesPrice: 102000,
|
||||
currentRevision: 3,
|
||||
isFinal: true,
|
||||
finalizedDate: '2025-11-25T10:00:00Z',
|
||||
finalizedBy: '관리자',
|
||||
revisions: [],
|
||||
status: 'finalized',
|
||||
createdAt: '2025-11-01T09:00:00Z',
|
||||
createdBy: '관리자',
|
||||
updatedAt: '2025-11-25T10:00:00Z',
|
||||
updatedBy: '관리자',
|
||||
},
|
||||
};
|
||||
|
||||
return mockPricings[id] || null;
|
||||
}
|
||||
|
||||
export default async function EditPricingPage({ params }: EditPricingPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const pricingData = await getPricingById(id);
|
||||
|
||||
if (!pricingData) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">단가 정보를 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground">
|
||||
올바른 단가 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingFormClient
|
||||
mode="edit"
|
||||
initialData={pricingData}
|
||||
onSave={async (data, isRevision, revisionReason) => {
|
||||
'use server';
|
||||
// TODO: API 연동 시 실제 수정 로직으로 교체
|
||||
console.log('단가 수정:', data, isRevision, revisionReason);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 단가 등록 페이지
|
||||
*
|
||||
* 경로: /sales/pricing-management/create?itemId=xxx&itemCode=xxx
|
||||
*/
|
||||
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import type { ItemInfo } from '@/components/pricing';
|
||||
|
||||
interface CreatePricingPageProps {
|
||||
searchParams: Promise<{
|
||||
itemId?: string;
|
||||
itemCode?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// TODO: API 연동 시 실제 품목 조회로 교체
|
||||
async function getItemInfo(itemId: string, itemCode: string): Promise<ItemInfo | null> {
|
||||
// 임시 목(Mock) 데이터
|
||||
const mockItems: Record<string, ItemInfo> = {
|
||||
'item-1': {
|
||||
id: 'item-1',
|
||||
itemCode: 'SCREEN-001',
|
||||
itemName: '스크린 셔터 기본형',
|
||||
itemType: 'FG',
|
||||
specification: '표준형',
|
||||
unit: 'SET',
|
||||
},
|
||||
'item-2': {
|
||||
id: 'item-2',
|
||||
itemCode: 'SCREEN-002',
|
||||
itemName: '스크린 셔터 오픈형',
|
||||
itemType: 'FG',
|
||||
specification: '오픈형',
|
||||
unit: 'SET',
|
||||
},
|
||||
'item-3': {
|
||||
id: 'item-3',
|
||||
itemCode: 'STEEL-001',
|
||||
itemName: '철재 셔터 기본형',
|
||||
itemType: 'FG',
|
||||
specification: '표준형',
|
||||
unit: 'SET',
|
||||
},
|
||||
};
|
||||
|
||||
return mockItems[itemId] || null;
|
||||
}
|
||||
|
||||
export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) {
|
||||
const params = await searchParams;
|
||||
const itemId = params.itemId || '';
|
||||
const itemCode = params.itemCode || '';
|
||||
|
||||
const itemInfo = await getItemInfo(itemId, itemCode);
|
||||
|
||||
if (!itemInfo) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">품목 정보를 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground">
|
||||
올바른 품목 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingFormClient
|
||||
mode="create"
|
||||
itemInfo={itemInfo}
|
||||
onSave={async (data) => {
|
||||
'use server';
|
||||
// TODO: API 연동 시 실제 저장 로직으로 교체
|
||||
console.log('단가 등록:', data);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
178
src/app/[locale]/(protected)/sales/pricing-management/page.tsx
Normal file
178
src/app/[locale]/(protected)/sales/pricing-management/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 단가 목록 페이지
|
||||
*
|
||||
* 경로: /sales/pricing-management
|
||||
*/
|
||||
|
||||
import { PricingListClient } from '@/components/pricing';
|
||||
import type { PricingListItem } from '@/components/pricing';
|
||||
|
||||
// TODO: API 연동 시 실제 데이터 fetching으로 교체
|
||||
async function getPricingList(): Promise<PricingListItem[]> {
|
||||
// 임시 목(Mock) 데이터
|
||||
return [
|
||||
{
|
||||
id: 'pricing-1',
|
||||
itemId: 'item-1',
|
||||
itemCode: 'SCREEN-001',
|
||||
itemName: '스크린 셔터 기본형',
|
||||
itemType: 'FG',
|
||||
specification: '표준형',
|
||||
unit: 'SET',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-2',
|
||||
itemId: 'item-2',
|
||||
itemCode: 'SCREEN-002',
|
||||
itemName: '스크린 셔터 오픈형',
|
||||
itemType: 'FG',
|
||||
specification: '오픈형',
|
||||
unit: 'SET',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-3',
|
||||
itemId: 'item-3',
|
||||
itemCode: 'STEEL-001',
|
||||
itemName: '철재 셔터 기본형',
|
||||
itemType: 'FG',
|
||||
specification: '표준형',
|
||||
unit: 'SET',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-4',
|
||||
itemId: 'item-4',
|
||||
itemCode: 'GR-001',
|
||||
itemName: '가이드레일 130×80',
|
||||
itemType: 'PT',
|
||||
specification: '130×80×2438',
|
||||
unit: 'EA',
|
||||
purchasePrice: 45000,
|
||||
processingCost: 5000,
|
||||
salesPrice: 60000,
|
||||
marginRate: 20,
|
||||
effectiveDate: '2025-11-24',
|
||||
status: 'active',
|
||||
currentRevision: 1,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-5',
|
||||
itemId: 'item-5',
|
||||
itemCode: 'CASE-001',
|
||||
itemName: '케이스 철재',
|
||||
itemType: 'PT',
|
||||
specification: '표준형',
|
||||
unit: 'EA',
|
||||
purchasePrice: 35000,
|
||||
processingCost: 10000,
|
||||
salesPrice: 56250,
|
||||
marginRate: 25,
|
||||
effectiveDate: '2025-11-20',
|
||||
status: 'active',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-6',
|
||||
itemId: 'item-6',
|
||||
itemCode: 'MOTOR-001',
|
||||
itemName: '모터 0.4KW',
|
||||
itemType: 'PT',
|
||||
specification: '0.4KW',
|
||||
unit: 'EA',
|
||||
purchasePrice: 120000,
|
||||
processingCost: 10000,
|
||||
salesPrice: 149500,
|
||||
marginRate: 15,
|
||||
effectiveDate: '2025-11-15',
|
||||
status: 'active',
|
||||
currentRevision: 2,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-7',
|
||||
itemId: 'item-7',
|
||||
itemCode: 'CTL-001',
|
||||
itemName: '제어기 기본형',
|
||||
itemType: 'PT',
|
||||
specification: '기본형',
|
||||
unit: 'EA',
|
||||
purchasePrice: 80000,
|
||||
processingCost: 5000,
|
||||
salesPrice: 102000,
|
||||
marginRate: 20,
|
||||
effectiveDate: '2025-11-10',
|
||||
status: 'finalized',
|
||||
currentRevision: 3,
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
id: 'pricing-8',
|
||||
itemId: 'item-8',
|
||||
itemCode: '가이드레일wall12*30*12',
|
||||
itemName: '가이드레일',
|
||||
itemType: 'PT',
|
||||
specification: '가이드레일',
|
||||
unit: 'M',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
},
|
||||
{
|
||||
id: 'pricing-9',
|
||||
itemId: 'item-9',
|
||||
itemCode: '소모품 테스트-소모품 규격 테스트',
|
||||
itemName: '소모품 테스트',
|
||||
itemType: 'CS',
|
||||
specification: '소모품 규격 테스트',
|
||||
unit: 'M',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default async function PricingManagementPage() {
|
||||
const pricingList = await getPricingList();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<PricingListClient initialData={pricingList} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,22 +74,32 @@ async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
|
||||
/**
|
||||
* 백엔드 API 요청 실행 함수
|
||||
*
|
||||
* @param isFormData - true인 경우 Content-Type 헤더를 생략 (브라우저가 boundary 자동 설정)
|
||||
*/
|
||||
async function executeBackendRequest(
|
||||
url: URL,
|
||||
method: string,
|
||||
token: string | undefined,
|
||||
body: string | undefined,
|
||||
contentType: string
|
||||
body: string | FormData | undefined,
|
||||
contentType: string,
|
||||
isFormData: boolean = false
|
||||
): Promise<Response> {
|
||||
// FormData인 경우 Content-Type을 생략해야 브라우저가 boundary를 자동 설정
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
};
|
||||
|
||||
// FormData가 아닌 경우에만 Content-Type 설정
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -162,8 +172,9 @@ async function proxyRequest(
|
||||
});
|
||||
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE, PATCH)
|
||||
let body: string | undefined;
|
||||
let body: string | FormData | undefined;
|
||||
const contentType = request.headers.get('content-type') || 'application/json';
|
||||
let isFormData = false;
|
||||
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
if (contentType.includes('application/json')) {
|
||||
@@ -171,16 +182,38 @@ async function proxyRequest(
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
console.log('🔵 [PROXY] Request Body:', body); // 디버깅용
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
// multipart는 formData로 처리해야 하지만, 현재는 지원하지 않음
|
||||
console.warn('🟡 [PROXY] multipart/form-data is not fully supported');
|
||||
body = await request.text();
|
||||
// multipart/form-data 처리: FormData를 그대로 전달
|
||||
console.log('📎 [PROXY] Processing multipart/form-data request');
|
||||
isFormData = true;
|
||||
|
||||
// 원본 요청의 FormData 읽기
|
||||
const originalFormData = await request.formData();
|
||||
|
||||
// 새 FormData 생성 (백엔드 전송용)
|
||||
const newFormData = new FormData();
|
||||
|
||||
// 모든 필드 복사
|
||||
for (const [key, value] of originalFormData.entries()) {
|
||||
if (value instanceof File) {
|
||||
// File 객체는 그대로 추가
|
||||
newFormData.append(key, value, value.name);
|
||||
console.log(`📎 [PROXY] File field: ${key} = ${value.name} (${value.size} bytes)`);
|
||||
} else {
|
||||
// 일반 필드
|
||||
newFormData.append(key, value);
|
||||
console.log(`📎 [PROXY] Form field: ${key} = ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
body = newFormData;
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
}
|
||||
} else {
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
}
|
||||
|
||||
// 4. 백엔드로 프록시 요청
|
||||
let backendResponse = await executeBackendRequest(url, method, token, body, contentType);
|
||||
let backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
|
||||
let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null;
|
||||
|
||||
// 5. 🔄 401 응답 시 토큰 갱신 후 재시도
|
||||
@@ -195,7 +228,7 @@ async function proxyRequest(
|
||||
// 새 토큰으로 원래 요청 재시도
|
||||
token = refreshResult.accessToken;
|
||||
newTokens = refreshResult;
|
||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType);
|
||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
|
||||
|
||||
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user