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:
byeongcheolryu
2025-12-06 11:36:38 +09:00
parent 751e65f59b
commit 48dbba0e5f
59 changed files with 9888 additions and 101 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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>
&quot;{employee.name}&quot; ?
<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>
</>
);
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View 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>
);
}

View File

@@ -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}
/>

View File

@@ -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,
};
}

View File

@@ -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 : '품목 등록에 실패했습니다.');

View File

@@ -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);
}}
/>
);
}

View File

@@ -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);
}}
/>
);
}

View 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>
);
}

View File

@@ -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')) {
// multipartformData 처리해야 하지만, 현재는 지원하지 않음
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 {