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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
// ============================================
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
Reference in New Issue
Block a user