refactor(WEB): 품목관리 경로 통합 - /items 삭제 및 /production/screen-production으로 일원화

- /items 폴더 삭제 (중복 경로 제거)
- /production/screen-production에 신버전 DynamicItemForm 기반 페이지 적용
- 구버전 ItemForm 연결 제거로 등록/수정 오류 해결
- 컴포넌트 내부 경로 참조 /items → /production/screen-production 변경
  - ItemListClient, ItemForm, ItemDetailClient, ItemDetailEdit, DynamicItemForm

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-20 11:34:59 +09:00
parent 36322a0927
commit 6f457b28f3
14 changed files with 333 additions and 1734 deletions

View File

@@ -1,32 +0,0 @@
'use client';
import { use, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
interface PageProps {
params: Promise<{ id: string }>;
}
/**
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
* 기존 쿼리 파라미터(type, id)는 유지
*/
export default function ItemEditPage({ params }: PageProps) {
const { id } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
// 기존 쿼리 파라미터 유지하면서 mode=edit 추가
const type = searchParams.get('type') || 'FG';
const itemId = searchParams.get('id') || '';
router.replace(`/items/${id}?type=${type}&id=${itemId}&mode=edit`);
}, [id, router, searchParams]);
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
'use client';
/**
* 품목 상세/수정 페이지 (Client Component)
* V2 패턴: ?mode=edit로 수정 모드 전환
*/
import { use } from 'react';
import { useSearchParams } from 'next/navigation';
import { ItemDetailView } from '@/components/items/ItemDetailView';
import { ItemDetailEdit } from '@/components/items/ItemDetailEdit';
interface PageProps {
params: Promise<{ id: string }>;
}
export default function ItemDetailPage({ params }: PageProps) {
const { id } = use(params);
const searchParams = useSearchParams();
// URL에서 type, id, mode 쿼리 파라미터 읽기
const itemType = searchParams.get('type') || 'FG';
const itemId = searchParams.get('id') || '';
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
// 품목 코드 디코딩
const itemCode = decodeURIComponent(id);
if (mode === 'edit') {
return <ItemDetailEdit itemCode={itemCode} itemType={itemType} itemId={itemId} />;
}
return <ItemDetailView itemCode={itemCode} itemType={itemType} itemId={itemId} />;
}

View File

@@ -1,101 +0,0 @@
/**
* 품목 등록 페이지
*
* DynamicItemForm을 사용하여 품목기준관리 데이터 기반 동적 폼 렌더링
*/
'use client';
import { useState } from 'react';
import DynamicItemForm from '@/components/items/DynamicItemForm';
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
// 2025-12-16: options 관련 변환 로직 제거
// 백엔드가 품목기준관리 field_key 매핑을 처리하므로 프론트에서 변환 불필요
import { DuplicateCodeError } from '@/lib/api/error-handler';
// 기존 ItemForm (주석처리 - 롤백 시 사용)
// import ItemForm from '@/components/items/ItemForm';
// import type { CreateItemFormData } from '@/lib/utils/validation';
export default function CreateItemPage() {
const [submitError, setSubmitError] = useState<string | null>(null);
const handleSubmit = async (data: DynamicFormData) => {
setSubmitError(null);
// 필드명 변환: spec → specification (백엔드 API 규격)
const submitData = { ...data };
if (submitData.spec !== undefined) {
submitData.specification = submitData.spec;
delete submitData.spec;
}
// 2025-12-15: item_type은 Request Body에서 필수 (ItemService.store validation)
// product_type과 item_type을 동일하게 설정
const itemType = submitData.product_type as string;
submitData.item_type = itemType;
// API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
// bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨)
if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && (submitData.bending_diagram as string).startsWith('data:')) {
delete submitData.bending_diagram;
}
// 시방서/인정서 파일 필드도 base64면 제거
if (submitData.specification_file && typeof submitData.specification_file === 'string' && (submitData.specification_file as string).startsWith('data:')) {
delete submitData.specification_file;
}
if (submitData.certification_file && typeof submitData.certification_file === 'string' && (submitData.certification_file as string).startsWith('data:')) {
delete submitData.certification_file;
}
// 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(submitData),
});
const result = await response.json();
if (!response.ok || !result.success) {
// 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException)
// duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시
if (response.status === 400 && result.duplicate_id) {
console.warn('[CreateItemPage] 품목코드 중복 에러:', result);
throw new DuplicateCodeError(
result.message || '해당 품목코드가 이미 존재합니다.',
result.duplicate_id,
result.duplicate_code
);
}
const errorMessage = result.message || '품목 등록에 실패했습니다.';
console.error('[CreateItemPage] 품목 등록 실패:', errorMessage);
setSubmitError(errorMessage);
throw new Error(errorMessage);
}
// 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨
// console.log('[CreateItemPage] 품목 등록 성공:', result.data);
// 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용)
return { id: result.data.id, ...result.data };
};
return (
<div className="p-6">
{submitError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
{submitError}
</div>
)}
<DynamicItemForm
mode="create"
onSubmit={handleSubmit}
/>
</div>
);
}

View File

@@ -1,24 +0,0 @@
/**
* 품목 관리 페이지
*
* 품목기준관리 API 연동
* - 품목 목록: API에서 조회
* - 테이블 컬럼: custom-tabs API에서 동적 구성
*/
import ItemListClient from '@/components/items/ItemListClient';
/**
* 품목 목록 페이지
*/
export default function ItemsPage() {
return <ItemListClient />;
}
/**
* 메타데이터 설정
*/
export const metadata = {
title: '품목 관리',
description: '품목 목록 조회 및 관리',
};

View File

@@ -1,213 +1,32 @@
/**
* 품목 수정 페이지
*/
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import ItemForm from '@/components/items/ItemForm';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import type { ItemMaster } from '@/types/item';
import type { CreateItemFormData } from '@/lib/utils/validation';
import { use, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
// Mock 데이터 (API 연동 전 임시)
const mockItems: ItemMaster[] = [
{
id: '1',
itemCode: 'KD-FG-001',
itemName: '스크린 제품 A',
itemType: 'FG',
unit: 'EA',
specification: '2000x2000',
isActive: true,
category1: '본체부품',
category2: '가이드시스템',
salesPrice: 150000,
purchasePrice: 100000,
marginRate: 33.3,
processingCost: 20000,
laborCost: 15000,
installCost: 10000,
productCategory: 'SCREEN',
lotAbbreviation: 'KD',
note: '스크린 제품 샘플입니다.',
safetyStock: 10,
leadTime: 7,
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
updatedAt: '2025-01-12T00:00:00Z',
bom: [
{
id: 'bom-1',
childItemCode: 'KD-PT-001',
childItemName: '가이드레일(벽면형)',
quantity: 2,
unit: 'EA',
unitPrice: 35000,
quantityFormula: 'H / 1000',
},
{
id: 'bom-2',
childItemCode: 'KD-PT-002',
childItemName: '절곡품 샘플',
quantity: 4,
unit: 'EA',
unitPrice: 30000,
isBending: true,
},
{
id: 'bom-3',
childItemCode: 'KD-SM-001',
childItemName: '볼트 M6x20',
quantity: 20,
unit: 'EA',
unitPrice: 50,
},
],
},
{
id: '2',
itemCode: 'KD-PT-001',
itemName: '가이드레일(벽면형)',
itemType: 'PT',
unit: 'EA',
specification: '2438mm',
isActive: true,
category1: '본체부품',
category2: '가이드시스템',
category3: '가이드레일',
salesPrice: 50000,
purchasePrice: 35000,
marginRate: 30,
partType: 'ASSEMBLY',
partUsage: 'GUIDE_RAIL',
installationType: '벽면형',
assemblyType: 'M',
assemblyLength: '2438',
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
{
id: '3',
itemCode: 'KD-PT-002',
itemName: '절곡품 샘플',
itemType: 'PT',
unit: 'EA',
specification: 'EGI 1.55T',
isActive: true,
partType: 'BENDING',
material: 'EGI 1.55T',
length: '2000',
salesPrice: 30000,
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
{
id: '4',
itemCode: 'KD-RM-001',
itemName: 'SPHC-SD',
itemType: 'RM',
unit: 'KG',
specification: '1.6T x 1219 x 2438',
isActive: true,
category1: '철강재',
purchasePrice: 1500,
material: 'SPHC-SD',
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
{
id: '5',
itemCode: 'KD-SM-001',
itemName: '볼트 M6x20',
itemType: 'SM',
unit: 'EA',
specification: 'M6x20',
isActive: true,
category1: '구조재/부속품',
category2: '볼트/너트',
purchasePrice: 50,
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
];
interface PageProps {
params: Promise<{ id: string }>;
}
export default function EditItemPage() {
const params = useParams();
/**
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
* 기존 쿼리 파라미터(type, id)는 유지
*/
export default function ItemEditPage({ params }: PageProps) {
const { id } = use(params);
const router = useRouter();
const [item, setItem] = useState<ItemMaster | null>(null);
const [isLoading, setIsLoading] = useState(true);
const searchParams = useSearchParams();
useEffect(() => {
// TODO: API 연동 시 fetchItemByCode() 호출
const fetchItem = async () => {
setIsLoading(true);
try {
// params.id 타입 체크
if (!params.id || typeof params.id !== 'string') {
alert('잘못된 품목 ID입니다.');
router.push('/items');
return;
}
// 기존 쿼리 파라미터 유지하면서 mode=edit 추가
const type = searchParams.get('type') || 'FG';
const itemId = searchParams.get('id') || '';
// Mock: 데이터 조회
const itemCode = decodeURIComponent(params.id);
const foundItem = mockItems.find((item) => item.itemCode === itemCode);
if (foundItem) {
setItem(foundItem);
} else {
alert('품목을 찾을 수 없습니다.');
router.push('/items');
}
} catch {
alert('품목 조회에 실패했습니다.');
router.push('/items');
} finally {
setIsLoading(false);
}
};
fetchItem();
}, [params.id, router]);
const handleSubmit = async (data: CreateItemFormData) => {
// TODO: API 연동 시 updateItem() 호출
console.log('품목 수정 데이터:', data);
// Mock: 성공 메시지
alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 수정되었습니다.`);
// API 연동 예시:
// const updatedItem = await updateItem(item.itemCode, data);
// router.push(`/items/${updatedItem.itemCode}`);
};
if (isLoading) {
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
}
if (!item) {
return (
<ServerErrorPage
title="품목 정보를 불러올 수 없습니다"
message="품목을 찾을 수 없습니다."
showBackButton={true}
showHomeButton={true}
/>
);
}
router.replace(`/production/screen-production/${id}?type=${type}&id=${itemId}&mode=edit`);
}, [id, router, searchParams]);
return (
<div className="p-6">
<ItemForm mode="edit" initialData={item} onSubmit={handleSubmit} />
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
}

View File

@@ -1,183 +1,34 @@
'use client';
/**
* 품목 상세 조회 페이지 (Client Component)
* 품목 상세/수정 페이지 (Client Component)
* V2 패턴: ?mode=edit로 수정 모드 전환
*/
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import ItemDetailClient from '@/components/items/ItemDetailClient';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import type { ItemMaster } from '@/types/item';
import { use } from 'react';
import { useSearchParams } from 'next/navigation';
import { ItemDetailView } from '@/components/items/ItemDetailView';
import { ItemDetailEdit } from '@/components/items/ItemDetailEdit';
// Mock 데이터 (API 연동 전 임시)
const mockItems: ItemMaster[] = [
{
id: '1',
itemCode: 'KD-FG-001',
itemName: '스크린 제품 A',
itemType: 'FG',
unit: 'EA',
specification: '2000x2000',
isActive: true,
category1: '본체부품',
category2: '가이드시스템',
salesPrice: 150000,
purchasePrice: 100000,
marginRate: 33.3,
processingCost: 20000,
laborCost: 15000,
installCost: 10000,
productCategory: 'SCREEN',
lotAbbreviation: 'KD',
note: '스크린 제품 샘플입니다.',
safetyStock: 10,
leadTime: 7,
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
updatedAt: '2025-01-12T00:00:00Z',
bom: [
{
id: 'bom-1',
childItemCode: 'KD-PT-001',
childItemName: '가이드레일(벽면형)',
quantity: 2,
unit: 'EA',
unitPrice: 35000,
quantityFormula: 'H / 1000',
},
{
id: 'bom-2',
childItemCode: 'KD-PT-002',
childItemName: '절곡품 샘플',
quantity: 4,
unit: 'EA',
unitPrice: 30000,
isBending: true,
},
{
id: 'bom-3',
childItemCode: 'KD-SM-001',
childItemName: '볼트 M6x20',
quantity: 20,
unit: 'EA',
unitPrice: 50,
},
],
},
{
id: '2',
itemCode: 'KD-PT-001',
itemName: '가이드레일(벽면형)',
itemType: 'PT',
unit: 'EA',
specification: '2438mm',
isActive: true,
category1: '본체부품',
category2: '가이드시스템',
category3: '가이드레일',
salesPrice: 50000,
purchasePrice: 35000,
marginRate: 30,
partType: 'ASSEMBLY',
partUsage: 'GUIDE_RAIL',
installationType: '벽면형',
assemblyType: 'M',
assemblyLength: '2438',
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
{
id: '3',
itemCode: 'KD-PT-002',
itemName: '절곡품 샘플',
itemType: 'PT',
unit: 'EA',
specification: 'EGI 1.55T',
isActive: true,
partType: 'BENDING',
material: 'EGI 1.55T',
length: '2000',
salesPrice: 30000,
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
{
id: '4',
itemCode: 'KD-RM-001',
itemName: 'SPHC-SD',
itemType: 'RM',
unit: 'KG',
specification: '1.6T x 1219 x 2438',
isActive: true,
category1: '철강재',
purchasePrice: 1500,
material: 'SPHC-SD',
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
{
id: '5',
itemCode: 'KD-SM-001',
itemName: '볼트 M6x20',
itemType: 'SM',
unit: 'EA',
specification: 'M6x20',
isActive: true,
category1: '구조재/부속품',
category2: '볼트/너트',
purchasePrice: 50,
currentRevision: 0,
isFinal: false,
createdAt: '2025-01-10T00:00:00Z',
},
];
/**
* 품목 상세 페이지
*/
export default function ItemDetailPage({
params,
}: {
interface PageProps {
params: Promise<{ id: string }>;
}) {
}
export default function ItemDetailPage({ params }: PageProps) {
const { id } = use(params);
const router = useRouter();
const [item, setItem] = useState<ItemMaster | null>(null);
const [isLoading, setIsLoading] = useState(true);
const searchParams = useSearchParams();
useEffect(() => {
// API 연동 전 mock 데이터 사용
const foundItem = mockItems.find(
(item) => item.itemCode === decodeURIComponent(id)
);
setItem(foundItem || null);
setIsLoading(false);
}, [id]);
// URL에서 type, id, mode 쿼리 파라미터 읽기
const itemType = searchParams.get('type') || 'FG';
const itemId = searchParams.get('id') || '';
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
if (isLoading) {
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
// 품목 코드 디코딩
const itemCode = decodeURIComponent(id);
if (mode === 'edit') {
return <ItemDetailEdit itemCode={itemCode} itemType={itemType} itemId={itemId} />;
}
if (!item) {
return (
<ServerErrorPage
title="품목 정보를 불러올 수 없습니다"
message="품목을 찾을 수 없습니다."
showBackButton={true}
showHomeButton={true}
/>
);
}
return (
<div className="p-6">
<ItemDetailClient item={item} />
</div>
);
}
return <ItemDetailView itemCode={itemCode} itemType={itemType} itemId={itemId} />;
}

View File

@@ -1,28 +1,101 @@
/**
* 품목 등록 페이지
*
* DynamicItemForm을 사용하여 품목기준관리 데이터 기반 동적 폼 렌더링
*/
'use client';
import ItemForm from '@/components/items/ItemForm';
import type { CreateItemFormData } from '@/lib/utils/validation';
import { useState } from 'react';
import DynamicItemForm from '@/components/items/DynamicItemForm';
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
// 2025-12-16: options 관련 변환 로직 제거
// 백엔드가 품목기준관리 field_key 매핑을 처리하므로 프론트에서 변환 불필요
import { DuplicateCodeError } from '@/lib/api/error-handler';
// 기존 ItemForm (주석처리 - 롤백 시 사용)
// import ItemForm from '@/components/items/ItemForm';
// import type { CreateItemFormData } from '@/lib/utils/validation';
export default function CreateItemPage() {
const handleSubmit = async (data: CreateItemFormData) => {
// TODO: API 연동 시 createItem() 호출
console.log('품목 등록 데이터:', data);
const [submitError, setSubmitError] = useState<string | null>(null);
// Mock: 성공 메시지
alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 등록되었습니다.`);
const handleSubmit = async (data: DynamicFormData) => {
setSubmitError(null);
// API 연동 예시:
// const newItem = await createItem(data);
// router.push(`/items/${newItem.itemCode}`);
// 필드명 변환: spec → specification (백엔드 API 규격)
const submitData = { ...data };
if (submitData.spec !== undefined) {
submitData.specification = submitData.spec;
delete submitData.spec;
}
// 2025-12-15: item_type은 Request Body에서 필수 (ItemService.store validation)
// product_type과 item_type을 동일하게 설정
const itemType = submitData.product_type as string;
submitData.item_type = itemType;
// API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
// bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨)
if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && (submitData.bending_diagram as string).startsWith('data:')) {
delete submitData.bending_diagram;
}
// 시방서/인정서 파일 필드도 base64면 제거
if (submitData.specification_file && typeof submitData.specification_file === 'string' && (submitData.specification_file as string).startsWith('data:')) {
delete submitData.specification_file;
}
if (submitData.certification_file && typeof submitData.certification_file === 'string' && (submitData.certification_file as string).startsWith('data:')) {
delete submitData.certification_file;
}
// 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(submitData),
});
const result = await response.json();
if (!response.ok || !result.success) {
// 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException)
// duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시
if (response.status === 400 && result.duplicate_id) {
console.warn('[CreateItemPage] 품목코드 중복 에러:', result);
throw new DuplicateCodeError(
result.message || '해당 품목코드가 이미 존재합니다.',
result.duplicate_id,
result.duplicate_code
);
}
const errorMessage = result.message || '품목 등록에 실패했습니다.';
console.error('[CreateItemPage] 품목 등록 실패:', errorMessage);
setSubmitError(errorMessage);
throw new Error(errorMessage);
}
// 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨
// console.log('[CreateItemPage] 품목 등록 성공:', result.data);
// 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용)
return { id: result.data.id, ...result.data };
};
return (
<div className="p-6">
<ItemForm mode="create" onSubmit={handleSubmit} />
{submitError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
{submitError}
</div>
)}
<DynamicItemForm
mode="create"
onSubmit={handleSubmit}
/>
</div>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
/**
* 품목 목록 페이지 (Client Component)
* 품목 관리 페이지
*
* Next.js 15 App Router
* 품목기준관리 API 연동
* - 품목 목록: API에서 조회
* - 테이블 컬럼: custom-tabs API에서 동적 구성
*/
import ItemListClient from '@/components/items/ItemListClient';
@@ -13,4 +13,12 @@ import ItemListClient from '@/components/items/ItemListClient';
*/
export default function ItemsPage() {
return <ItemListClient />;
}
}
/**
* 메타데이터 설정
*/
export const metadata = {
title: '품목 관리',
description: '품목 목록 조회 및 관리',
};