Files
sam-react-prod/src/app/[locale]/(protected)/items/[id]/edit/page.tsx
byeongcheolryu c6b605200d feat: 신규 페이지 구현 및 HR/설정 기능 개선
신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 19:12:34 +09:00

444 lines
18 KiB
TypeScript

/**
* 품목 수정 페이지
*
* API 연동:
* - GET /api/proxy/items/{id} (품목 조회 - id 기반 통일)
* - PUT /api/proxy/items/{id} (품목 수정)
*/
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import DynamicItemForm from '@/components/items/DynamicItemForm';
import type { DynamicFormData, BOMLine } from '@/components/items/DynamicItemForm/types';
import type { ItemType } from '@/types/item';
import { Loader2 } from 'lucide-react';
import {
isMaterialType,
transformMaterialDataForSave,
} from '@/lib/utils/materialTransform';
import { DuplicateCodeError } from '@/lib/api/error-handler';
/**
* API 응답 타입 (백엔드 Product 모델 기준)
*
* 백엔드 필드명: code, name, product_type (item_code, item_name, item_type 아님!)
*/
interface ItemApiResponse {
id: number;
// 백엔드 Product 모델 필드
code: string;
name: string;
product_type: string;
// 기존 필드도 fallback으로 유지
item_code?: string;
item_name?: string;
item_type?: string;
unit?: string;
specification?: string;
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;
length?: string;
thickness?: string;
installation_type?: string;
assembly_type?: string;
assembly_length?: string;
side_spec_width?: string;
side_spec_height?: string;
product_category?: string;
lot_abbreviation?: string;
certification_number?: string;
certification_start_date?: string;
certification_end_date?: string;
[key: string]: unknown;
}
/**
* API 응답을 DynamicFormData로 변환
*
* 2025-12-10: field_key 통일로 변환 로직 간소화
* - 백엔드에서 주는 field_key 그대로 사용 (변환 불필요)
* - 기존 레거시 데이터(98_unit 형식)도 그대로 동작
* - 신규 데이터(unit 형식)도 그대로 동작
*/
function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData {
const formData: DynamicFormData = {};
// 제외할 시스템 필드 (프론트엔드 폼에서 사용하지 않는 필드)
const excludeKeys = [
'id', 'tenant_id', 'category_id', 'category',
'created_at', 'updated_at', 'deleted_at',
'component_lines', 'bom',
'details', // details는 아래에서 펼쳐서 추가
];
// 백엔드 응답의 모든 필드를 그대로 복사
Object.entries(data).forEach(([key, value]) => {
if (!excludeKeys.includes(key) && value !== null && value !== undefined) {
formData[key] = value as DynamicFormData[string];
}
});
// details 객체가 있으면 펼쳐서 추가 (item_details 테이블 필드)
// 2025-12-16: details 내의 최신 값을 최상위로 매핑
const details = (data as Record<string, unknown>).details as Record<string, unknown> | undefined;
if (details && typeof details === 'object') {
const detailExcludeKeys = ['id', 'item_id', 'created_at', 'updated_at'];
Object.entries(details).forEach(([key, value]) => {
if (!detailExcludeKeys.includes(key) && value !== null && value !== undefined) {
formData[key] = value as DynamicFormData[string];
}
});
}
// attributes 객체가 있으면 펼쳐서 추가 (조립부품 등의 동적 필드)
const attributes = (data.attributes || {}) as Record<string, unknown>;
Object.entries(attributes).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
// 이미 있는 필드는 덮어쓰지 않음
if (!(key in formData)) {
formData[key] = value as DynamicFormData[string];
}
}
});
// 2025-12-16: options 매핑 로직 제거
// options는 백엔드가 품목기준관리 field_key 매핑용으로 내부적으로 사용하는 필드
// 프론트엔드는 백엔드가 정제해서 주는 필드(name, code, unit 등)만 사용
// options 내부 값을 직접 파싱하면 오래된 값과 최신 값이 꼬이는 버그 발생
// is_active 기본값 처리
if (formData['is_active'] === undefined) {
formData['is_active'] = true;
}
console.log('[EditItem] mapApiResponseToFormData 결과:', formData);
return formData;
}
export default function EditItemPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const [itemId, setItemId] = useState<number | null>(null);
const [itemType, setItemType] = useState<ItemType | null>(null);
const [initialData, setInitialData] = useState<DynamicFormData | null>(null);
const [initialBomLines, setInitialBomLines] = useState<BOMLine[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// URL에서 type과 id 쿼리 파라미터 읽기
const urlItemType = searchParams.get('type') || 'FG';
const urlItemId = searchParams.get('id');
// 품목 데이터 로드
useEffect(() => {
const fetchItem = async () => {
if (!params.id || typeof params.id !== 'string') {
setError('잘못된 품목 ID입니다.');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const itemCode = decodeURIComponent(params.id);
// console.log('[EditItem] Fetching item:', { itemCode, urlItemType, urlItemId });
let response: Response;
// 모든 품목: GET /api/proxy/items/{id} (id 기반 통일)
if (!urlItemId) {
setError('품목 ID가 없습니다.');
setIsLoading(false);
return;
}
// 2025-12-15: 백엔드에서 id만으로 조회 가능 (item_type 불필요)
const isMaterial = isMaterialType(urlItemType);
const queryParams = new URLSearchParams();
if (!isMaterial) {
queryParams.append('include_bom', 'true');
}
console.log('[EditItem] Fetching:', { urlItemId, urlItemType, isMaterial });
const queryString = queryParams.toString();
response = await fetch(`/api/proxy/items/${urlItemId}${queryString ? `?${queryString}` : ''}`);
if (!response.ok) {
if (response.status === 404) {
setError('품목을 찾을 수 없습니다.');
} else {
const errorData = await response.json().catch(() => null);
setError(errorData?.message || `오류 발생 (${response.status})`);
}
setIsLoading(false);
return;
}
const result = await response.json();
// console.log('[EditItem] API Response:', result);
if (result.success && result.data) {
const apiData = result.data as ItemApiResponse;
console.log('========== [EditItem] API 원본 데이터 (백엔드 응답) ==========');
console.log('id:', apiData.id);
console.log('specification:', apiData.specification);
console.log('unit:', apiData.unit);
console.log('is_active:', apiData.is_active);
console.log('files:', (apiData as any).files); // 파일 데이터 확인
console.log('전체:', apiData);
console.log('==============================================================');
// ID, 품목 유형 저장
// Product: product_type, Material: material_type 또는 type_code
setItemId(apiData.id);
const resolvedItemType = apiData.product_type || (apiData as Record<string, unknown>).material_type || (apiData as Record<string, unknown>).type_code || apiData.item_type;
// console.log('[EditItem] Resolved itemType:', resolvedItemType);
setItemType(resolvedItemType as ItemType);
// 폼 데이터로 변환
const formData = mapApiResponseToFormData(apiData);
console.log('========== [EditItem] 폼에 전달되는 initialData ==========');
console.log('specification:', formData['specification']);
console.log('unit:', formData['unit']);
console.log('is_active:', formData['is_active']);
console.log('files:', formData['files']); // 파일 데이터 확인
console.log('전체:', formData);
console.log('==========================================================');
setInitialData(formData);
// BOM 데이터 별도 API 호출 (expandBomItems로 품목 정보 포함)
// GET /api/proxy/items/{id}/bom - 품목 정보가 확장된 BOM 데이터 반환
if (!isMaterialType(urlItemType)) {
try {
const bomResponse = await fetch(`/api/proxy/items/${urlItemId}/bom`);
const bomResult = await bomResponse.json();
if (bomResult.success && bomResult.data && Array.isArray(bomResult.data)) {
const expandedBomData = bomResult.data as Array<Record<string, unknown>>;
const mappedBomLines: BOMLine[] = expandedBomData.map((b, index) => ({
id: (b.id as string) || `bom-${Date.now()}-${index}`,
childItemId: b.child_item_id ? String(b.child_item_id) : undefined,
childItemType: (b.child_item_type as 'PRODUCT' | 'MATERIAL') || 'PRODUCT',
childItemCode: (b.child_item_code as string) || '',
childItemName: (b.child_item_name as string) || '',
specification: (b.specification as string) || '',
material: (b.material as string) || '',
quantity: (b.quantity as number) ?? 1,
unit: (b.unit as string) || 'EA',
unitPrice: (b.unit_price as number) ?? 0,
note: (b.note as string) || '',
isBending: (b.is_bending as boolean) ?? false,
bendingDiagram: (b.bending_diagram as string) || undefined,
}));
setInitialBomLines(mappedBomLines);
console.log('[EditItem] BOM 데이터 로드 (expanded):', mappedBomLines.length, '건', mappedBomLines);
}
} catch (bomErr) {
console.error('[EditItem] BOM 조회 실패:', bomErr);
}
}
} else {
setError(result.message || '품목 정보를 불러올 수 없습니다.');
}
} catch (err) {
console.error('[EditItem] Error:', err);
setError('품목 정보를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
fetchItem();
}, [params.id, urlItemType, urlItemId]);
/**
* 품목 수정 제출 핸들러
*
* API 엔드포인트:
* - Products (FG, PT): PUT /api/proxy/items/{id}
* - Materials (SM, RM, CS): PATCH /api/proxy/products/materials/{id}
*
* 주의: 리다이렉트는 DynamicItemForm에서 처리하므로 여기서는 API 호출만 수행
*/
const handleSubmit = async (data: DynamicFormData) => {
if (!itemId) {
throw new Error('품목 ID가 없습니다.');
}
// console.log('[EditItem] Submitting update:', { itemId, itemType, data });
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용
// Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용
const isMaterial = isMaterialType(itemType);
// 디버깅: material_code 생성 관련 변수 확인 (필요 시 주석 해제)
// console.log('========== [EditItem] handleSubmit 디버깅 ==========');
// console.log('itemType:', itemType, 'isMaterial:', isMaterial);
// console.log('data:', JSON.stringify(data, null, 2));
// console.log('====================================================');
// 2025-12-15: 백엔드 동적 테이블 라우팅 - 모든 품목이 /items 엔드포인트 사용
// /products/materials 라우트 삭제됨 (products/materials 테이블 삭제)
const updateUrl = `/api/proxy/items/${itemId}?item_type=${itemType}`;
const method = 'PUT';
// console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')');
// 품목코드 자동생성 처리
// - FG(제품): 품목코드 = 품목명
// - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙)
// - Material(SM, RM, CS): material_code = 품목명-규격
// 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation)
let submitData = { ...data, item_type: itemType };
if (itemType === 'FG') {
// FG는 품목명이 품목코드가 되므로 name 값으로 code 설정
submitData.code = submitData.name;
} 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) {
submitData.specification = submitData.spec;
delete submitData.spec;
}
if (isMaterial) {
// 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 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
// bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨)
if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && submitData.bending_diagram.startsWith('data:')) {
delete submitData.bending_diagram;
}
// 시방서/인정서 파일 필드도 base64면 제거
if (submitData.specification_file && typeof submitData.specification_file === 'string' && submitData.specification_file.startsWith('data:')) {
delete submitData.specification_file;
}
if (submitData.certification_file && typeof submitData.certification_file === 'string' && submitData.certification_file.startsWith('data:')) {
delete submitData.certification_file;
}
// API 호출
console.log('========== [EditItem] 수정 요청 데이터 ==========');
console.log('URL:', updateUrl);
console.log('Method:', method);
console.log('specification:', submitData.specification);
console.log('unit:', submitData.unit);
console.log('is_active:', submitData.is_active);
console.log('전체:', submitData);
console.log('=================================================');
const response = await fetch(updateUrl, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submitData),
});
const result = await response.json();
// console.log('========== [EditItem] PUT 응답 ==========');
// console.log('Response:', JSON.stringify(result, null, 2));
// console.log('==========================================');
if (!response.ok || !result.success) {
// 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException)
// duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시
if (response.status === 400 && result.duplicate_id) {
console.warn('[EditItem] 품목코드 중복 에러:', result);
throw new DuplicateCodeError(
result.message || '해당 품목코드가 이미 존재합니다.',
result.duplicate_id,
result.duplicate_code
);
}
throw new Error(result.message || '품목 수정에 실패했습니다.');
}
// 성공 시 품목 ID 반환 (파일 업로드용)
return { id: itemId, ...result.data };
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground"> ...</p>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-destructive">{error}</p>
<button
onClick={() => router.push('/items')}
className="text-primary hover:underline"
>
</button>
</div>
);
}
// 데이터 없음
if (!itemType || !initialData) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-muted-foreground"> .</p>
<button
onClick={() => router.push('/items')}
className="text-primary hover:underline"
>
</button>
</div>
);
}
return (
<div className="p-6">
<DynamicItemForm
mode="edit"
itemType={itemType}
itemId={itemId ?? undefined}
initialData={initialData}
initialBomLines={initialBomLines}
onSubmit={handleSubmit}
/>
</div>
);
}