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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package } from 'lucide-react';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -60,6 +60,22 @@ function formatItemCodeForAssembly(item: ItemMaster): string {
|
||||
return item.itemCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage 경로를 전체 URL로 변환
|
||||
* - 이미 전체 URL인 경우 그대로 반환
|
||||
* - 상대 경로인 경우 API URL + /storage/ 붙여서 반환
|
||||
*/
|
||||
function getStorageUrl(path: string | undefined): string | null {
|
||||
if (!path) return null;
|
||||
// 이미 전체 URL인 경우
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// 상대 경로인 경우
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${apiUrl}/storage/${path}`;
|
||||
}
|
||||
|
||||
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -339,6 +355,186 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 절곡품/조립품 전개도 정보 */}
|
||||
{item.itemType === 'PT' &&
|
||||
(item.partType === 'BENDING' || item.partType === 'ASSEMBLY') &&
|
||||
(item.bendingDiagram || (item.bendingDetails && item.bendingDetails.length > 0)) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
|
||||
<FileImage className="h-4 w-4 md:h-5 md:w-5" />
|
||||
{item.partType === 'ASSEMBLY' ? '조립품 전개도 (바라시)' : '절곡품 전개도 (바라시)'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-6 pt-0">
|
||||
{/* 전개도 이미지 */}
|
||||
{item.bendingDiagram ? (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">전개도 이미지</Label>
|
||||
<div className="mt-2 p-2 md:p-4 border rounded-lg bg-gray-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getStorageUrl(item.bendingDiagram) || ''}
|
||||
alt="전개도"
|
||||
className="max-w-full h-auto max-h-64 md:max-h-96 mx-auto border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 md:py-8 text-xs md:text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
등록된 전개도 이미지가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전개도 상세 데이터 */}
|
||||
{item.bendingDetails && item.bendingDetails.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">전개도 상세 데이터</Label>
|
||||
<div className="mt-2 overflow-x-auto bg-white rounded border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-100">
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">번호</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">입력</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">연신율</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">연신율계산후</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">합계</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">음영</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">A각</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{item.bendingDetails.map((detail, detailIndex) => {
|
||||
const calculated = detail.input + detail.elongation;
|
||||
let sum = 0;
|
||||
for (let i = 0; i <= detailIndex; i++) {
|
||||
const d = item.bendingDetails![i];
|
||||
sum += d.input + d.elongation;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={detail.id} className={detail.shaded ? "bg-gray-200" : ""}>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.no}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.input}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.elongation}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center bg-blue-50">{calculated}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center bg-green-50 font-medium">{sum}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">
|
||||
{detail.shaded ? <Check className="h-3 w-3 md:h-4 md:w-4 inline" /> : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.aAngle || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-2 p-2 md:p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-xs md:text-sm">
|
||||
<span className="font-medium">최종 전개 길이:</span>{" "}
|
||||
<span className="text-base md:text-lg font-bold text-blue-700">
|
||||
{item.bendingDetails.reduce((sum, d) => sum + d.input + d.elongation, 0)} mm
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 제품(FG) 인정 정보 및 첨부 파일 */}
|
||||
{item.itemType === 'FG' && (item.certificationNumber || item.specificationFile || item.certificationFile) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
|
||||
<FileText className="h-4 w-4 md:h-5 md:w-5" />
|
||||
인정 정보 및 첨부 파일
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-6 pt-0">
|
||||
{/* 인정 정보 */}
|
||||
{item.certificationNumber && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg border">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">인정번호</Label>
|
||||
<p className="mt-1 font-medium">{item.certificationNumber}</p>
|
||||
</div>
|
||||
{item.certificationStartDate && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
유효기간 시작
|
||||
</Label>
|
||||
<p className="mt-1">{new Date(item.certificationStartDate).toLocaleDateString('ko-KR')}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.certificationEndDate && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
유효기간 종료
|
||||
</Label>
|
||||
<p className="mt-1">{new Date(item.certificationEndDate).toLocaleDateString('ko-KR')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 첨부 파일 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 시방서 */}
|
||||
<div className="p-4 border rounded-lg">
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">시방서</Label>
|
||||
{item.specificationFile ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.specificationFileName || '시방서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.specificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 시방서가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인정서 */}
|
||||
<div className="p-4 border rounded-lg">
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">인정서</Label>
|
||||
{item.certificationFile ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.certificationFileName || '인정서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.certificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 인정서가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* BOM 정보 - 절곡 부품은 제외 */}
|
||||
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user