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

@@ -80,6 +80,22 @@ export function DropdownField({
// 옵션이 없으면 드롭다운을 disabled로 표시
const hasOptions = options.length > 0;
// 디버깅: 단위 필드 값 추적
if (isUnitField) {
console.log('[DropdownField] 단위 필드 디버깅:', {
fieldKey,
fieldName: field.field_name,
rawValue: value,
stringValue,
isUnitField,
unitOptionsCount: unitOptions?.length || 0,
unitOptions: unitOptions?.slice(0, 3), // 처음 3개만
optionsCount: options.length,
options: options.slice(0, 3), // 처음 3개만
valueInOptions: options.some(o => o.value === stringValue),
});
}
return (
<div>
<Label htmlFor={fieldKey}>
@@ -87,6 +103,7 @@ export function DropdownField({
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<Select
key={`${fieldKey}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
disabled={disabled || !hasOptions}

View File

@@ -9,7 +9,7 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback } from 'react';
import type {
DynamicFormData,
DynamicFormErrors,
@@ -19,26 +19,18 @@ import type {
import type { ItemFieldResponse } from '@/types/item-master-api';
export function useDynamicFormState(
initialData?: DynamicFormData
_initialData?: DynamicFormData // 사용하지 않음 - 호환성을 위해 파라미터 유지
): UseDynamicFormStateResult {
const [formData, setFormData] = useState<DynamicFormData>(initialData || {});
// 2025-12-05: 항상 빈 객체로 시작
// Edit 모드 데이터는 DynamicItemForm에서 resetForm()으로 설정
// 이렇게 해야 StrictMode 리마운트에서도 안전함
const [formData, setFormData] = useState<DynamicFormData>({});
const [errors, setErrors] = useState<DynamicFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 2025-12-04: Edit 모드에서 initialData가 비동기로 로드될 때 formData 동기화
// useState의 초기값은 첫 렌더 시에만 사용되므로,
// initialData가 나중에 변경되면 formData를 업데이트해야 함
const isInitialDataLoaded = useRef(false);
useEffect(() => {
// initialData가 있고, 아직 로드되지 않았을 때만 동기화
// (사용자가 수정 중인 데이터를 덮어쓰지 않도록)
if (initialData && Object.keys(initialData).length > 0 && !isInitialDataLoaded.current) {
console.log('[useDynamicFormState] initialData 동기화:', initialData);
setFormData(initialData);
isInitialDataLoaded.current = true;
}
}, [initialData]);
// 2025-12-05: initialData 동기화 useEffect 제거
// 모든 초기 데이터는 resetForm()을 통해서만 설정
// StrictMode에서 useState 초기값이 원본 데이터로 리셋되는 문제 해결
// 필드 값 설정
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
@@ -186,6 +178,7 @@ export function useDynamicFormState(
// 폼 초기화
const resetForm = useCallback((newInitialData?: DynamicFormData) => {
console.log('[useDynamicFormState] resetForm 호출됨:', newInitialData);
setFormData(newInitialData || {});
setErrors({});
setIsSubmitting(false);

View File

@@ -8,7 +8,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Save, X } from 'lucide-react';
import { Package, Save, X, FileText, Trash2, Download } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
@@ -34,9 +34,10 @@ import {
generateBendingItemCodeSimple,
generatePurchasedItemCode,
} from './utils/itemCodeGenerator';
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState } from './types';
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState, ItemSaveResult } from './types';
import type { ItemType, BendingDetail } from '@/types/item';
import type { ItemFieldResponse } from '@/types/item-master-api';
import { uploadItemFile, deleteItemFile, ItemFileType } from '@/lib/api/items';
/**
* 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
@@ -220,6 +221,7 @@ function DynamicSectionRenderer({
export default function DynamicItemForm({
mode,
itemType: initialItemType,
itemId: propItemId,
initialData,
onSubmit,
}: DynamicItemFormProps) {
@@ -260,6 +262,75 @@ export default function DynamicItemForm({
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
const [certificationFile, setCertificationFile] = useState<File | null>(null);
// 기존 파일 URL 상태 (edit 모드에서 사용)
const [existingBendingDiagram, setExistingBendingDiagram] = useState<string>('');
const [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
// initialData에서 기존 파일 정보 로드 (edit 모드)
useEffect(() => {
if (mode === 'edit' && initialData) {
if (initialData.bending_diagram) {
setExistingBendingDiagram(initialData.bending_diagram as string);
}
if (initialData.specification_file) {
setExistingSpecificationFile(initialData.specification_file as string);
setExistingSpecificationFileName((initialData.specification_file_name as string) || '시방서');
}
if (initialData.certification_file) {
setExistingCertificationFile(initialData.certification_file as string);
setExistingCertificationFileName((initialData.certification_file_name as string) || '인정서');
}
}
}, [mode, initialData]);
// Storage 경로를 전체 URL로 변환
const getStorageUrl = (path: string | undefined): string | null => {
if (!path) return null;
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}/storage/${path}`;
};
// 파일 삭제 핸들러
const handleDeleteFile = async (fileType: ItemFileType) => {
if (!propItemId) return;
const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' :
fileType === 'specification' ? '시방서 파일을' : '인정서 파일을';
if (!confirm(`${confirmMessage} 삭제하시겠습니까?`)) return;
try {
setIsDeletingFile(fileType);
await deleteItemFile(propItemId, fileType);
// 상태 업데이트
if (fileType === 'bending_diagram') {
setExistingBendingDiagram('');
setBendingDiagram('');
} else if (fileType === 'specification') {
setExistingSpecificationFile('');
setExistingSpecificationFileName('');
} else if (fileType === 'certification') {
setExistingCertificationFile('');
setExistingCertificationFileName('');
}
alert('파일이 삭제되었습니다.');
} catch (error) {
console.error('[DynamicItemForm] 파일 삭제 실패:', error);
alert('파일 삭제에 실패했습니다.');
} finally {
setIsDeletingFile(null);
}
};
// 조건부 표시 관리
const { shouldShowSection, shouldShowField } = useConditionalDisplay(structure, formData);
@@ -332,9 +403,18 @@ export default function DynamicItemForm({
const [isEditDataMapped, setIsEditDataMapped] = useState(false);
useEffect(() => {
if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
if (mode !== 'edit' || !structure || !initialData) return;
// console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
// 이미 매핑된 데이터가 formData에 있으면 스킵 (98_unit 같은 field_key 형식)
// StrictMode 리렌더에서도 안전하게 동작
const hasFieldKeyData = Object.keys(formData).some(key => /^\d+_/.test(key));
if (hasFieldKeyData) {
console.log('[DynamicItemForm] Edit mode: 이미 field_key 형식 데이터 있음, 매핑 스킵');
return;
}
console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
console.log('[DynamicItemForm] initialData:', initialData);
// initialData의 간단한 키를 structure의 field_key로 매핑
// 예: { item_name: '테스트' } → { '98_item_name': '테스트' }
@@ -353,6 +433,17 @@ export default function DynamicItemForm({
// structure에서 모든 필드의 field_key 수집
const fieldKeyMap: Record<string, string> = {}; // 간단한 키 → field_key 매핑
// 영문 → 한글 필드명 별칭 (API 응답 키 → structure field_name 매핑)
// API는 영문 키(unit, note)로 응답하지만, structure field_key는 한글(단위, 비고) 포함
const fieldAliases: Record<string, string> = {
'unit': '단위',
'note': '비고',
'remarks': '비고', // Material 모델은 remarks 사용
'item_name': '품목명',
'specification': '규격',
'description': '설명',
};
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const field = f.field;
@@ -378,7 +469,7 @@ export default function DynamicItemForm({
}
});
// console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
// initialData를 field_key 형식으로 변환
Object.entries(initialData).forEach(([key, value]) => {
@@ -390,13 +481,41 @@ export default function DynamicItemForm({
else if (fieldKeyMap[key]) {
mappedData[fieldKeyMap[key]] = value;
}
// 영문 → 한글 별칭으로 시도 (API 응답 키 → structure field_name)
else if (fieldAliases[key] && fieldKeyMap[fieldAliases[key]]) {
mappedData[fieldKeyMap[fieldAliases[key]]] = value;
console.log(`[DynamicItemForm] 별칭 매핑: ${key}${fieldAliases[key]}${fieldKeyMap[fieldAliases[key]]}`);
}
// 매핑 없는 경우 그대로 유지
else {
mappedData[key] = value;
}
});
// console.log('[DynamicItemForm] Mapped initialData:', mappedData);
// 추가: 폼 구조의 모든 필드를 순회하면서, initialData에서 해당 값 직접 찾아서 설정
// (fieldKeyMap에 매핑이 없는 경우를 위한 fallback)
Object.entries(fieldKeyMap).forEach(([simpleName, fieldKey]) => {
// 아직 매핑 안된 필드인데 initialData에 값이 있으면 설정
if (mappedData[fieldKey] === undefined && initialData[simpleName] !== undefined) {
mappedData[fieldKey] = initialData[simpleName];
}
});
// 추가: 영문 별칭을 역으로 검색하여 매핑 (한글 field_name → 영문 API 키)
// 예: fieldKeyMap에 '단위'가 있고, initialData에 'unit'이 있으면 매핑
Object.entries(fieldAliases).forEach(([englishKey, koreanKey]) => {
const targetFieldKey = fieldKeyMap[koreanKey];
if (targetFieldKey && mappedData[targetFieldKey] === undefined && initialData[englishKey] !== undefined) {
mappedData[targetFieldKey] = initialData[englishKey];
console.log(`[DynamicItemForm] 별칭 fallback 매핑: ${englishKey}${koreanKey}${targetFieldKey}`);
}
});
console.log('========== [DynamicItemForm] Edit 모드 데이터 매핑 ==========');
console.log('specification 관련 키:', Object.keys(mappedData).filter(k => k.includes('specification') || k.includes('규격')));
console.log('is_active 관련 키:', Object.keys(mappedData).filter(k => k.includes('active') || k.includes('상태')));
console.log('매핑된 데이터:', mappedData);
console.log('==============================================================');
// 변환된 데이터로 폼 리셋
resetForm(mappedData);
@@ -1113,7 +1232,11 @@ export default function DynamicItemForm({
};
// formData를 백엔드 필드명으로 변환
// console.log('[DynamicItemForm] formData before conversion:', formData);
console.log('========== [DynamicItemForm] 저장 시 formData ==========');
console.log('specification 관련:', Object.entries(formData).filter(([k]) => k.includes('specification') || k.includes('규격')));
console.log('is_active 관련:', Object.entries(formData).filter(([k]) => k.includes('active') || k.includes('상태')));
console.log('전체 formData:', formData);
console.log('=========================================================');
const convertedData: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
// "{id}_{fieldKey}" 형식 체크: 숫자로 시작하고 _가 있는 경우
@@ -1131,6 +1254,7 @@ export default function DynamicItemForm({
// "활성", true, "true", "1", 1 등을 true로, 나머지는 false로
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
console.log(`[DynamicItemForm] is_active 변환: key=${key}, value=${value}(${typeof value}) → isActive=${isActive}`);
convertedData[backendKey] = isActive;
} else {
convertedData[backendKey] = value;
@@ -1142,13 +1266,18 @@ export default function DynamicItemForm({
if (backendKey === 'is_active') {
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
console.log(`[DynamicItemForm] is_active 변환 (non-field_key): key=${key}, value=${value}(${typeof value}) → isActive=${isActive}`);
convertedData[backendKey] = isActive;
} else {
convertedData[backendKey] = value;
}
}
});
// console.log('[DynamicItemForm] convertedData after conversion:', convertedData);
console.log('========== [DynamicItemForm] convertedData 결과 ==========');
console.log('is_active:', convertedData.is_active);
console.log('specification:', convertedData.spec || convertedData.specification);
console.log('전체:', convertedData);
console.log('===========================================================');
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
// 2025-12-04: 절곡 부품은 별도 품목명 필드(bendingFieldKeys.itemName) 사용
@@ -1249,7 +1378,79 @@ export default function DynamicItemForm({
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
await handleSubmit(async () => {
await onSubmit(submitData);
// 품목 저장 (ID 반환)
const result = await onSubmit(submitData);
const itemId = result?.id;
// 파일 업로드 (품목 ID가 있을 때만)
if (itemId) {
const fileUploadErrors: string[] = [];
// PT (절곡/조립) 전개도 이미지 업로드
if (selectedItemType === 'PT' && (isBendingPart || isAssemblyPart) && bendingDiagramFile) {
try {
console.log('[DynamicItemForm] 전개도 파일 업로드 시작:', bendingDiagramFile.name);
await uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram', {
bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({
angle: d.angle || 0,
length: d.width || 0,
type: d.direction || '',
})) : undefined,
});
console.log('[DynamicItemForm] 전개도 파일 업로드 성공');
} catch (error) {
console.error('[DynamicItemForm] 전개도 파일 업로드 실패:', error);
fileUploadErrors.push('전개도 이미지');
}
}
// FG (제품) 시방서 업로드
if (selectedItemType === 'FG' && specificationFile) {
try {
console.log('[DynamicItemForm] 시방서 파일 업로드 시작:', specificationFile.name);
await uploadItemFile(itemId, specificationFile, 'specification');
console.log('[DynamicItemForm] 시방서 파일 업로드 성공');
} catch (error) {
console.error('[DynamicItemForm] 시방서 파일 업로드 실패:', error);
fileUploadErrors.push('시방서');
}
}
// FG (제품) 인정서 업로드
if (selectedItemType === 'FG' && certificationFile) {
try {
console.log('[DynamicItemForm] 인정서 파일 업로드 시작:', certificationFile.name);
// formData에서 인정서 관련 필드 추출
const certNumber = Object.entries(formData).find(([key]) =>
key.includes('certification_number') || key.includes('인정번호')
)?.[1] as string | undefined;
const certStartDate = Object.entries(formData).find(([key]) =>
key.includes('certification_start') || key.includes('인정_유효기간_시작')
)?.[1] as string | undefined;
const certEndDate = Object.entries(formData).find(([key]) =>
key.includes('certification_end') || key.includes('인정_유효기간_종료')
)?.[1] as string | undefined;
await uploadItemFile(itemId, certificationFile, 'certification', {
certificationNumber: certNumber,
certificationStartDate: certStartDate,
certificationEndDate: certEndDate,
});
console.log('[DynamicItemForm] 인정서 파일 업로드 성공');
} catch (error) {
console.error('[DynamicItemForm] 인정서 파일 업로드 실패:', error);
fileUploadErrors.push('인정서');
}
}
// 파일 업로드 실패 경고 (품목은 저장됨)
if (fileUploadErrors.length > 0) {
console.warn('[DynamicItemForm] 일부 파일 업로드 실패:', fileUploadErrors.join(', '));
// 품목은 저장되었으므로 경고만 표시하고 진행
alert(`품목이 저장되었습니다.\n\n일부 파일 업로드에 실패했습니다: ${fileUploadErrors.join(', ')}\n수정 화면에서 다시 업로드해 주세요.`);
}
}
router.push('/items');
router.refresh();
});
@@ -1484,10 +1685,36 @@ export default function DynamicItemForm({
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
{isCertEndDateField && selectedItemType === 'FG' && (
<div className="mt-4 space-y-4">
{/* 시방서 파일 업로드 */}
{/* 시방서 파일 */}
<div>
<Label htmlFor="specification_file"> (PDF)</Label>
<div className="mt-1.5">
<div className="mt-1.5 space-y-2">
{/* 기존 파일 표시 (edit 모드) */}
{mode === 'edit' && existingSpecificationFile && !specificationFile && (
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
<FileText className="h-4 w-4 text-blue-600" />
<span className="text-sm flex-1 truncate">{existingSpecificationFileName}</span>
<a
href={getStorageUrl(existingSpecificationFile) || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
<Download className="h-3.5 w-3.5" />
</a>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDeleteFile('specification')}
disabled={isDeletingFile === 'specification' || isSubmitting}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
{/* 새 파일 업로드 */}
<Input
id="specification_file"
type="file"
@@ -1500,16 +1727,42 @@ export default function DynamicItemForm({
className="cursor-pointer"
/>
{specificationFile && (
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-muted-foreground">
: {specificationFile.name}
</p>
)}
</div>
</div>
{/* 인정서 파일 업로드 */}
{/* 인정서 파일 */}
<div>
<Label htmlFor="certification_file"> (PDF)</Label>
<div className="mt-1.5">
<div className="mt-1.5 space-y-2">
{/* 기존 파일 표시 (edit 모드) */}
{mode === 'edit' && existingCertificationFile && !certificationFile && (
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
<FileText className="h-4 w-4 text-green-600" />
<span className="text-sm flex-1 truncate">{existingCertificationFileName}</span>
<a
href={getStorageUrl(existingCertificationFile) || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
>
<Download className="h-3.5 w-3.5" />
</a>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDeleteFile('certification')}
disabled={isDeletingFile === 'certification' || isSubmitting}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
{/* 새 파일 업로드 */}
<Input
id="certification_file"
type="file"
@@ -1522,7 +1775,7 @@ export default function DynamicItemForm({
className="cursor-pointer"
/>
{certificationFile && (
<p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-muted-foreground">
: {certificationFile.name}
</p>
)}

View File

@@ -134,14 +134,24 @@ export type DynamicFormErrors = Record<string, string>;
// 컴포넌트 Props 타입
// ============================================
/**
* 품목 저장 결과 (파일 업로드에 필요한 ID 포함)
*/
export interface ItemSaveResult {
id: number;
[key: string]: unknown;
}
/**
* DynamicItemForm 메인 컴포넌트 Props
*/
export interface DynamicItemFormProps {
mode: 'create' | 'edit';
itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
itemType?: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
itemId?: number; // edit 모드에서 파일 업로드에 사용
initialData?: DynamicFormData;
onSubmit: (data: DynamicFormData) => Promise<void>;
/** 품목 저장 후 결과 반환 (create: id 필수, edit: id 선택) */
onSubmit: (data: DynamicFormData) => Promise<ItemSaveResult | void>;
}
/**