feat: 품목 관리 및 마스터 데이터 관리 시스템 구현

주요 기능:
- 품목 CRUD 기능 (생성, 조회, 수정)
- 품목 마스터 데이터 관리 시스템
- BOM(Bill of Materials) 관리 기능
- 도면 캔버스 기능
- 품목 속성 및 카테고리 관리
- 스크린 인쇄 생산 관리 페이지

기술 개선:
- localStorage SSR 호환성 수정 (9개 useState 초기화)
- Shadcn UI 컴포넌트 추가 (table, tabs, alert, drawer 등)
- DataContext 및 DeveloperModeContext 추가
- API 라우트 구현 (items, master-data)
- 타입 정의 및 유틸리티 함수 추가

빌드 테스트:  성공 (3.1초)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-18 14:17:52 +09:00
parent 21edc932d9
commit 63f5df7d7d
56 changed files with 23927 additions and 149 deletions

469
src/lib/api/items.ts Normal file
View File

@@ -0,0 +1,469 @@
/**
* 품목 관리 API 클라이언트
*
* Laravel 백엔드와 통신하는 API 함수들
* Next.js 15 Server Components와 Client Components 모두에서 사용 가능
*/
import type {
ItemMaster,
CreateItemData,
UpdateItemData,
FetchItemsParams,
ApiResponse,
PaginatedResponse,
BOMLine,
} from '@/types/item';
// ===== 환경 변수 =====
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// ===== 유틸리티 함수 =====
/**
* 인증 토큰 가져오기
* TODO: 실제 인증 구현에 맞게 수정 필요
*/
function getAuthToken(): string | null {
// Server Component에서는 쿠키에서 자동으로
// Client Component에서는 localStorage 또는 쿠키에서
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
}
/**
* Fetch 옵션 생성
*/
function createFetchOptions(options: RequestInit = {}): RequestInit {
const token = getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
...options,
headers,
credentials: 'include', // 쿠키 포함
};
}
/**
* API 에러 처리
*/
async function handleApiResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({
message: 'API 요청 실패',
}));
throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
}
// ===== 품목 CRUD =====
/**
* 품목 목록 조회
*
* @example
* // Server Component에서
* const items = await fetchItems({ itemType: 'FG' });
*
* // Client Component에서
* const [items, setItems] = useState([]);
* useEffect(() => {
* fetchItems().then(setItems);
* }, []);
*/
export async function fetchItems(
params?: FetchItemsParams
): Promise<ItemMaster[]> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
}
const url = `${API_URL}/api/items${queryParams.toString() ? `?${queryParams}` : ''}`;
const response = await fetch(url, createFetchOptions());
const data = await handleApiResponse<ApiResponse<ItemMaster[]>>(response);
return data.data;
}
/**
* 품목 목록 조회 (페이지네이션)
*/
export async function fetchItemsPaginated(
params?: FetchItemsParams
): Promise<PaginatedResponse<ItemMaster>> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
}
const url = `${API_URL}/api/items/paginated${queryParams.toString() ? `?${queryParams}` : ''}`;
const response = await fetch(url, createFetchOptions());
const data = await handleApiResponse<ApiResponse<PaginatedResponse<ItemMaster>>>(response);
return data.data;
}
/**
* 품목 상세 조회
*
* @param itemCode - 품목 코드 (예: "KD-FG-001")
*
* @example
* const item = await fetchItemByCode('KD-FG-001');
*/
export async function fetchItemByCode(itemCode: string): Promise<ItemMaster> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
return data.data;
}
/**
* 품목 등록
*
* @example
* const newItem = await createItem({
* itemCode: 'KD-FG-001',
* itemName: '스크린 제품',
* itemType: 'FG',
* unit: 'EA',
* });
*/
export async function createItem(
itemData: CreateItemData
): Promise<ItemMaster> {
const response = await fetch(
`${API_URL}/api/items`,
createFetchOptions({
method: 'POST',
body: JSON.stringify(itemData),
})
);
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
return data.data;
}
/**
* 품목 수정
*
* @param itemCode - 품목 코드
* @param updates - 수정할 필드들
*
* @example
* const updatedItem = await updateItem('KD-FG-001', {
* itemName: '스크린 제품 (수정)',
* salesPrice: 150000,
* });
*/
export async function updateItem(
itemCode: string,
updates: UpdateItemData
): Promise<ItemMaster> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
createFetchOptions({
method: 'PUT',
body: JSON.stringify(updates),
})
);
const data = await handleApiResponse<ApiResponse<ItemMaster>>(response);
return data.data;
}
/**
* 품목 삭제
*
* @param itemCode - 품목 코드
*
* @example
* await deleteItem('KD-FG-001');
*/
export async function deleteItem(itemCode: string): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== BOM (자재명세서) 관리 =====
/**
* BOM 목록 조회
*
* @param itemCode - 상위 품목 코드
*/
export async function fetchBOM(itemCode: string): Promise<BOMLine[]> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<BOMLine[]>>(response);
return data.data;
}
/**
* BOM 계층구조 조회 (트리 형태)
*
* @param itemCode - 상위 품목 코드
*/
export async function fetchBOMTree(itemCode: string): Promise<any> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/tree`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<any>>(response);
return data.data;
}
/**
* BOM 라인 추가
*
* @param itemCode - 상위 품목 코드
* @param bomLine - BOM 라인 데이터
*/
export async function addBOMLine(
itemCode: string,
bomLine: Omit<BOMLine, 'id'>
): Promise<BOMLine> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom`,
createFetchOptions({
method: 'POST',
body: JSON.stringify(bomLine),
})
);
const data = await handleApiResponse<ApiResponse<BOMLine>>(response);
return data.data;
}
/**
* BOM 라인 수정
*
* @param itemCode - 상위 품목 코드
* @param lineId - BOM 라인 ID
* @param updates - 수정할 필드들
*/
export async function updateBOMLine(
itemCode: string,
lineId: string,
updates: Partial<BOMLine>
): Promise<BOMLine> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`,
createFetchOptions({
method: 'PUT',
body: JSON.stringify(updates),
})
);
const data = await handleApiResponse<ApiResponse<BOMLine>>(response);
return data.data;
}
/**
* BOM 라인 삭제
*
* @param itemCode - 상위 품목 코드
* @param lineId - BOM 라인 ID
*/
export async function deleteBOMLine(
itemCode: string,
lineId: string
): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/bom/${lineId}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== 파일 업로드 =====
/**
* 파일 업로드 (시방서, 인정서, 전개도 등)
*
* @param itemCode - 품목 코드
* @param file - 업로드할 파일
* @param fileType - 파일 유형 (specification, certification, bending_diagram)
*/
export async function uploadFile(
itemCode: string,
file: File,
fileType: 'specification' | 'certification' | 'bending_diagram'
): Promise<{ url: string; filename: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', fileType);
const token = getAuthToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files`,
{
method: 'POST',
headers,
body: formData,
credentials: 'include',
}
);
const data = await handleApiResponse<ApiResponse<{ url: string; filename: string }>>(response);
return data.data;
}
/**
* 파일 삭제
*
* @param itemCode - 품목 코드
* @param fileType - 파일 유형
*/
export async function deleteFile(
itemCode: string,
fileType: 'specification' | 'certification' | 'bending_diagram'
): Promise<void> {
const response = await fetch(
`${API_URL}/api/items/${encodeURIComponent(itemCode)}/files/${fileType}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<ApiResponse<null>>(response);
}
// ===== 검색 및 필터 =====
/**
* 품목 코드로 검색 (자동완성용)
*
* @param query - 검색어
* @param limit - 최대 결과 개수
*/
export async function searchItemCodes(
query: string,
limit: number = 10
): Promise<string[]> {
const response = await fetch(
`${API_URL}/api/items/search/codes?q=${encodeURIComponent(query)}&limit=${limit}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<string[]>>(response);
return data.data;
}
/**
* 품목명으로 검색 (자동완성용)
*
* @param query - 검색어
* @param limit - 최대 결과 개수
*/
export async function searchItemNames(
query: string,
limit: number = 10
): Promise<Array<{ itemCode: string; itemName: string }>> {
const response = await fetch(
`${API_URL}/api/items/search/names?q=${encodeURIComponent(query)}&limit=${limit}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<Array<{ itemCode: string; itemName: string }>>>(response);
return data.data;
}
// ===== 유틸리티 =====
/**
* 품목 코드 중복 체크
*
* @param itemCode - 체크할 품목 코드
* @returns 사용 가능 여부 (true: 사용 가능, false: 중복)
*/
export async function checkItemCodeAvailability(
itemCode: string
): Promise<boolean> {
const response = await fetch(
`${API_URL}/api/items/check/${encodeURIComponent(itemCode)}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<{ available: boolean }>>(response);
return data.data.available;
}
/**
* 다음 품목 코드 생성 (서버에서 자동 생성)
*
* @param itemType - 품목 유형
* @returns 생성된 품목 코드 (예: "KD-FG-001")
*/
export async function generateItemCode(
itemType: string
): Promise<string> {
const response = await fetch(
`${API_URL}/api/items/generate-code?itemType=${itemType}`,
createFetchOptions()
);
const data = await handleApiResponse<ApiResponse<{ itemCode: string }>>(response);
return data.data.itemCode;
}
/**
* Next.js revalidation 트리거
* Server Component에서 데이터 변경 시 호출
*/
export async function revalidateItems(): Promise<void> {
if (typeof window === 'undefined') {
// Server Component에서만 실행
const { revalidatePath } = await import('next/cache');
revalidatePath('/items');
}
}

439
src/lib/api/master-data.ts Normal file
View File

@@ -0,0 +1,439 @@
/**
* 품목기준관리 API 클라이언트
*
* Laravel 백엔드와 통신하는 API 함수들
* 동적 페이지 구성, 버전 관리, 멀티테넌시 지원
*/
import type {
PageConfig,
PageConfigRevision,
PageType,
DynamicFormData,
FetchPageConfigParams,
MasterDataApiResponse,
VersionComparisonResult,
} from '@/types/master-data';
// ===== 환경 변수 =====
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// ===== 유틸리티 함수 =====
/**
* 인증 토큰 가져오기
*/
function getAuthToken(): string | null {
if (typeof window !== 'undefined') {
return localStorage.getItem('auth_token');
}
return null;
}
/**
* Fetch 옵션 생성
*/
function createFetchOptions(options: RequestInit = {}): RequestInit {
const token = getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
...options,
headers,
credentials: 'include',
};
}
/**
* API 에러 처리
*/
async function handleApiResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({
message: 'API 요청 실패',
}));
throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
}
// ===== 페이지 구성 CRUD =====
/**
* 페이지 구성 목록 조회
*
* @example
* const configs = await fetchPageConfigs({ pageType: 'item-master' });
*/
export async function fetchPageConfigs(
params?: FetchPageConfigParams
): Promise<PageConfig[]> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
}
const url = `${API_URL}/api/master-data/pages${queryParams.toString() ? `?${queryParams}` : ''}`;
const response = await fetch(url, createFetchOptions());
const data = await handleApiResponse<MasterDataApiResponse<PageConfig[]>>(response);
return data.data;
}
/**
* 특정 페이지 타입의 최신 구성 조회
*
* @param pageType - 페이지 타입
*
* @example
* const config = await fetchPageConfigByType('item-master');
*/
export async function fetchPageConfigByType(
pageType: PageType
): Promise<PageConfig | null> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${pageType}`,
createFetchOptions()
);
if (response.status === 404) {
return null;
}
const data = await handleApiResponse<MasterDataApiResponse<PageConfig>>(response);
return data.data;
}
/**
* 페이지 구성 상세 조회 (특정 버전)
*
* @param id - 페이지 구성 ID
* @param version - 버전 번호 (선택사항, 기본: 최신)
*
* @example
* const config = await fetchPageConfigById('uuid', 2);
*/
export async function fetchPageConfigById(
id: string,
version?: number
): Promise<PageConfig> {
const url = version
? `${API_URL}/api/master-data/pages/${id}?version=${version}`
: `${API_URL}/api/master-data/pages/${id}`;
const response = await fetch(url, createFetchOptions());
const data = await handleApiResponse<MasterDataApiResponse<PageConfig>>(response);
return data.data;
}
/**
* 페이지 구성 생성
*
* @param configData - 페이지 구성 데이터
*
* @example
* const newConfig = await createPageConfig({
* pageName: '품목기준정보',
* pageType: 'item-master',
* sections: [...],
* isActive: true,
* });
*/
export async function createPageConfig(
configData: Omit<PageConfig, 'id' | 'version' | 'createdAt' | 'updatedAt' | 'tenantId'>
): Promise<PageConfig> {
const response = await fetch(
`${API_URL}/api/master-data/pages`,
createFetchOptions({
method: 'POST',
body: JSON.stringify(configData),
})
);
const data = await handleApiResponse<MasterDataApiResponse<PageConfig>>(response);
return data.data;
}
/**
* 페이지 구성 수정
*
* @param id - 페이지 구성 ID
* @param updates - 수정할 필드들
*
* @example
* const updatedConfig = await updatePageConfig('uuid', {
* sections: [...],
* });
*/
export async function updatePageConfig(
id: string,
updates: Partial<PageConfig>
): Promise<PageConfig> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${id}`,
createFetchOptions({
method: 'PUT',
body: JSON.stringify(updates),
})
);
const data = await handleApiResponse<MasterDataApiResponse<PageConfig>>(response);
return data.data;
}
/**
* 페이지 구성 삭제
*
* @param id - 페이지 구성 ID
*/
export async function deletePageConfig(id: string): Promise<void> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${id}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<MasterDataApiResponse<null>>(response);
}
// ===== 버전 관리 =====
/**
* 페이지 구성 버전 이력 조회
*
* @param pageConfigId - 페이지 구성 ID
*
* @example
* const revisions = await fetchPageConfigRevisions('uuid');
*/
export async function fetchPageConfigRevisions(
pageConfigId: string
): Promise<PageConfigRevision[]> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${pageConfigId}/revisions`,
createFetchOptions()
);
const data = await handleApiResponse<MasterDataApiResponse<PageConfigRevision[]>>(response);
return data.data;
}
/**
* 특정 버전 상세 조회
*
* @param pageConfigId - 페이지 구성 ID
* @param version - 버전 번호
*/
export async function fetchPageConfigRevisionByVersion(
pageConfigId: string,
version: number
): Promise<PageConfigRevision> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${pageConfigId}/revisions/${version}`,
createFetchOptions()
);
const data = await handleApiResponse<MasterDataApiResponse<PageConfigRevision>>(response);
return data.data;
}
/**
* 버전 비교
*
* @param pageConfigId - 페이지 구성 ID
* @param version1 - 비교할 버전 1
* @param version2 - 비교할 버전 2
*
* @example
* const comparison = await comparePageConfigVersions('uuid', 1, 2);
*/
export async function comparePageConfigVersions(
pageConfigId: string,
version1: number,
version2: number
): Promise<VersionComparisonResult> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${pageConfigId}/compare?v1=${version1}&v2=${version2}`,
createFetchOptions()
);
const data = await handleApiResponse<MasterDataApiResponse<VersionComparisonResult>>(response);
return data.data;
}
/**
* 버전 승인
*
* @param revisionId - 버전 ID
*
* @example
* await approvePageConfigRevision('revision-uuid');
*/
export async function approvePageConfigRevision(
revisionId: string
): Promise<PageConfigRevision> {
const response = await fetch(
`${API_URL}/api/master-data/revisions/${revisionId}/approve`,
createFetchOptions({
method: 'POST',
})
);
const data = await handleApiResponse<MasterDataApiResponse<PageConfigRevision>>(response);
return data.data;
}
/**
* 특정 버전으로 롤백
*
* @param pageConfigId - 페이지 구성 ID
* @param version - 롤백할 버전
*/
export async function rollbackPageConfig(
pageConfigId: string,
version: number
): Promise<PageConfig> {
const response = await fetch(
`${API_URL}/api/master-data/pages/${pageConfigId}/rollback`,
createFetchOptions({
method: 'POST',
body: JSON.stringify({ version }),
})
);
const data = await handleApiResponse<MasterDataApiResponse<PageConfig>>(response);
return data.data;
}
// ===== 동적 폼 데이터 =====
/**
* 동적 폼 데이터 조회
*
* @param pageType - 페이지 타입
* @param id - 데이터 ID (선택사항)
*/
export async function fetchFormData(
pageType: PageType,
id?: string
): Promise<DynamicFormData | DynamicFormData[]> {
const url = id
? `${API_URL}/api/master-data/form-data/${pageType}/${id}`
: `${API_URL}/api/master-data/form-data/${pageType}`;
const response = await fetch(url, createFetchOptions());
const data = await handleApiResponse<MasterDataApiResponse<DynamicFormData | DynamicFormData[]>>(response);
return data.data;
}
/**
* 동적 폼 데이터 저장
*
* @param formData - 폼 데이터
*/
export async function saveFormData(
formData: DynamicFormData
): Promise<DynamicFormData> {
const response = await fetch(
`${API_URL}/api/master-data/form-data`,
createFetchOptions({
method: 'POST',
body: JSON.stringify(formData),
})
);
const data = await handleApiResponse<MasterDataApiResponse<DynamicFormData>>(response);
return data.data;
}
/**
* 동적 폼 데이터 수정
*
* @param id - 데이터 ID
* @param updates - 수정할 필드들
*/
export async function updateFormData(
id: string,
updates: Partial<DynamicFormData>
): Promise<DynamicFormData> {
const response = await fetch(
`${API_URL}/api/master-data/form-data/${id}`,
createFetchOptions({
method: 'PUT',
body: JSON.stringify(updates),
})
);
const data = await handleApiResponse<MasterDataApiResponse<DynamicFormData>>(response);
return data.data;
}
/**
* 동적 폼 데이터 삭제
*
* @param id - 데이터 ID
*/
export async function deleteFormData(id: string): Promise<void> {
const response = await fetch(
`${API_URL}/api/master-data/form-data/${id}`,
createFetchOptions({
method: 'DELETE',
})
);
await handleApiResponse<MasterDataApiResponse<null>>(response);
}
// ===== 캐싱 =====
/**
* 페이지 구성 캐시 무효화
*
* @param pageType - 페이지 타입 (선택사항, 전체 무효화 가능)
*/
export async function invalidatePageConfigCache(
pageType?: PageType
): Promise<void> {
const url = pageType
? `${API_URL}/api/master-data/cache/invalidate/${pageType}`
: `${API_URL}/api/master-data/cache/invalidate`;
const response = await fetch(
url,
createFetchOptions({
method: 'POST',
})
);
await handleApiResponse<MasterDataApiResponse<null>>(response);
}
/**
* Next.js revalidation 트리거
*/
export async function revalidateMasterData(): Promise<void> {
if (typeof window === 'undefined') {
const { revalidatePath } = await import('next/cache');
revalidatePath('/master-data');
}
}