feat: API 프록시 추가 및 품목기준관리 기능 개선

- HttpOnly 쿠키 기반 API 프록시 라우트 추가 (/api/proxy/[...path])
- 품목기준관리 컴포넌트 개선 (섹션, 필드, 다이얼로그)
- ItemMasterContext API 연동 강화
- mock-data 제거 및 실제 API 연동
- 문서 명명규칙 정리 ([TYPE-DATE] 형식)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-25 21:07:10 +09:00
parent 5b2f8adc87
commit 593644922a
37 changed files with 5897 additions and 3267 deletions

View File

@@ -2,38 +2,35 @@
// API 요청 시 자동으로 인증 헤더 추가
/**
* API 요청에 사용할 인증 헤더 생성
* API 요청에 사용할 헤더 생성 (프록시 패턴용)
* - Content-Type: application/json
* - X-API-KEY: 환경변수에서 로드
* - Authorization: Bearer 토큰 (쿠키에서 추출)
* - Accept: Laravel expectsJson() 체크용
*
* ⚠️ 중요: Authorization 헤더는 Next.js 프록시에서 서버사이드로 처리
* - HttpOnly 쿠키는 JavaScript로 읽을 수 없음 (보안)
* - /api/proxy/* 라우트가 서버에서 쿠키 읽어 Authorization 헤더 추가
*/
export const getAuthHeaders = (): HeadersInit => {
// TODO: 실제 프로젝트의 토큰 저장 방식에 맞춰 수정 필요
// 현재는 쿠키에서 'auth_token' 추출하는 방식
const token = typeof window !== 'undefined'
? document.cookie.split('; ').find(row => row.startsWith('auth_token='))?.split('=')[1]
: '';
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'Authorization': token ? `Bearer ${token}` : '',
};
};
/**
* Multipart/form-data 요청에 사용할 헤더 생성
* Multipart/form-data 요청에 사용할 헤더 생성 (프록시 패턴용)
* - Content-Type은 브라우저가 자동으로 설정 (boundary 포함)
* - X-API-KEY와 Authorization만 포함
* - X-API-KEY만 포함
*
* ⚠️ 중요: Authorization 헤더는 Next.js 프록시에서 서버사이드로 처리
* - /api/proxy/* 라우트가 서버에서 쿠키 읽어 Authorization 헤더 추가
*/
export const getMultipartHeaders = (): HeadersInit => {
const token = typeof window !== 'undefined'
? document.cookie.split('; ').find(row => row.startsWith('auth_token='))?.split('=')[1]
: '';
return {
'Accept': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'Authorization': token ? `Bearer ${token}` : '',
// Content-Type은 명시하지 않음 (multipart/form-data; boundary=... 자동 설정)
};
};
@@ -43,6 +40,7 @@ export const getMultipartHeaders = (): HeadersInit => {
*/
export const hasAuthToken = (): boolean => {
if (typeof window === 'undefined') return false;
const token = document.cookie.split('; ').find(row => row.startsWith('auth_token='))?.split('=')[1];
// ✅ access_token 쿠키 존재 여부 확인
const token = document.cookie.split('; ').find(row => row.startsWith('access_token='))?.split('=')[1];
return !!token;
};

View File

@@ -25,20 +25,12 @@ export const handleApiError = async (response: Response): Promise<never> => {
const data = await response.json().catch(() => ({}));
// 401 Unauthorized - 토큰 만료 또는 인증 실패
// ✅ 자동 리다이렉트 제거: 각 페이지에서 에러를 직접 처리하도록 변경
// 이를 통해 개발자가 Network 탭에서 에러를 확인할 수 있음
if (response.status === 401) {
// 로그인 페이지로 리다이렉트
if (typeof window !== 'undefined') {
// 현재 페이지 URL을 저장 (로그인 후 돌아오기 위함)
const currentPath = window.location.pathname + window.location.search;
sessionStorage.setItem('redirectAfterLogin', currentPath);
// 로그인 페이지로 이동
window.location.href = '/login?session=expired';
}
throw new ApiError(
401,
'인증이 만료되었습니다. 다시 로그인해주세요.',
data.message || '인증이 필요합니다. 로그인 상태를 확인해주세요.',
data.errors
);
}

View File

@@ -30,7 +30,10 @@ import { getAuthHeaders } from './auth-headers';
import { handleApiError } from './error-handler';
import { apiLogger } from './logger';
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.codebridge-x.com/api/v1';
// ✅ HttpOnly 쿠키 보안 유지: Next.js API 프록시 사용
// 프록시가 서버에서 쿠키를 읽어 백엔드로 전달
// Frontend: /api/proxy/* → Backend: /api/v1/*
const BASE_URL = '/api/proxy';
export const itemMasterApi = {
// ============================================

View File

@@ -39,9 +39,9 @@ function getAuthToken(): string | null {
*/
function createFetchOptions(options: RequestInit = {}): RequestInit {
const token = getAuthToken();
const headers: HeadersInit = {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
...(options.headers as Record<string, string>),
};
if (token) {
@@ -339,7 +339,7 @@ export async function uploadFile(
formData.append('type', fileType);
const token = getAuthToken();
const headers: HeadersInit = {};
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

View File

@@ -1,449 +0,0 @@
// API Mock 데이터
// 백엔드 API 준비 전 프론트엔드 개발용 Mock 데이터
import type {
ItemPageResponse,
ItemSectionResponse,
ItemFieldResponse,
BomItemResponse,
SectionTemplateResponse,
MasterFieldResponse,
InitResponse,
} from '@/types/item-master-api';
// ============================================
// Mock Pages
// ============================================
export const mockPages: ItemPageResponse[] = [
{
id: 1,
tenant_id: 1,
page_name: '완제품(FG)',
item_type: 'FG',
absolute_path: '/item-master/FG',
is_active: true,
sections: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
page_name: '반제품(PT)',
item_type: 'PT',
absolute_path: '/item-master/PT',
is_active: true,
sections: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
page_name: '원자재(RM)',
item_type: 'RM',
absolute_path: '/item-master/RM',
is_active: true,
sections: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Sections
// ============================================
export const mockSections: ItemSectionResponse[] = [
{
id: 1,
tenant_id: 1,
page_id: 1,
title: '기본 정보',
type: 'fields',
order_no: 1,
fields: [],
bomItems: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
page_id: 1,
title: 'BOM',
type: 'bom',
order_no: 2,
fields: [],
bomItems: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Fields
// ============================================
export const mockFields: ItemFieldResponse[] = [
{
id: 1,
tenant_id: 1,
section_id: 1,
field_name: '품목코드',
field_type: 'textbox',
order_no: 1,
is_required: true,
placeholder: '품목코드를 입력하세요',
default_value: null,
display_condition: null,
validation_rules: { maxLength: 50 },
options: null,
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
section_id: 1,
field_name: '품목명',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
default_value: null,
display_condition: null,
validation_rules: { maxLength: 100 },
options: null,
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
section_id: 1,
field_name: '단위',
field_type: 'dropdown',
order_no: 3,
is_required: true,
placeholder: '단위를 선택하세요',
default_value: null,
display_condition: null,
validation_rules: null,
options: ['EA', 'KG', 'L', 'M', 'SET'],
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 4,
tenant_id: 1,
section_id: 1,
field_name: '수량',
field_type: 'number',
order_no: 4,
is_required: false,
placeholder: '수량을 입력하세요',
default_value: '0',
display_condition: null,
validation_rules: { min: 0 },
options: null,
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock BOM Items
// ============================================
export const mockBomItems: BomItemResponse[] = [
{
id: 1,
tenant_id: 1,
section_id: 2,
item_code: 'RM-001',
item_name: '철판',
quantity: 5,
unit: 'KG',
unit_price: 10000,
total_price: 50000,
spec: 'SUS304 2T',
note: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
section_id: 2,
item_code: 'PT-001',
item_name: '플레이트',
quantity: 2,
unit: 'EA',
unit_price: 25000,
total_price: 50000,
spec: '200x200mm',
note: '표면처리 필요',
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Section Templates
// ============================================
export const mockSectionTemplates: SectionTemplateResponse[] = [
{
id: 1,
tenant_id: 1,
title: '기본정보 템플릿',
type: 'fields',
description: '품목 기본 정보 입력용 템플릿',
is_default: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
title: 'BOM 템플릿',
type: 'bom',
description: 'BOM 관리용 템플릿',
is_default: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Master Fields
// ============================================
export const mockMasterFields: MasterFieldResponse[] = [
{
id: 1,
tenant_id: 1,
field_name: '품목코드',
field_type: 'textbox',
category: 'basic',
description: '품목 고유 코드',
is_common: true,
default_value: null,
options: null,
validation_rules: { required: true, maxLength: 50 },
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
field_name: '품목명',
field_type: 'textbox',
category: 'basic',
description: '품목 명칭',
is_common: true,
default_value: null,
options: null,
validation_rules: { required: true, maxLength: 100 },
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
field_name: '단위',
field_type: 'dropdown',
category: 'basic',
description: '수량 단위',
is_common: true,
default_value: 'EA',
options: ['EA', 'KG', 'L', 'M', 'SET'],
validation_rules: { required: true },
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Init Response
// ============================================
export const mockInitResponse: InitResponse = {
pages: mockPages,
sections: mockSections,
fields: mockFields,
bom_items: mockBomItems,
section_templates: mockSectionTemplates,
master_fields: mockMasterFields,
custom_tabs: [
{
id: 1,
tenant_id: 1,
tab_name: '사용자 정의 탭',
item_type: 'FG',
order_no: 10,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
unit_options: [
{
id: 1,
tenant_id: 1,
option_type: 'unit',
option_value: 'EA',
display_name: '개',
order_no: 1,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
option_type: 'unit',
option_value: 'KG',
display_name: '킬로그램',
order_no: 2,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
option_type: 'unit',
option_value: 'L',
display_name: '리터',
order_no: 3,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
material_options: [
{
id: 4,
tenant_id: 1,
option_type: 'material',
option_value: 'SUS304',
display_name: '스테인리스 304',
order_no: 1,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 5,
tenant_id: 1,
option_type: 'material',
option_value: 'AL',
display_name: '알루미늄',
order_no: 2,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
surface_treatment_options: [
{
id: 6,
tenant_id: 1,
option_type: 'surface_treatment',
option_value: 'ANODIZING',
display_name: '아노다이징',
order_no: 1,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 7,
tenant_id: 1,
option_type: 'surface_treatment',
option_value: 'PAINTING',
display_name: '도장',
order_no: 2,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
};
// ============================================
// Mock 모드 활성화 플래그
// ============================================
/**
* Mock 모드 활성화 여부
* - true: Mock 데이터 사용 (백엔드 없이 프론트엔드 개발)
* - false: 실제 API 호출
*/
export const MOCK_MODE = process.env.NEXT_PUBLIC_MOCK_MODE === 'true';
/**
* Mock API 응답 시뮬레이션 (네트워크 지연 재현)
*/
export const simulateNetworkDelay = async (ms: number = 500) => {
if (!MOCK_MODE) return;
await new Promise((resolve) => setTimeout(resolve, ms));
};