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:
244
src/app/api/proxy/[...path]/route.ts
Normal file
244
src/app/api/proxy/[...path]/route.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
|
||||
*
|
||||
* ⚡ 설계 목적:
|
||||
* - HttpOnly 쿠키 보안 유지: JavaScript 접근 차단
|
||||
* - 모든 백엔드 API를 단일 프록시로 처리
|
||||
* - 서버에서 쿠키 읽어 Authorization 헤더 자동 추가
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/proxy/* (토큰 없이)
|
||||
* 2. Next.js: HttpOnly 쿠키에서 access_token 읽기 (서버에서만 가능)
|
||||
* 3. Next.js → PHP Backend /api/v1/* (Authorization 헤더 포함)
|
||||
* 4. PHP Backend → Next.js (응답)
|
||||
* 5. Next.js → 클라이언트 (응답 전달)
|
||||
*
|
||||
* 🔐 보안 특징:
|
||||
* - HttpOnly 쿠키: JavaScript 접근 불가 (XSS 방지)
|
||||
* - 서버 사이드 토큰 처리: 브라우저에 토큰 노출 안됨
|
||||
* - 자동 인증 헤더 추가: 클라이언트는 신경 쓸 필요 없음
|
||||
*
|
||||
* 📍 사용 예시:
|
||||
* - Frontend: fetch('/api/proxy/item-master/init')
|
||||
* - Backend: GET https://api.codebridge-x.com/api/v1/item-master/init
|
||||
*
|
||||
* ⚠️ 주의:
|
||||
* - 로그아웃 API(/api/auth/logout)와 동일한 패턴
|
||||
* - 모든 HTTP 메서드 지원 (GET, POST, PUT, DELETE)
|
||||
* - 쿼리 파라미터와 요청 바디 모두 전달
|
||||
*/
|
||||
|
||||
/**
|
||||
* 토큰 갱신 함수 (access_token 만료 시 refresh_token으로 갱신)
|
||||
*/
|
||||
async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
success: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('🔴 [PROXY] Token refresh failed');
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('✅ [PROXY] Token refreshed successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('🔴 [PROXY] Token refresh error:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all proxy handler for all HTTP methods
|
||||
*/
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
params: { path: string[] },
|
||||
method: string
|
||||
) {
|
||||
try {
|
||||
// 1. HttpOnly 쿠키에서 토큰 읽기 (서버에서만 가능!)
|
||||
let token = request.cookies.get('access_token')?.value;
|
||||
const refreshToken = request.cookies.get('refresh_token')?.value;
|
||||
|
||||
// 1-1. access_token이 없고 refresh_token이 있으면 자동 갱신
|
||||
let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null;
|
||||
if (!token && refreshToken) {
|
||||
console.log('🔄 [PROXY] No access_token, attempting refresh...');
|
||||
const refreshResult = await refreshAccessToken(refreshToken);
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
token = refreshResult.accessToken;
|
||||
newTokens = refreshResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 백엔드 URL 구성
|
||||
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`;
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const url = new URL(backendUrl);
|
||||
request.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE)
|
||||
let body: string | undefined;
|
||||
if (['POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
// Content-Type에 따라 바디 처리
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
body = await request.text();
|
||||
|
||||
// 🔍 디버깅: 전송 데이터 로그
|
||||
console.log('🔵 [PROXY DEBUG] Request Details:');
|
||||
console.log(' Method:', method);
|
||||
console.log(' URL:', url.toString());
|
||||
console.log(' Body:', body);
|
||||
console.log(' Token:', token ? `${token.substring(0, 20)}...` : 'null');
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
// FormData는 그대로 전달
|
||||
const formData = await request.formData();
|
||||
// FormData를 백엔드로 전달하기 위해 다시 변환
|
||||
body = await request.text();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 백엔드로 프록시 요청
|
||||
const backendResponse = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': request.headers.get('content-type') || 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// 5. 응답 데이터 읽기
|
||||
const responseData = await backendResponse.text();
|
||||
|
||||
// 🔍 디버깅: 백엔드 응답 로그
|
||||
console.log('🔵 [PROXY DEBUG] Backend Response:');
|
||||
console.log(' Status:', backendResponse.status);
|
||||
console.log(' Response:', responseData.substring(0, 500)); // 처음 500자만
|
||||
|
||||
// 6. 클라이언트로 응답 전달
|
||||
const clientResponse = new NextResponse(responseData, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
'Content-Type': backendResponse.headers.get('content-type') || 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 6-1. 토큰이 갱신되었으면 새 쿠키 설정
|
||||
if (newTokens && newTokens.accessToken) {
|
||||
const accessTokenCookie = [
|
||||
`access_token=${newTokens.accessToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
`Max-Age=${newTokens.expiresIn || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
clientResponse.headers.append('Set-Cookie', accessTokenCookie);
|
||||
|
||||
if (newTokens.refreshToken) {
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${newTokens.refreshToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
].join('; ');
|
||||
clientResponse.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
}
|
||||
|
||||
console.log('🍪 [PROXY] New tokens set in cookies');
|
||||
}
|
||||
|
||||
return clientResponse;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Proxy request error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Proxy server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 요청 프록시
|
||||
* Next.js 15: params는 Promise이므로 await 필요
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'GET');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청 프록시
|
||||
* Next.js 15: params는 Promise이므로 await 필요
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청 프록시
|
||||
* Next.js 15: params는 Promise이므로 await 필요
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청 프록시
|
||||
* Next.js 15: params는 Promise이므로 await 필요
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
return proxyRequest(request, resolvedParams, 'DELETE');
|
||||
}
|
||||
@@ -131,8 +131,8 @@ export function LoginPage() {
|
||||
} else {
|
||||
setError(error.message || t('invalidCredentials'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoggingIn(false); // ✅ 로그인 종료 (성공/실패 상관없이)
|
||||
|
||||
setIsLoggingIn(false); // ✅ 실패 시에만 버튼 재활성화 (성공 시 페이지 전환까지 비활성화 유지)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface BOMManagementSectionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
bomItems: BOMItem[];
|
||||
onAddItem: (item: Omit<BOMItem, 'id' | 'createdAt' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
|
||||
onAddItem: (item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
|
||||
onUpdateItem: (id: number, item: Partial<BOMItem>) => void;
|
||||
onDeleteItem: (id: number) => void;
|
||||
itemTypeOptions?: { value: string; label: string }[];
|
||||
@@ -30,17 +30,8 @@ export function BOMManagementSection({
|
||||
onAddItem,
|
||||
onUpdateItem,
|
||||
onDeleteItem,
|
||||
itemTypeOptions = [
|
||||
{ value: 'product', label: '제품' },
|
||||
{ value: 'part', label: '부품' },
|
||||
{ value: 'material', label: '원자재' },
|
||||
],
|
||||
unitOptions = [
|
||||
{ value: 'EA', label: 'EA' },
|
||||
{ value: 'KG', label: 'KG' },
|
||||
{ value: 'M', label: 'M' },
|
||||
{ value: 'L', label: 'L' },
|
||||
],
|
||||
itemTypeOptions = [],
|
||||
unitOptions = [],
|
||||
}: BOMManagementSectionProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import { ITEM_TYPE_LABELS, _PART_TYPE_LABELS, _PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
|
||||
import { ITEM_TYPE_LABELS, PART_TYPE_LABELS, PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemPage, ItemSection, ItemField, FieldDisplayCondition, ItemMasterField, ItemFieldProperty, SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemPage, ItemSection, ItemField, FieldDisplayCondition, ItemMasterField, ItemFieldProperty, SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
|
||||
import { MasterFieldTab, HierarchyTab, SectionsTab } from './ItemMasterDataManagement/tabs';
|
||||
import { FieldDialog } from './ItemMasterDataManagement/dialogs/FieldDialog';
|
||||
import { type ConditionalFieldConfig } from './ItemMasterDataManagement/components/ConditionalDisplayUI';
|
||||
@@ -82,6 +82,7 @@ const INPUT_TYPE_OPTIONS = [
|
||||
export function ItemMasterDataManagement() {
|
||||
const {
|
||||
itemPages,
|
||||
loadItemPages,
|
||||
addItemPage,
|
||||
updateItemPage,
|
||||
deleteItemPage,
|
||||
@@ -93,14 +94,17 @@ export function ItemMasterDataManagement() {
|
||||
deleteField,
|
||||
reorderFields,
|
||||
itemMasterFields,
|
||||
loadItemMasterFields,
|
||||
addItemMasterField,
|
||||
updateItemMasterField,
|
||||
deleteItemMasterField,
|
||||
sectionTemplates,
|
||||
loadSectionTemplates,
|
||||
addSectionTemplate,
|
||||
updateSectionTemplate,
|
||||
deleteSectionTemplate,
|
||||
resetAllData
|
||||
resetAllData,
|
||||
tenantId
|
||||
} = useItemMaster();
|
||||
|
||||
console.log('ItemMasterDataManagement: Current sectionTemplates', sectionTemplates);
|
||||
@@ -134,23 +138,17 @@ export function ItemMasterDataManagement() {
|
||||
|
||||
const data = await itemMasterApi.init();
|
||||
|
||||
// 페이지 데이터 로드 (context의 addItemPage 사용)
|
||||
data.pages.forEach(page => {
|
||||
const transformed = transformPagesResponse([page])[0];
|
||||
addItemPage(transformed);
|
||||
});
|
||||
// 페이지 데이터 로드 (이미 존재하는 데이터를 state에 로드 - API 호출 없음)
|
||||
const transformedPages = transformPagesResponse(data.pages);
|
||||
loadItemPages(transformedPages);
|
||||
|
||||
// 섹션 템플릿 로드
|
||||
data.sectionTemplates.forEach(template => {
|
||||
const transformed = transformSectionTemplatesResponse([template])[0];
|
||||
addSectionTemplate(transformed);
|
||||
});
|
||||
// 섹션 템플릿 로드 (덮어쓰기 - API 호출 없음!)
|
||||
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
|
||||
loadSectionTemplates(transformedTemplates);
|
||||
|
||||
// 마스터 필드 로드
|
||||
data.masterFields.forEach(field => {
|
||||
const transformed = transformMasterFieldsResponse([field])[0];
|
||||
addItemMasterField(transformed);
|
||||
});
|
||||
// 마스터 필드 로드 (덮어쓰기 - API 호출 없음!)
|
||||
const transformedFields = transformMasterFieldsResponse(data.masterFields);
|
||||
loadItemMasterFields(transformedFields);
|
||||
|
||||
// 커스텀 탭 로드 (local state)
|
||||
if (data.customTabs && data.customTabs.length > 0) {
|
||||
@@ -207,12 +205,8 @@ export function ItemMasterDataManagement() {
|
||||
|
||||
const [activeTab, setActiveTab] = useState('hierarchy');
|
||||
|
||||
// 속성 하위 탭 관리
|
||||
const [attributeSubTabs, setAttributeSubTabs] = useState<Array<{id: string; label: string; key: string; isDefault: boolean; order: number}>>([
|
||||
{ id: 'units', label: '단위', key: 'units', isDefault: true, order: 0 },
|
||||
{ id: 'materials', label: '재질', key: 'materials', isDefault: true, order: 1 },
|
||||
{ id: 'surface', label: '표면처리', key: 'surface', isDefault: true, order: 2 }
|
||||
]);
|
||||
// 속성 하위 탭 관리 (API에서 로드)
|
||||
const [attributeSubTabs, setAttributeSubTabs] = useState<Array<{id: string; label: string; key: string; isDefault: boolean; order: number}>>([]);
|
||||
|
||||
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
|
||||
useEffect(() => {
|
||||
@@ -344,7 +338,9 @@ export function ItemMasterDataManagement() {
|
||||
const [newSectionTitle, setNewSectionTitle] = useState('');
|
||||
const [newSectionDescription, setNewSectionDescription] = useState('');
|
||||
const [newSectionType, setNewSectionType] = useState<'fields' | 'bom'>('fields');
|
||||
|
||||
const [sectionInputMode, setSectionInputMode] = useState<'custom' | 'template'>('custom');
|
||||
const [selectedSectionTemplateId, setSelectedSectionTemplateId] = useState<number | null>(null);
|
||||
|
||||
// 모바일 체크
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -426,6 +422,10 @@ export function ItemMasterDataManagement() {
|
||||
const [templateFieldMultiColumn, setTemplateFieldMultiColumn] = useState(false);
|
||||
const [templateFieldColumnCount, setTemplateFieldColumnCount] = useState(2);
|
||||
const [templateFieldColumnNames, setTemplateFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
|
||||
// 템플릿 필드 마스터 항목 관련 상태
|
||||
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master'>('custom');
|
||||
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
|
||||
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
|
||||
|
||||
// BOM 관리 상태
|
||||
const [_bomItems, setBomItems] = useState<BOMItem[]>([]);
|
||||
@@ -433,6 +433,8 @@ export function ItemMasterDataManagement() {
|
||||
// 속성 변경 시 연동된 마스터 항목의 옵션 자동 업데이트
|
||||
useEffect(() => {
|
||||
itemMasterFields.forEach(field => {
|
||||
// default_properties가 null/undefined인 경우 스킵
|
||||
if (!field.default_properties) return;
|
||||
const attributeType = (field.default_properties as any).attributeType;
|
||||
if (attributeType && attributeType !== 'custom' && field.default_properties?.inputType === 'dropdown') {
|
||||
let newOptions: string[] = [];
|
||||
@@ -548,20 +550,19 @@ export function ItemMasterDataManagement() {
|
||||
setIsLoading(true);
|
||||
const absolutePath = generateAbsolutePath(newPageItemType, newPageName);
|
||||
|
||||
// API 호출
|
||||
const response = await itemMasterApi.pages.create({
|
||||
// Context의 addItemPage 사용 (API 호출 + state 업데이트)
|
||||
// ⚠️ 이전 코드는 여기서 API 호출 후 addItemPage도 호출해서 API가 2번 호출되는 버그가 있었음
|
||||
const newPage = await addItemPage({
|
||||
page_name: newPageName,
|
||||
item_type: newPageItemType,
|
||||
absolute_path: absolutePath,
|
||||
is_active: true,
|
||||
sections: [],
|
||||
order_no: 0,
|
||||
});
|
||||
|
||||
// 응답 변환 및 context에 추가
|
||||
const transformedPage = transformPageResponse(response);
|
||||
addItemPage(transformedPage);
|
||||
|
||||
// 새로 생성된 페이지를 선택
|
||||
setSelectedPageId(transformedPage.id);
|
||||
setSelectedPageId(newPage.id);
|
||||
|
||||
// 폼 초기화
|
||||
setNewPageName('');
|
||||
@@ -586,43 +587,39 @@ export function ItemMasterDataManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicatePage = (pageId: number) => {
|
||||
const handleDuplicatePage = async (pageId: number) => {
|
||||
const originalPage = itemPages.find(p => p.id === pageId);
|
||||
if (!originalPage) return toast.error('페이지를 찾을 수 없습니다');
|
||||
|
||||
// 섹션 인스턴스 깊은 복사 (새로운 ID 부여)
|
||||
const duplicatedSections = originalPage.sections.map(section => ({
|
||||
...section,
|
||||
id: Date.now(),
|
||||
fields: section.fields?.map(field => ({
|
||||
...field,
|
||||
id: Date.now()
|
||||
})) || [],
|
||||
bomItems: section.bomItems?.map(item => ({
|
||||
...item,
|
||||
id: Date.now()
|
||||
}))
|
||||
}));
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 페이지 복제
|
||||
const duplicatedPageName = `${originalPage.page_name} (복제)`;
|
||||
const absolutePath = generateAbsolutePath(originalPage.item_type, duplicatedPageName);
|
||||
const newPage: ItemPage = {
|
||||
id: Date.now(),
|
||||
page_name: duplicatedPageName,
|
||||
item_type: originalPage.item_type,
|
||||
sections: duplicatedSections,
|
||||
is_active: true,
|
||||
absolute_path: absolutePath,
|
||||
order_no: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
// 페이지 복제
|
||||
const duplicatedPageName = `${originalPage.page_name} (복제)`;
|
||||
const absolutePath = generateAbsolutePath(originalPage.item_type, duplicatedPageName);
|
||||
|
||||
// 페이지 추가
|
||||
addItemPage(newPage);
|
||||
setSelectedPageId(newPage.id);
|
||||
toast.success('페이지가 복제되었습니다 (저장 필요)');
|
||||
// Context의 addItemPage 사용 (API 호출 + state 업데이트)
|
||||
const newPage = await addItemPage({
|
||||
page_name: duplicatedPageName,
|
||||
item_type: originalPage.item_type,
|
||||
sections: [], // 섹션은 별도 API로 복제해야 함
|
||||
is_active: true,
|
||||
absolute_path: absolutePath,
|
||||
order_no: 0,
|
||||
});
|
||||
|
||||
// 서버에서 반환된 ID로 선택
|
||||
setSelectedPageId(newPage.id);
|
||||
toast.success('페이지가 복제되었습니다');
|
||||
|
||||
// TODO: 원본 페이지의 섹션들도 복제 필요 (별도 API 호출)
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
toast.error(errorMessage);
|
||||
console.error('❌ Failed to duplicate page:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
@@ -657,20 +654,21 @@ export function ItemMasterDataManagement() {
|
||||
// 섹션은 페이지의 일부이므로 sections로 별도 추적하지 않음
|
||||
|
||||
// 2. 섹션관리 탭에도 템플릿으로 자동 추가 (계층구조 섹션 = 섹션 탭 섹션)
|
||||
const newTemplate: SectionTemplate = {
|
||||
id: Date.now(),
|
||||
// 프론트엔드 형식: template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
|
||||
const newTemplateData = {
|
||||
tenant_id: tenantId ?? 0,
|
||||
template_name: newSection.section_name,
|
||||
section_type: newSection.section_type,
|
||||
description: newSection.description,
|
||||
section_type: newSection.section_type as 'BASIC' | 'BOM' | 'CUSTOM',
|
||||
description: newSection.description ?? null,
|
||||
default_fields: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
};
|
||||
addSectionTemplate(newTemplate);
|
||||
addSectionTemplate(newTemplateData);
|
||||
|
||||
console.log('Section added to both page and template:', {
|
||||
sectionId: newSection.id,
|
||||
templateId: newTemplate.id
|
||||
templateTitle: newTemplateData.title
|
||||
});
|
||||
|
||||
setNewSectionTitle('');
|
||||
@@ -680,6 +678,67 @@ export function ItemMasterDataManagement() {
|
||||
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
|
||||
};
|
||||
|
||||
// 섹션 템플릿을 페이지에 연결 (SectionDialog에서 사용)
|
||||
const handleLinkTemplate = (template: SectionTemplate) => {
|
||||
if (!selectedPage) {
|
||||
toast.error('페이지를 먼저 선택해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
// 템플릿을 섹션으로 변환하여 페이지에 추가
|
||||
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: template.template_name,
|
||||
section_type: template.section_type,
|
||||
description: template.description || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: template.fields ? template.fields.map((field, idx) => ({
|
||||
id: Date.now() + idx,
|
||||
section_id: 0, // 추후 업데이트됨
|
||||
field_name: field.name,
|
||||
field_type: field.property.inputType,
|
||||
order_no: idx + 1,
|
||||
is_required: field.property.required,
|
||||
placeholder: field.description || null,
|
||||
default_value: null,
|
||||
display_condition: null,
|
||||
validation_rules: null,
|
||||
options: field.property.options
|
||||
? field.property.options.map(opt => ({ label: opt, value: opt }))
|
||||
: null,
|
||||
properties: field.property.multiColumn ? {
|
||||
multiColumn: true,
|
||||
columnCount: field.property.columnCount,
|
||||
columnNames: field.property.columnNames
|
||||
} : null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})) : [],
|
||||
bomItems: template.section_type === 'BOM' ? (template.bomItems || []) : undefined
|
||||
};
|
||||
|
||||
console.log('Linking template to page:', {
|
||||
templateId: template.id,
|
||||
templateName: template.template_name,
|
||||
pageId: selectedPage.id,
|
||||
newSection
|
||||
});
|
||||
|
||||
addSectionToPage(selectedPage.id, newSection);
|
||||
|
||||
// 다이얼로그 상태 초기화
|
||||
setSectionInputMode('custom');
|
||||
setSelectedSectionTemplateId(null);
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
setNewSectionType('fields');
|
||||
setIsSectionDialogOpen(false);
|
||||
|
||||
toast.success(`"${template.template_name}" 템플릿이 페이지에 연결되었습니다!`);
|
||||
};
|
||||
|
||||
const handleEditSectionTitle = (sectionId: string, currentTitle: string) => {
|
||||
setEditingSectionId(sectionId);
|
||||
setEditingSectionTitle(currentTitle);
|
||||
@@ -758,9 +817,15 @@ export function ItemMasterDataManagement() {
|
||||
// 텍스트박스 컬럼 설정
|
||||
const hasColumns = newFieldInputType === 'textbox' && textboxColumns.length > 0;
|
||||
|
||||
// 마스터 항목에서 가져온 경우 master_field_id 설정
|
||||
const masterFieldId = fieldInputMode === 'master' && selectedMasterFieldId
|
||||
? Number(selectedMasterFieldId)
|
||||
: null;
|
||||
|
||||
const newField: ItemField = {
|
||||
id: editingFieldId ? Number(editingFieldId) : Date.now(),
|
||||
section_id: Number(selectedSectionForField),
|
||||
master_field_id: masterFieldId, // 마스터 항목 연결 정보
|
||||
field_name: newFieldName,
|
||||
field_type: newFieldInputType,
|
||||
order_no: 0,
|
||||
@@ -805,17 +870,16 @@ export function ItemMasterDataManagement() {
|
||||
// 1. 섹션에 항목 추가
|
||||
addFieldToSection(Number(selectedSectionForField), newField);
|
||||
|
||||
// 2. 항목관리 탭에도 마스터 항목으로 자동 추가 (중복 체크)
|
||||
// 2. 마스터 항목 선택이 아닌 경우에만 새 마스터 항목 자동 생성
|
||||
// (마스터 항목 선택 시에는 이미 master_field_id로 연결되어 있음)
|
||||
const isFromMasterField = masterFieldId !== null;
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (!existingMasterField) {
|
||||
if (!isFromMasterField && !existingMasterField) {
|
||||
// API 스펙: field_type은 소문자만 허용 (textbox, number, dropdown, checkbox, date, textarea)
|
||||
const newMasterField: ItemMasterField = {
|
||||
id: Date.now(),
|
||||
field_name: newField.field_name,
|
||||
field_type: newField.field_type === 'textbox' ? 'TEXT' :
|
||||
newField.field_type === 'number' ? 'NUMBER' :
|
||||
newField.field_type === 'date' ? 'DATE' :
|
||||
newField.field_type === 'textarea' ? 'TEXTAREA' :
|
||||
newField.field_type === 'checkbox' ? 'CHECKBOX' : 'SELECT',
|
||||
field_type: newField.field_type, // API 스펙에 맞게 소문자 그대로 전달
|
||||
description: newField.placeholder,
|
||||
default_properties: newField.properties,
|
||||
category: selectedPage.item_type, // 현재 페이지의 품목유형을 카테고리로 설정
|
||||
@@ -975,14 +1039,11 @@ export function ItemMasterDataManagement() {
|
||||
}));
|
||||
}
|
||||
|
||||
// API 스펙: field_type은 소문자만 허용 (textbox, number, dropdown, checkbox, date, textarea)
|
||||
const newMasterField: ItemMasterField = {
|
||||
id: Date.now(),
|
||||
field_name: newMasterFieldName,
|
||||
field_type: newMasterFieldInputType === 'textbox' ? 'TEXT' :
|
||||
newMasterFieldInputType === 'number' ? 'NUMBER' :
|
||||
newMasterFieldInputType === 'date' ? 'DATE' :
|
||||
newMasterFieldInputType === 'textarea' ? 'TEXTAREA' :
|
||||
newMasterFieldInputType === 'checkbox' ? 'CHECKBOX' : 'SELECT',
|
||||
field_type: newMasterFieldInputType,
|
||||
category: newMasterFieldCategory || null,
|
||||
description: newMasterFieldDescription || null,
|
||||
default_validation: null,
|
||||
@@ -1214,21 +1275,24 @@ export function ItemMasterDataManagement() {
|
||||
|
||||
// 섹션 템플릿 핸들러
|
||||
const handleAddSectionTemplate = () => {
|
||||
if (!newSectionTemplateTitle.trim())
|
||||
if (!newSectionTemplateTitle.trim())
|
||||
return toast.error('섹션 제목을 입력해주세요');
|
||||
|
||||
const newTemplate: SectionTemplate = {
|
||||
id: Date.now(),
|
||||
// Context의 addSectionTemplate이 기대하는 SectionTemplate 형식 사용
|
||||
// template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
|
||||
const newTemplateData = {
|
||||
tenant_id: tenantId ?? 0,
|
||||
template_name: newSectionTemplateTitle,
|
||||
section_type: newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC',
|
||||
description: newSectionTemplateDescription || undefined,
|
||||
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM',
|
||||
description: newSectionTemplateDescription || null,
|
||||
default_fields: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
category: newSectionTemplateCategory,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
};
|
||||
|
||||
console.log('Adding section template:', newTemplate);
|
||||
addSectionTemplate(newTemplate);
|
||||
console.log('Adding section template:', newTemplateData);
|
||||
addSectionTemplate(newTemplateData);
|
||||
|
||||
setNewSectionTemplateTitle('');
|
||||
setNewSectionTemplateDescription('');
|
||||
@@ -1240,9 +1304,10 @@ export function ItemMasterDataManagement() {
|
||||
|
||||
const handleEditSectionTemplate = (template: SectionTemplate) => {
|
||||
setEditingSectionTemplateId(template.id);
|
||||
// SectionTemplate 타입에 맞게 template_name, section_type 사용
|
||||
setNewSectionTemplateTitle(template.template_name);
|
||||
setNewSectionTemplateDescription(template.description || '');
|
||||
setNewSectionTemplateCategory([]);
|
||||
setNewSectionTemplateCategory(template.category || []);
|
||||
setNewSectionTemplateType(template.section_type === 'BOM' ? 'bom' : 'fields');
|
||||
setIsSectionTemplateDialogOpen(true);
|
||||
};
|
||||
@@ -1251,13 +1316,16 @@ export function ItemMasterDataManagement() {
|
||||
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim())
|
||||
return toast.error('섹션 제목을 입력해주세요');
|
||||
|
||||
// Context의 updateSectionTemplate이 기대하는 SectionTemplate 형식 사용
|
||||
// template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
|
||||
const updateData = {
|
||||
title: newSectionTemplateTitle,
|
||||
template_name: newSectionTemplateTitle,
|
||||
description: newSectionTemplateDescription || undefined,
|
||||
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
|
||||
type: newSectionTemplateType
|
||||
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM'
|
||||
};
|
||||
|
||||
console.log('Updating section template:', { id: editingSectionTemplateId, updateData });
|
||||
updateSectionTemplate(editingSectionTemplateId, updateData);
|
||||
|
||||
setEditingSectionTemplateId(null);
|
||||
@@ -1290,19 +1358,20 @@ export function ItemMasterDataManagement() {
|
||||
}
|
||||
|
||||
// 템플릿을 복사해서 섹션으로 추가
|
||||
// API 스펙: SectionTemplate은 title, type ('fields' | 'bom') 사용
|
||||
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: template.template_name,
|
||||
section_type: template.section_type,
|
||||
section_name: template.title,
|
||||
section_type: template.type === 'bom' ? 'BOM' : 'BASIC',
|
||||
description: template.description || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: [],
|
||||
bomItems: template.section_type === 'BOM' ? [] : undefined
|
||||
bomItems: template.type === 'bom' ? [] : undefined
|
||||
};
|
||||
|
||||
console.log('Loading template to section:', template.template_name, 'type:', template.section_type, 'newSection:', newSection);
|
||||
console.log('Loading template to section:', template.title, 'type:', template.type, 'newSection:', newSection);
|
||||
addSectionToPage(selectedPage.id, newSection);
|
||||
setSelectedTemplateId(null);
|
||||
setIsLoadTemplateDialogOpen(false);
|
||||
@@ -1321,15 +1390,11 @@ export function ItemMasterDataManagement() {
|
||||
// 항목 탭에 해당 항목이 없으면 자동으로 추가
|
||||
const existingMasterField = itemMasterFields.find(f => f.id.toString() === templateFieldKey);
|
||||
if (!existingMasterField && !editingTemplateFieldId) {
|
||||
// API 스펙: field_type은 소문자만 허용 (textbox, number, dropdown, checkbox, date, textarea)
|
||||
const newMasterField: ItemMasterField = {
|
||||
id: Date.now(),
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType === 'textbox' ? 'TEXT' :
|
||||
templateFieldInputType === 'number' ? 'NUMBER' :
|
||||
templateFieldInputType === 'date' ? 'DATE' :
|
||||
templateFieldInputType === 'dropdown' ? 'SELECT' :
|
||||
templateFieldInputType === 'textarea' ? 'TEXTAREA' :
|
||||
templateFieldInputType === 'checkbox' ? 'CHECKBOX' : 'TEXT',
|
||||
field_type: templateFieldInputType,
|
||||
default_properties: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
@@ -1380,38 +1445,30 @@ export function ItemMasterDataManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const newField: ItemField = {
|
||||
id: editingTemplateFieldId || Date.now(),
|
||||
section_id: 0, // Placeholder for template
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType,
|
||||
order_no: 0,
|
||||
is_required: templateFieldRequired,
|
||||
placeholder: templateFieldDescription || null,
|
||||
default_value: null,
|
||||
display_condition: null,
|
||||
validation_rules: null,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
|
||||
: null,
|
||||
properties: {
|
||||
// TemplateField 형식으로 생성 (UI가 기대하는 형식)
|
||||
const newField: TemplateField = {
|
||||
id: String(editingTemplateFieldId || Date.now()),
|
||||
name: templateFieldName,
|
||||
fieldKey: templateFieldKey,
|
||||
property: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
row: 1,
|
||||
col: 1,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => o.trim())
|
||||
: undefined,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
description: templateFieldDescription || undefined
|
||||
};
|
||||
|
||||
let updatedFields;
|
||||
const currentFields = template.default_fields ? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields) : [];
|
||||
|
||||
if (editingTemplateFieldId) {
|
||||
updatedFields = Array.isArray(currentFields) ? currentFields.map((f: any) => f.id === editingTemplateFieldId ? newField : f) : [];
|
||||
// f.id는 string, editingTemplateFieldId는 number이므로 String으로 변환하여 비교
|
||||
updatedFields = Array.isArray(currentFields) ? currentFields.map((f: any) => String(f.id) === String(editingTemplateFieldId) ? newField : f) : [];
|
||||
toast.success('항목이 수정되었습니다');
|
||||
} else {
|
||||
updatedFields = Array.isArray(currentFields) ? [...currentFields, newField] : [newField];
|
||||
@@ -1434,18 +1491,19 @@ export function ItemMasterDataManagement() {
|
||||
setIsTemplateFieldDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleEditTemplateField = (templateId: number, field: ItemField) => {
|
||||
// TemplateField 형식으로 수정 (UI가 전달하는 형식)
|
||||
const handleEditTemplateField = (templateId: number, field: TemplateField) => {
|
||||
setCurrentTemplateId(templateId);
|
||||
setEditingTemplateFieldId(field.id);
|
||||
setTemplateFieldName(field.field_name);
|
||||
setTemplateFieldKey(field.id.toString());
|
||||
setTemplateFieldInputType(field.properties?.inputType);
|
||||
setTemplateFieldRequired(field.is_required);
|
||||
setTemplateFieldOptions(field.options?.map(o => o.value).join(', ') || '');
|
||||
setTemplateFieldDescription(field.placeholder || '');
|
||||
setTemplateFieldMultiColumn(field.properties?.multiColumn || false);
|
||||
setTemplateFieldColumnCount(field.properties?.columnCount || 2);
|
||||
setTemplateFieldColumnNames(field.properties?.columnNames || ['컬럼1', '컬럼2']);
|
||||
setEditingTemplateFieldId(Number(field.id)); // TemplateField.id는 string
|
||||
setTemplateFieldName(field.name);
|
||||
setTemplateFieldKey(field.fieldKey);
|
||||
setTemplateFieldInputType(field.property.inputType);
|
||||
setTemplateFieldRequired(field.property.required);
|
||||
setTemplateFieldOptions(field.property.options?.join(', ') || '');
|
||||
setTemplateFieldDescription(field.description || '');
|
||||
setTemplateFieldMultiColumn(field.property.multiColumn || false);
|
||||
setTemplateFieldColumnCount(field.property.columnCount || 2);
|
||||
setTemplateFieldColumnNames(field.property.columnNames || ['컬럼1', '컬럼2']);
|
||||
setIsTemplateFieldDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -1456,7 +1514,8 @@ export function ItemMasterDataManagement() {
|
||||
if (!template) return;
|
||||
|
||||
const currentFields = template.default_fields ? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields) : [];
|
||||
const updatedFields = Array.isArray(currentFields) ? currentFields.filter((f: any) => f.id !== fieldId) : [];
|
||||
// f.id는 number 또는 string일 수 있으므로 String으로 변환하여 비교
|
||||
const updatedFields = Array.isArray(currentFields) ? currentFields.filter((f: any) => String(f.id) !== String(fieldId)) : [];
|
||||
updateSectionTemplate(templateId, { default_fields: updatedFields });
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
};
|
||||
@@ -1468,7 +1527,7 @@ export function ItemMasterDataManagement() {
|
||||
id: Date.now(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
tenant_id: 1,
|
||||
tenant_id: tenantId ?? 0,
|
||||
section_id: 0
|
||||
};
|
||||
setBomItems(prev => [...prev, newItem]);
|
||||
@@ -1489,7 +1548,7 @@ export function ItemMasterDataManagement() {
|
||||
id: Date.now(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
tenant_id: 1,
|
||||
tenant_id: tenantId ?? 0,
|
||||
section_id: 0
|
||||
};
|
||||
|
||||
@@ -1770,11 +1829,7 @@ export function ItemMasterDataManagement() {
|
||||
{ id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 }
|
||||
]);
|
||||
|
||||
setAttributeSubTabs([
|
||||
{ id: 'units', label: '단위', key: 'units', isDefault: true, order: 0 },
|
||||
{ id: 'materials', label: '재질', key: 'materials', isDefault: true, order: 1 },
|
||||
{ id: 'surface', label: '표면처리', key: 'surface', isDefault: true, order: 2 }
|
||||
]);
|
||||
setAttributeSubTabs([]);
|
||||
|
||||
console.log('🗑️ 모든 품목기준관리 데이터가 초기화되었습니다');
|
||||
toast.success('✅ 모든 데이터가 초기화되었습니다!\n계층구조, 섹션, 항목, 속성이 모두 삭제되었습니다.');
|
||||
@@ -1831,14 +1886,14 @@ export function ItemMasterDataManagement() {
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsManageTabsDialogOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
탭 관리
|
||||
</Button>
|
||||
{/*<Button*/}
|
||||
{/* size="sm"*/}
|
||||
{/* variant="outline"*/}
|
||||
{/* onClick={() => setIsManageTabsDialogOpen(true)}*/}
|
||||
{/*>*/}
|
||||
{/* <Settings className="h-4 w-4 mr-1" />*/}
|
||||
{/* 탭 관리*/}
|
||||
{/*</Button>*/}
|
||||
{/* 전체 초기화 버튼 숨김 처리 - 디자인에 없는 기능 */}
|
||||
{/* <Button
|
||||
size="sm"
|
||||
@@ -1880,7 +1935,7 @@ export function ItemMasterDataManagement() {
|
||||
className="shrink-0"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-1" />
|
||||
탭 관리
|
||||
항목 관리
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2622,6 +2677,12 @@ export function ItemMasterDataManagement() {
|
||||
newSectionDescription={newSectionDescription}
|
||||
setNewSectionDescription={setNewSectionDescription}
|
||||
handleAddSection={handleAddSection}
|
||||
sectionInputMode={sectionInputMode}
|
||||
setSectionInputMode={setSectionInputMode}
|
||||
sectionTemplates={sectionTemplates}
|
||||
selectedTemplateId={selectedSectionTemplateId}
|
||||
setSelectedTemplateId={setSelectedSectionTemplateId}
|
||||
handleLinkTemplate={handleLinkTemplate}
|
||||
/>
|
||||
|
||||
{/* 항목 추가/수정 다이얼로그 - 데스크톱 */}
|
||||
@@ -2809,6 +2870,14 @@ export function ItemMasterDataManagement() {
|
||||
templateFieldColumnNames={templateFieldColumnNames}
|
||||
setTemplateFieldColumnNames={setTemplateFieldColumnNames}
|
||||
handleAddTemplateField={handleAddTemplateField}
|
||||
// 마스터 항목 관련 props
|
||||
itemMasterFields={itemMasterFields}
|
||||
templateFieldInputMode={templateFieldInputMode}
|
||||
setTemplateFieldInputMode={setTemplateFieldInputMode}
|
||||
showMasterFieldList={templateFieldShowMasterFieldList}
|
||||
setShowMasterFieldList={setTemplateFieldShowMasterFieldList}
|
||||
selectedMasterFieldId={templateFieldSelectedMasterFieldId}
|
||||
setSelectedMasterFieldId={setTemplateFieldSelectedMasterFieldId}
|
||||
/>
|
||||
|
||||
<LoadTemplateDialog
|
||||
|
||||
@@ -30,7 +30,7 @@ interface ConditionalDisplayUIProps {
|
||||
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
selectedPage: ItemPage | null;
|
||||
selectedSectionForField: ItemSection | null;
|
||||
editingFieldId: string | null;
|
||||
editingFieldId: number | null;
|
||||
|
||||
// Constants
|
||||
INPUT_TYPE_OPTIONS: Array<{value: string; label: string}>;
|
||||
@@ -92,8 +92,10 @@ export function ConditionalDisplayUI({
|
||||
};
|
||||
|
||||
// selectedSectionForField는 이미 ItemSection 객체이므로 바로 사용
|
||||
// 신규 ItemField 타입: id는 number
|
||||
const availableFields = selectedSectionForField?.fields?.filter(f => f.id !== editingFieldId) || [];
|
||||
const availableSections = selectedPage?.sections.filter(s => s.type !== 'bom') || [];
|
||||
// 신규 ItemSection 타입: section_type은 'BASIC' | 'BOM' | 'CUSTOM'
|
||||
const availableSections = selectedPage?.sections.filter(s => s.section_type !== 'BOM') || [];
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
@@ -175,32 +177,35 @@ export function ConditionalDisplayUI({
|
||||
이 값일 때 표시할 항목들 ({condition.targetFieldIds?.length || 0}개 선택됨):
|
||||
</Label>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
|
||||
{availableFields.map(field => (
|
||||
<label key={field.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={condition.targetFieldIds?.includes(field.id) || false}
|
||||
onChange={(e) => {
|
||||
const newFields = [...newFieldConditionFields];
|
||||
if (e.target.checked) {
|
||||
newFields[conditionIndex].targetFieldIds = [
|
||||
...(newFields[conditionIndex].targetFieldIds || []),
|
||||
field.id
|
||||
];
|
||||
} else {
|
||||
newFields[conditionIndex].targetFieldIds =
|
||||
(newFields[conditionIndex].targetFieldIds || []).filter(id => id !== field.id);
|
||||
}
|
||||
setNewFieldConditionFields(newFields);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{field.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
|
||||
</Badge>
|
||||
</label>
|
||||
))}
|
||||
{availableFields.map(field => {
|
||||
const fieldIdStr = String(field.id);
|
||||
return (
|
||||
<label key={field.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={condition.targetFieldIds?.includes(fieldIdStr) || false}
|
||||
onChange={(e) => {
|
||||
const newFields = [...newFieldConditionFields];
|
||||
if (e.target.checked) {
|
||||
newFields[conditionIndex].targetFieldIds = [
|
||||
...(newFields[conditionIndex].targetFieldIds || []),
|
||||
fieldIdStr
|
||||
];
|
||||
} else {
|
||||
newFields[conditionIndex].targetFieldIds =
|
||||
(newFields[conditionIndex].targetFieldIds || []).filter(id => id !== fieldIdStr);
|
||||
}
|
||||
setNewFieldConditionFields(newFields);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -278,29 +283,32 @@ export function ConditionalDisplayUI({
|
||||
이 값일 때 표시할 섹션들 ({condition.targetSectionIds?.length || 0}개 선택됨):
|
||||
</Label>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
|
||||
{availableSections.map(section => (
|
||||
<label key={section.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={condition.targetSectionIds?.includes(section.id) || false}
|
||||
onChange={(e) => {
|
||||
const newFields = [...newFieldConditionFields];
|
||||
if (e.target.checked) {
|
||||
newFields[conditionIndex].targetSectionIds = [
|
||||
...(newFields[conditionIndex].targetSectionIds || []),
|
||||
section.id
|
||||
];
|
||||
} else {
|
||||
newFields[conditionIndex].targetSectionIds =
|
||||
(newFields[conditionIndex].targetSectionIds || []).filter(id => id !== section.id);
|
||||
}
|
||||
setNewFieldConditionFields(newFields);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{section.title}</span>
|
||||
</label>
|
||||
))}
|
||||
{availableSections.map(section => {
|
||||
const sectionIdStr = String(section.id);
|
||||
return (
|
||||
<label key={section.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={condition.targetSectionIds?.includes(sectionIdStr) || false}
|
||||
onChange={(e) => {
|
||||
const newFields = [...newFieldConditionFields];
|
||||
if (e.target.checked) {
|
||||
newFields[conditionIndex].targetSectionIds = [
|
||||
...(newFields[conditionIndex].targetSectionIds || []),
|
||||
sectionIdStr
|
||||
];
|
||||
} else {
|
||||
newFields[conditionIndex].targetSectionIds =
|
||||
(newFields[conditionIndex].targetSectionIds || []).filter(id => id !== sectionIdStr);
|
||||
}
|
||||
setNewFieldConditionFields(newFields);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{section.section_name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,29 +71,29 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm">{field.name}</span>
|
||||
<span className="text-sm">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.property.required && (
|
||||
{field.is_required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
{field.displayCondition && (
|
||||
{field.display_condition && (
|
||||
<Badge variant="secondary" className="text-xs">조건부</Badge>
|
||||
)}
|
||||
{field.order !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">순서: {field.order + 1}</Badge>
|
||||
{field.order_no !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">순서: {field.order_no + 1}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-6 text-xs text-gray-500 mt-1">
|
||||
필드키: {field.fieldKey}
|
||||
{field.displayCondition && (
|
||||
필드ID: {field.id}
|
||||
{field.display_condition && (
|
||||
<span className="ml-2">
|
||||
(조건: {field.displayCondition.fieldKey} = {field.displayCondition.expectedValue})
|
||||
(조건부 표시 설정됨)
|
||||
</span>
|
||||
)}
|
||||
{field.description && (
|
||||
<span className="ml-2">• {field.description}</span>
|
||||
{field.placeholder && (
|
||||
<span className="ml-2">• {field.placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,11 @@ interface DraggableSectionProps {
|
||||
index: number;
|
||||
moveSection: (dragIndex: number, hoverIndex: number) => void;
|
||||
onDelete: () => void;
|
||||
onEditTitle: (id: string, title: string) => void;
|
||||
editingSectionId: string | null;
|
||||
onEditTitle: (id: number, title: string) => void;
|
||||
editingSectionId: number | null;
|
||||
editingSectionTitle: string;
|
||||
setEditingSectionTitle: (title: string) => void;
|
||||
setEditingSectionId: (id: string | null) => void;
|
||||
setEditingSectionId: (id: number | null) => void;
|
||||
handleSaveSectionTitle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -106,9 +106,9 @@ export function DraggableSection({
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer group min-w-0"
|
||||
onClick={() => onEditTitle(section.id, section.title)}
|
||||
onClick={() => onEditTitle(section.id, section.section_name)}
|
||||
>
|
||||
<span className="text-blue-900 truncate text-sm sm:text-base">{section.title}</span>
|
||||
<span className="text-blue-900 truncate text-sm sm:text-base">{section.section_name}</span>
|
||||
<Edit className="h-3 w-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
@@ -118,8 +118,9 @@ export function DraggableSection({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
title="페이지에서 연결 해제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { toast } from 'sonner';
|
||||
import type { ItemPage, ItemSection, ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import { ConditionalDisplayUI, type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
|
||||
|
||||
// 입력 타입 정의
|
||||
export type InputType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
|
||||
// 텍스트박스 칼럼 타입 (단순 구조)
|
||||
interface OptionColumn {
|
||||
id: string;
|
||||
@@ -20,7 +23,7 @@ interface OptionColumn {
|
||||
key: string;
|
||||
}
|
||||
|
||||
const INPUT_TYPE_OPTIONS = [
|
||||
const INPUT_TYPE_OPTIONS: Array<{ value: InputType; label: string }> = [
|
||||
{ value: 'textbox', label: '텍스트박스' },
|
||||
{ value: 'dropdown', label: '드롭다운' },
|
||||
{ value: 'checkbox', label: '체크박스' },
|
||||
@@ -56,8 +59,8 @@ interface FieldDialogProps {
|
||||
setNewFieldName: (name: string) => void;
|
||||
newFieldKey: string;
|
||||
setNewFieldKey: (key: string) => void;
|
||||
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section';
|
||||
setNewFieldInputType: (type: any) => void;
|
||||
newFieldInputType: InputType;
|
||||
setNewFieldInputType: (type: InputType) => void;
|
||||
newFieldRequired: boolean;
|
||||
setNewFieldRequired: (required: boolean) => void;
|
||||
newFieldDescription: string;
|
||||
@@ -198,21 +201,22 @@ export function FieldDialog({
|
||||
<div
|
||||
key={field.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedMasterFieldId === field.id
|
||||
selectedMasterFieldId === String(field.id)
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedMasterFieldId(field.id);
|
||||
setNewFieldName(field.name);
|
||||
setNewFieldKey(field.fieldKey);
|
||||
setNewFieldInputType(field.property.inputType);
|
||||
setNewFieldRequired(field.property.required);
|
||||
setSelectedMasterFieldId(String(field.id));
|
||||
setNewFieldName(field.field_name);
|
||||
setNewFieldKey(field.id.toString());
|
||||
setNewFieldInputType(field.field_type);
|
||||
setNewFieldRequired(field.properties?.required || false);
|
||||
setNewFieldDescription(field.description || '');
|
||||
setNewFieldOptions(field.property.options?.join(', ') || '');
|
||||
if (field.property.multiColumn && field.property.columnNames) {
|
||||
// options는 {label, value}[] 배열이므로 label만 추출
|
||||
setNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
if (field.properties?.multiColumn && field.properties?.columnNames) {
|
||||
setTextboxColumns(
|
||||
field.property.columnNames.map((name, idx) => ({
|
||||
field.properties.columnNames.map((name: string, idx: number) => ({
|
||||
id: `col-${idx}`,
|
||||
name,
|
||||
key: `column${idx + 1}`
|
||||
@@ -224,28 +228,26 @@ export function FieldDialog({
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="font-medium">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.property.required && (
|
||||
{field.properties?.required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
|
||||
)}
|
||||
{Array.isArray(field.category) && field.category.length > 0 && (
|
||||
{field.category && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{field.category.map((cat, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{field.category}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMasterFieldId === field.id && (
|
||||
{selectedMasterFieldId === String(field.id) && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
@@ -280,7 +282,7 @@ export function FieldDialog({
|
||||
|
||||
<div>
|
||||
<Label>입력방식 *</Label>
|
||||
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
|
||||
<Select value={newFieldInputType} onValueChange={(v) => setNewFieldInputType(v as InputType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -7,10 +7,11 @@ import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
const ITEM_TYPE_OPTIONS = [
|
||||
{ value: 'product', label: '제품' },
|
||||
{ value: 'part', label: '부품' },
|
||||
{ value: 'material', label: '자재' },
|
||||
{ value: 'assembly', label: '조립품' },
|
||||
{ value: 'FG', label: '제품 (FG)' },
|
||||
{ value: 'PT', label: '부품 (PT)' },
|
||||
{ value: 'SM', label: '반제품 (SM)' },
|
||||
{ value: 'RM', label: '원자재 (RM)' },
|
||||
{ value: 'CS', label: '소모품 (CS)' },
|
||||
];
|
||||
|
||||
interface PageDialogProps {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Package, Check, X } from 'lucide-react';
|
||||
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
|
||||
|
||||
interface SectionDialogProps {
|
||||
isSectionDialogOpen: boolean;
|
||||
@@ -16,6 +19,13 @@ interface SectionDialogProps {
|
||||
newSectionDescription: string;
|
||||
setNewSectionDescription: (description: string) => void;
|
||||
handleAddSection: () => void;
|
||||
// 템플릿 선택 관련 props
|
||||
sectionInputMode: 'custom' | 'template';
|
||||
setSectionInputMode: (mode: 'custom' | 'template') => void;
|
||||
sectionTemplates: SectionTemplate[];
|
||||
selectedTemplateId: number | null;
|
||||
setSelectedTemplateId: (id: number | null) => void;
|
||||
handleLinkTemplate: (template: SectionTemplate) => void;
|
||||
}
|
||||
|
||||
export function SectionDialog({
|
||||
@@ -28,59 +38,246 @@ export function SectionDialog({
|
||||
newSectionDescription,
|
||||
setNewSectionDescription,
|
||||
handleAddSection,
|
||||
sectionInputMode,
|
||||
setSectionInputMode,
|
||||
sectionTemplates,
|
||||
selectedTemplateId,
|
||||
setSelectedTemplateId,
|
||||
handleLinkTemplate,
|
||||
}: SectionDialogProps) {
|
||||
const handleClose = () => {
|
||||
setIsSectionDialogOpen(false);
|
||||
setNewSectionType('fields');
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
setSectionInputMode('custom');
|
||||
setSelectedTemplateId(null);
|
||||
};
|
||||
|
||||
// 템플릿 선택 시 폼에 값 채우기
|
||||
const handleSelectTemplate = (template: SectionTemplate) => {
|
||||
setSelectedTemplateId(template.id);
|
||||
setNewSectionTitle(template.template_name);
|
||||
setNewSectionDescription(template.description || '');
|
||||
setNewSectionType(template.section_type === 'BOM' ? 'bom' : 'fields');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isSectionDialogOpen} onOpenChange={(open) => {
|
||||
setIsSectionDialogOpen(open);
|
||||
if (!open) {
|
||||
setNewSectionType('fields');
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
}
|
||||
if (!open) handleClose();
|
||||
else setIsSectionDialogOpen(open);
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{newSectionType === 'bom' ? 'BOM 섹션' : '일반 섹션'} 추가</DialogTitle>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
|
||||
<DialogTitle>섹션 추가</DialogTitle>
|
||||
<DialogDescription>
|
||||
{newSectionType === 'bom'
|
||||
? '새로운 BOM(자재명세서) 섹션을 추가합니다'
|
||||
: '새로운 일반 섹션을 추가합니다'}
|
||||
새로운 섹션을 추가하거나 기존 템플릿을 연결합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>섹션 제목 *</Label>
|
||||
<Input
|
||||
value={newSectionTitle}
|
||||
onChange={(e) => setNewSectionTitle(e.target.value)}
|
||||
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* 입력 모드 선택 */}
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSectionInputMode('custom');
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSectionInputMode('template')}
|
||||
className="flex-1"
|
||||
>
|
||||
템플릿 선택
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label>설명 (선택)</Label>
|
||||
<Textarea
|
||||
value={newSectionDescription}
|
||||
onChange={(e) => setNewSectionDescription(e.target.value)}
|
||||
placeholder="섹션에 대한 설명"
|
||||
/>
|
||||
</div>
|
||||
{newSectionType === 'bom' && (
|
||||
<div className="bg-blue-50 p-3 rounded-md">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>BOM 섹션:</strong> 자재명세서(BOM) 관리를 위한 전용 섹션입니다.
|
||||
부품 구성, 수량, 단가 등을 관리할 수 있습니다.
|
||||
</p>
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
{sectionInputMode === 'template' && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[300px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>섹션 템플릿 목록</Label>
|
||||
</div>
|
||||
{sectionTemplates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 섹션 템플릿이 없습니다.<br/>
|
||||
섹션 탭에서 템플릿을 먼저 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sectionTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedTemplateId === template.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.section_type === 'BOM' ? (
|
||||
<Package className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
<span className="font-medium">{template.template_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{template.section_type === 'BOM' ? '모듈(BOM)' : '일반'}
|
||||
</Badge>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
|
||||
)}
|
||||
{template.fields && template.fields.length > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{template.fields.length}개 항목 포함
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedTemplateId === template.id && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 또는 선택된 템플릿 정보 표시 */}
|
||||
{(sectionInputMode === 'custom' || selectedTemplateId) && (
|
||||
<>
|
||||
{/* 섹션 유형 선택 - 템플릿 선택 시 비활성화 */}
|
||||
<div>
|
||||
<Label className="mb-3 block">섹션 유형 *</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 일반 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sectionInputMode === 'custom') setNewSectionType('fields');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
|
||||
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
newSectionType === 'fields'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`h-5 w-5 ${newSectionType === 'fields' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'fields' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
일반 섹션
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">필드 항목 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'fields' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
{/* BOM 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sectionInputMode === 'custom') setNewSectionType('bom');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
|
||||
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
newSectionType === 'bom'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Package className={`h-5 w-5 ${newSectionType === 'bom' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'bom' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
모듈 섹션 (BOM)
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">자재명세서 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'bom' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>섹션 제목 *</Label>
|
||||
<Input
|
||||
value={newSectionTitle}
|
||||
onChange={(e) => setNewSectionTitle(e.target.value)}
|
||||
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
|
||||
disabled={sectionInputMode === 'template'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>설명 (선택)</Label>
|
||||
<Textarea
|
||||
value={newSectionDescription}
|
||||
onChange={(e) => setNewSectionDescription(e.target.value)}
|
||||
placeholder="섹션에 대한 설명"
|
||||
disabled={sectionInputMode === 'template'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sectionInputMode === 'template' && selectedTemplateId && (
|
||||
<div className="bg-green-50 p-3 rounded-md border border-green-200">
|
||||
<p className="text-sm text-green-700">
|
||||
<strong>템플릿 연결:</strong> 선택한 템플릿을 페이지에 연결합니다.
|
||||
템플릿에 포함된 항목들도 함께 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSectionType === 'bom' && sectionInputMode === 'custom' && (
|
||||
<div className="bg-blue-50 p-3 rounded-md">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>BOM 섹션:</strong> 자재명세서(BOM) 관리를 위한 전용 섹션입니다.
|
||||
부품 구성, 수량, 단가 등을 관리할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setIsSectionDialogOpen(false);
|
||||
setNewSectionType('fields');
|
||||
}} className="w-full sm:w-auto">취소</Button>
|
||||
<Button onClick={handleAddSection} className="w-full sm:w-auto">추가</Button>
|
||||
|
||||
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="w-full sm:w-auto">
|
||||
취소
|
||||
</Button>
|
||||
{sectionInputMode === 'template' && selectedTemplateId ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
|
||||
if (template) handleLinkTemplate(template);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
템플릿 연결
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleAddSection}
|
||||
className="w-full sm:w-auto"
|
||||
disabled={sectionInputMode === 'template' && !selectedTemplateId}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
|
||||
const INPUT_TYPE_OPTIONS = [
|
||||
{ value: 'textbox', label: '텍스트 입력' },
|
||||
@@ -41,6 +44,14 @@ interface TemplateFieldDialogProps {
|
||||
templateFieldColumnNames: string[];
|
||||
setTemplateFieldColumnNames: (names: string[]) => void;
|
||||
handleAddTemplateField: () => void;
|
||||
// 마스터 항목 관련 props
|
||||
itemMasterFields?: ItemMasterField[];
|
||||
templateFieldInputMode?: 'custom' | 'master';
|
||||
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
|
||||
showMasterFieldList?: boolean;
|
||||
setShowMasterFieldList?: (show: boolean) => void;
|
||||
selectedMasterFieldId?: string;
|
||||
setSelectedMasterFieldId?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateFieldDialog({
|
||||
@@ -67,31 +78,151 @@ export function TemplateFieldDialog({
|
||||
templateFieldColumnNames,
|
||||
setTemplateFieldColumnNames,
|
||||
handleAddTemplateField,
|
||||
// 마스터 항목 관련 props (optional)
|
||||
itemMasterFields = [],
|
||||
templateFieldInputMode = 'custom',
|
||||
setTemplateFieldInputMode,
|
||||
showMasterFieldList = false,
|
||||
setShowMasterFieldList,
|
||||
selectedMasterFieldId = '',
|
||||
setSelectedMasterFieldId,
|
||||
}: TemplateFieldDialogProps) {
|
||||
const handleClose = () => {
|
||||
setIsTemplateFieldDialogOpen(false);
|
||||
setEditingTemplateFieldId(null);
|
||||
setTemplateFieldName('');
|
||||
setTemplateFieldKey('');
|
||||
setTemplateFieldInputType('textbox');
|
||||
setTemplateFieldRequired(false);
|
||||
setTemplateFieldOptions('');
|
||||
setTemplateFieldDescription('');
|
||||
setTemplateFieldMultiColumn(false);
|
||||
setTemplateFieldColumnCount(2);
|
||||
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
|
||||
// 마스터 항목 관련 상태 초기화
|
||||
setTemplateFieldInputMode?.('custom');
|
||||
setShowMasterFieldList?.(false);
|
||||
setSelectedMasterFieldId?.('');
|
||||
};
|
||||
|
||||
const handleSelectMasterField = (field: ItemMasterField) => {
|
||||
setSelectedMasterFieldId?.(String(field.id));
|
||||
setTemplateFieldName(field.field_name);
|
||||
setTemplateFieldKey(field.id.toString());
|
||||
setTemplateFieldInputType(field.field_type);
|
||||
setTemplateFieldRequired(field.properties?.required || false);
|
||||
setTemplateFieldDescription(field.description || '');
|
||||
// options는 {label, value}[] 배열이므로 label만 추출
|
||||
setTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
if (field.properties?.multiColumn && field.properties?.columnNames) {
|
||||
setTemplateFieldMultiColumn(true);
|
||||
setTemplateFieldColumnCount(field.properties.columnNames.length);
|
||||
setTemplateFieldColumnNames(field.properties.columnNames);
|
||||
} else {
|
||||
setTemplateFieldMultiColumn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={(open) => {
|
||||
setIsTemplateFieldDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingTemplateFieldId(null);
|
||||
setTemplateFieldName('');
|
||||
setTemplateFieldKey('');
|
||||
setTemplateFieldInputType('textbox');
|
||||
setTemplateFieldRequired(false);
|
||||
setTemplateFieldOptions('');
|
||||
setTemplateFieldDescription('');
|
||||
setTemplateFieldMultiColumn(false);
|
||||
setTemplateFieldColumnCount(2);
|
||||
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
|
||||
}
|
||||
}}>
|
||||
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
섹션에 포함될 항목을 설정합니다
|
||||
재사용 가능한 마스터 항목을 선택하거나 직접 입력하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
|
||||
{!editingTemplateFieldId && setTemplateFieldInputMode && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={templateFieldInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setTemplateFieldInputMode('custom')}
|
||||
className="flex-1"
|
||||
>
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={templateFieldInputMode === 'master' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTemplateFieldInputMode('master');
|
||||
setShowMasterFieldList?.(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
마스터 항목 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마스터 항목 목록 */}
|
||||
{templateFieldInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>마스터 항목 목록</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMasterFieldList?.(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{itemMasterFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 마스터 항목이 없습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{itemMasterFields.map(field => (
|
||||
<div
|
||||
key={field.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedMasterFieldId === String(field.id)
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectMasterField(field)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.properties?.required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
|
||||
)}
|
||||
{field.category && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{field.category}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMasterFieldId === String(field.id) && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 */}
|
||||
{(templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode) && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>항목명 *</Label>
|
||||
@@ -199,6 +330,8 @@ export function TemplateFieldDialog({
|
||||
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
|
||||
<Label>필수 항목</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}>취소</Button>
|
||||
|
||||
@@ -304,6 +304,7 @@ export function HierarchyTab({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 다이얼로그에서 타입 선택하도록 기본값만 설정
|
||||
setNewSectionType('fields');
|
||||
setIsSectionDialogOpen(true);
|
||||
}}
|
||||
@@ -332,9 +333,9 @@ export function HierarchyTab({
|
||||
moveSection(dragIndex, hoverIndex);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (confirm('이 섹션과 모든 항목을 삭제하시겠습니까?')) {
|
||||
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
|
||||
deleteSection(selectedPage.id, section.id);
|
||||
toast.success('섹션이 삭제되었습니다');
|
||||
toast.success('섹션 연결이 해제되었습니다');
|
||||
}
|
||||
}}
|
||||
onEditTitle={handleEditSectionTitle}
|
||||
|
||||
@@ -78,16 +78,18 @@ export function MasterFieldTab({
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.properties?.inputType)?.label}
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label}
|
||||
</Badge>
|
||||
{field.properties?.required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
|
||||
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
|
||||
{field.category && (
|
||||
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
|
||||
)}
|
||||
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
|
||||
<Badge variant="default" className="text-xs bg-blue-500">
|
||||
{(field.properties as any).attributeType === 'unit' ? '단위 연동' :
|
||||
(field.properties as any).attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
|
||||
{field.properties.attributeType === 'unit' ? '단위 연동' :
|
||||
field.properties.attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -97,10 +99,10 @@ export function MasterFieldTab({
|
||||
<span className="ml-2">• {field.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{field.properties?.options && field.properties.options.length > 0 && (
|
||||
{field.options && field.options.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
옵션: {field.properties.options.join(', ')}
|
||||
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
|
||||
옵션: {field.options.map(opt => opt.label).join(', ')}
|
||||
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
(속성 탭 자동 동기화)
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical } from 'lucide-react';
|
||||
import type { SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
|
||||
import { BOMManagementSection } from '../../BOMManagementSection';
|
||||
|
||||
interface SectionsTabProps {
|
||||
@@ -22,11 +22,11 @@ interface SectionsTabProps {
|
||||
handleDeleteSectionTemplate: (id: number) => void;
|
||||
|
||||
// 템플릿 필드 핸들러
|
||||
handleEditTemplateField: (templateId: number, field: any) => void;
|
||||
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
|
||||
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
|
||||
|
||||
// BOM 핸들러
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'createdAt'>) => void;
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
|
||||
handleUpdateBOMItemInTemplate: (templateId: number, itemId: number, item: Partial<BOMItem>) => void;
|
||||
handleDeleteBOMItemFromTemplate: (templateId: number, itemId: number) => void;
|
||||
|
||||
@@ -169,14 +169,14 @@ export function SectionsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{template.fields.length === 0 ? (
|
||||
{(!template.fields || template.fields.length === 0) ? (
|
||||
<div className="bg-gray-50 border-2 border-dashed border-gray-200 rounded-lg py-16">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 mb-1">
|
||||
항목을 활용을 구간이에만 추가 버튼을 클릭해보세요
|
||||
항목을 활용한 구간입니다 추가 버튼을 클릭해보세요
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
품목의 목록명, 수량, 입력방법 고객화된 표시할 수 있습니다
|
||||
|
||||
@@ -242,20 +242,23 @@ export interface ItemFieldProperty {
|
||||
columnNames?: string[]; // 각 컬럼의 이름
|
||||
}
|
||||
|
||||
// 항목 마스터 (재사용 가능한 항목 템플릿) - API 응답 구조에 맞춰 수정
|
||||
// 항목 마스터 (재사용 가능한 항목 템플릿) - MasterFieldResponse와 정확히 일치
|
||||
export interface ItemMasterField {
|
||||
id: number; // 서버 생성 ID (string → number)
|
||||
tenant_id?: number; // 백엔드에서 자동 추가
|
||||
field_name: string; // 항목명 (name → field_name)
|
||||
field_type: 'TEXT' | 'NUMBER' | 'DATE' | 'SELECT' | 'TEXTAREA' | 'CHECKBOX'; // 필드 타입 추가
|
||||
category?: string | null; // 카테고리 (사용 용도 구분용)
|
||||
description?: string | null; // 설명
|
||||
default_validation?: Record<string, any> | null; // 기본 검증 규칙 (JSON)
|
||||
default_properties?: Record<string, any> | null; // 기본 속성 (JSON, property/properties → default_properties)
|
||||
created_by?: number | null; // 생성자 ID 추가
|
||||
updated_by?: number | null; // 수정자 ID 추가
|
||||
created_at: string; // 생성일 (createdAt → created_at)
|
||||
updated_at: string; // 수정일 추가
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
field_name: string;
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
is_common: boolean; // 공통 필드 여부
|
||||
default_value: string | null; // 기본값
|
||||
options: Array<{ label: string; value: string }> | null; // dropdown 옵션
|
||||
validation_rules: Record<string, any> | null; // 검증 규칙
|
||||
properties: Record<string, any> | null; // 추가 속성
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 조건부 표시 설정
|
||||
@@ -336,7 +339,7 @@ export interface ItemPage {
|
||||
id: number; // 서버 생성 ID (string → number)
|
||||
tenant_id?: number; // 백엔드에서 자동 추가
|
||||
page_name: string; // 페이지명 (camelCase → snake_case)
|
||||
item_type: string; // 품목유형
|
||||
item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형
|
||||
description?: string | null; // 설명 추가
|
||||
absolute_path: string; // 절대경로 (camelCase → snake_case)
|
||||
is_active: boolean; // 사용 여부 (camelCase → snake_case)
|
||||
@@ -348,19 +351,37 @@ export interface ItemPage {
|
||||
sections: ItemSection[]; // 페이지에 포함된 섹션들 (Nested)
|
||||
}
|
||||
|
||||
// 섹션 템플릿 (재사용 가능한 섹션) - API 응답 구조에 맞춰 수정
|
||||
// 템플릿 필드 (로컬 관리용 - API에서 제공하지 않음)
|
||||
export interface TemplateField {
|
||||
id: string;
|
||||
name: string;
|
||||
fieldKey: string;
|
||||
property: {
|
||||
inputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
multiColumn?: boolean;
|
||||
columnCount?: number;
|
||||
columnNames?: string[];
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 섹션 템플릿 (재사용 가능한 섹션) - Transformer 출력과 UI 요구사항에 맞춤
|
||||
export interface SectionTemplate {
|
||||
id: number; // 서버 생성 ID (string → number)
|
||||
tenant_id?: number; // 백엔드에서 자동 추가
|
||||
template_name: string; // 템플릿명 (title → template_name)
|
||||
section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // 섹션 타입 (type → section_type, 값 변경)
|
||||
description?: string | null; // 설명
|
||||
default_fields?: Record<string, any> | null; // 기본 필드 설정 (JSON)
|
||||
bomItems?: BOMItem[]; // BOM 섹션의 BOM 아이템 목록
|
||||
created_by?: number | null; // 생성자 ID 추가
|
||||
updated_by?: number | null; // 수정자 ID 추가
|
||||
created_at: string; // 생성일 (createdAt → created_at)
|
||||
updated_at: string; // 수정일 (updatedAt → updated_at, required)
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
template_name: string; // transformer가 title → template_name으로 변환
|
||||
section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // transformer가 type → section_type으로 변환
|
||||
description: string | null;
|
||||
default_fields: TemplateField[] | null; // 기본 필드 (로컬 관리)
|
||||
category?: string[]; // 적용 카테고리 (로컬 관리)
|
||||
fields?: TemplateField[]; // 템플릿에 포함된 필드 (로컬 관리)
|
||||
bomItems?: BOMItem[]; // BOM 타입일 경우 BOM 품목 (로컬 관리)
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== Context Type =====
|
||||
@@ -385,19 +406,22 @@ interface ItemMasterContextType {
|
||||
|
||||
// 품목기준관리 - 마스터 항목
|
||||
itemMasterFields: ItemMasterField[];
|
||||
loadItemMasterFields: (fields: ItemMasterField[]) => void; // 초기 데이터 로딩용 (API 호출 없음)
|
||||
addItemMasterField: (field: Omit<ItemMasterField, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
|
||||
updateItemMasterField: (id: number, updates: Partial<ItemMasterField>) => Promise<void>;
|
||||
deleteItemMasterField: (id: number) => Promise<void>;
|
||||
|
||||
// 품목기준관리 - 섹션 템플릿
|
||||
sectionTemplates: SectionTemplate[];
|
||||
loadSectionTemplates: (templates: SectionTemplate[]) => void; // 초기 데이터 로딩용 (API 호출 없음)
|
||||
addSectionTemplate: (template: Omit<SectionTemplate, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
|
||||
updateSectionTemplate: (id: number, updates: Partial<SectionTemplate>) => Promise<void>;
|
||||
deleteSectionTemplate: (id: number) => Promise<void>;
|
||||
|
||||
// 품목기준관리 계층구조
|
||||
itemPages: ItemPage[];
|
||||
addItemPage: (page: Omit<ItemPage, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
|
||||
loadItemPages: (pages: ItemPage[]) => void; // 초기 데이터 로딩용 (API 호출 없음)
|
||||
addItemPage: (page: Omit<ItemPage, 'id' | 'created_at' | 'updated_at'>) => Promise<ItemPage>;
|
||||
updateItemPage: (id: number, updates: Partial<ItemPage>) => Promise<void>;
|
||||
deleteItemPage: (id: number) => Promise<void>;
|
||||
reorderPages: (newOrder: Array<{ id: number; order_no: number }>) => Promise<void>;
|
||||
@@ -455,6 +479,9 @@ interface ItemMasterContextType {
|
||||
// 캐시 및 데이터 초기화
|
||||
clearCache: () => void; // TenantAwareCache 정리
|
||||
resetAllData: () => void; // 모든 state 초기화
|
||||
|
||||
// 테넌트 정보
|
||||
tenantId: number | undefined; // 현재 로그인한 사용자의 테넌트 ID
|
||||
}
|
||||
|
||||
// Create context
|
||||
@@ -1056,19 +1083,27 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
// ItemMasterField CRUD (임시: 로컬 state)
|
||||
/**
|
||||
* 초기 데이터 로딩용: API 호출 없이 마스터 필드를 state에 로드 (덮어쓰기)
|
||||
*/
|
||||
const loadItemMasterFields = (fields: ItemMasterField[]) => {
|
||||
setItemMasterFields(fields);
|
||||
console.log('[ItemMasterContext] 마스터 필드 로드 완료:', fields.length);
|
||||
};
|
||||
|
||||
const addItemMasterField = async (field: Omit<ItemMasterField, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
try {
|
||||
// API 호출
|
||||
const response = await itemMasterApi.masterFields.create({
|
||||
field_name: field.field_name,
|
||||
field_type: field.field_type,
|
||||
category: field.category,
|
||||
description: field.description,
|
||||
category: field.category ?? undefined,
|
||||
description: field.description ?? undefined,
|
||||
is_common: field.is_common,
|
||||
default_value: field.default_value,
|
||||
options: field.options,
|
||||
validation_rules: field.validation_rules,
|
||||
properties: field.properties,
|
||||
default_value: field.default_value ?? undefined,
|
||||
options: field.options ?? undefined,
|
||||
validation_rules: field.validation_rules ?? undefined,
|
||||
properties: field.properties ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -1088,6 +1123,8 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
options: response.data.options,
|
||||
validation_rules: response.data.validation_rules,
|
||||
properties: response.data.properties,
|
||||
created_by: response.data.created_by,
|
||||
updated_by: response.data.updated_by,
|
||||
created_at: response.data.created_at,
|
||||
updated_at: response.data.updated_at,
|
||||
};
|
||||
@@ -1166,28 +1203,50 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
// SectionTemplate CRUD with API
|
||||
/**
|
||||
* 초기 데이터 로딩용: API 호출 없이 섹션 템플릿을 state에 로드 (덮어쓰기)
|
||||
*/
|
||||
const loadSectionTemplates = (templates: SectionTemplate[]) => {
|
||||
setSectionTemplates(templates);
|
||||
console.log('[ItemMasterContext] 섹션 템플릿 로드 완료:', templates.length);
|
||||
};
|
||||
|
||||
const addSectionTemplate = async (template: Omit<SectionTemplate, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
try {
|
||||
// API 호출
|
||||
// 프론트엔드 형식 → API 형식 변환
|
||||
// template_name → title, section_type → type
|
||||
const apiType = template.section_type === 'BOM' ? 'bom' : 'fields';
|
||||
|
||||
const response = await itemMasterApi.templates.create({
|
||||
title: template.title,
|
||||
type: template.type,
|
||||
description: template.description,
|
||||
is_default: template.is_default,
|
||||
title: template.template_name,
|
||||
type: apiType,
|
||||
description: template.description ?? undefined,
|
||||
is_default: false, // 기본값
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || '섹션 템플릿 생성 실패');
|
||||
}
|
||||
|
||||
// 응답 데이터 변환 및 state 업데이트
|
||||
// API 응답 → 프론트엔드 형식 변환
|
||||
// title → template_name, type → section_type
|
||||
const SECTION_TYPE_MAP: Record<string, 'BASIC' | 'BOM' | 'CUSTOM'> = {
|
||||
fields: 'BASIC',
|
||||
bom: 'BOM',
|
||||
};
|
||||
|
||||
const newTemplate: SectionTemplate = {
|
||||
id: response.data.id,
|
||||
tenant_id: response.data.tenant_id,
|
||||
title: response.data.title,
|
||||
type: response.data.type,
|
||||
template_name: response.data.title,
|
||||
section_type: SECTION_TYPE_MAP[response.data.type] || 'BASIC',
|
||||
description: response.data.description,
|
||||
is_default: response.data.is_default,
|
||||
default_fields: null,
|
||||
category: template.category,
|
||||
fields: template.fields,
|
||||
bomItems: template.bomItems,
|
||||
created_by: response.data.created_by,
|
||||
updated_by: response.data.updated_by,
|
||||
created_at: response.data.created_at,
|
||||
updated_at: response.data.updated_at,
|
||||
};
|
||||
@@ -1203,32 +1262,79 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const updateSectionTemplate = async (id: number, updates: Partial<SectionTemplate>) => {
|
||||
try {
|
||||
// API 호출
|
||||
const requestData: any = {};
|
||||
if (updates.title !== undefined) requestData.title = updates.title;
|
||||
if (updates.type !== undefined) requestData.type = updates.type;
|
||||
if (updates.description !== undefined) requestData.description = updates.description;
|
||||
if (updates.is_default !== undefined) requestData.is_default = updates.is_default;
|
||||
// default_fields, fields, category, bomItems는 로컬에서만 관리 (API 미지원)
|
||||
const localOnlyUpdates = ['default_fields', 'fields', 'category', 'bomItems'];
|
||||
const hasApiUpdates = Object.keys(updates).some(key => !localOnlyUpdates.includes(key));
|
||||
const hasLocalUpdates = Object.keys(updates).some(key => localOnlyUpdates.includes(key));
|
||||
|
||||
const response = await itemMasterApi.templates.update(id, requestData);
|
||||
// API 호출이 필요한 경우에만 API 요청
|
||||
if (hasApiUpdates) {
|
||||
// API 요청 형식으로 변환 (frontend → API)
|
||||
// frontend: template_name, section_type
|
||||
// API: title, type
|
||||
const requestData: any = {};
|
||||
if (updates.template_name !== undefined) requestData.title = updates.template_name;
|
||||
if (updates.section_type !== undefined) {
|
||||
// section_type 변환: 'BASIC' | 'CUSTOM' → 'fields', 'BOM' → 'bom'
|
||||
requestData.type = updates.section_type === 'BOM' ? 'bom' : 'fields';
|
||||
}
|
||||
if (updates.description !== undefined) requestData.description = updates.description;
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || '섹션 템플릿 수정 실패');
|
||||
const response = await itemMasterApi.templates.update(id, requestData);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || '섹션 템플릿 수정 실패');
|
||||
}
|
||||
|
||||
// state 업데이트 (API 응답 → frontend 형식으로 변환)
|
||||
// API 응답: title, type ('fields' | 'bom')
|
||||
// Frontend 형식: template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
|
||||
const SECTION_TYPE_MAP: Record<string, 'BASIC' | 'BOM' | 'CUSTOM'> = {
|
||||
fields: 'BASIC',
|
||||
bom: 'BOM',
|
||||
};
|
||||
|
||||
setSectionTemplates(prev => prev.map(template =>
|
||||
template.id === id ? {
|
||||
...template,
|
||||
template_name: response.data!.title,
|
||||
section_type: SECTION_TYPE_MAP[response.data!.type] || 'BASIC',
|
||||
description: response.data!.description,
|
||||
updated_at: response.data!.updated_at,
|
||||
} : template
|
||||
));
|
||||
|
||||
console.log('[ItemMasterContext] 섹션 템플릿 수정 성공 (API):', id);
|
||||
}
|
||||
|
||||
// state 업데이트
|
||||
setSectionTemplates(prev => prev.map(template =>
|
||||
template.id === id ? {
|
||||
...template,
|
||||
title: response.data!.title,
|
||||
type: response.data!.type,
|
||||
description: response.data!.description,
|
||||
is_default: response.data!.is_default,
|
||||
updated_at: response.data!.updated_at,
|
||||
} : template
|
||||
));
|
||||
// 로컬 전용 필드 업데이트 (default_fields, fields, category, bomItems)
|
||||
if (hasLocalUpdates) {
|
||||
setSectionTemplates(prev => prev.map(template => {
|
||||
if (template.id !== id) return template;
|
||||
|
||||
console.log('[ItemMasterContext] 섹션 템플릿 수정 성공:', id);
|
||||
const updatedTemplate = { ...template };
|
||||
|
||||
// default_fields 업데이트 시 fields도 같이 업데이트
|
||||
if (updates.default_fields !== undefined) {
|
||||
updatedTemplate.default_fields = updates.default_fields;
|
||||
updatedTemplate.fields = updates.default_fields as TemplateField[];
|
||||
}
|
||||
if (updates.fields !== undefined) {
|
||||
updatedTemplate.fields = updates.fields;
|
||||
updatedTemplate.default_fields = updates.fields;
|
||||
}
|
||||
if (updates.category !== undefined) {
|
||||
updatedTemplate.category = updates.category;
|
||||
}
|
||||
if (updates.bomItems !== undefined) {
|
||||
updatedTemplate.bomItems = updates.bomItems;
|
||||
}
|
||||
|
||||
return updatedTemplate;
|
||||
}));
|
||||
|
||||
console.log('[ItemMasterContext] 섹션 템플릿 수정 성공 (로컬):', id, Object.keys(updates).filter(k => localOnlyUpdates.includes(k)));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
console.error('[ItemMasterContext] 섹션 템플릿 수정 실패:', errorMessage);
|
||||
@@ -1256,7 +1362,22 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
// ItemPage CRUD with API
|
||||
const addItemPage = async (pageData: Omit<ItemPage, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
|
||||
/**
|
||||
* 초기 데이터 로딩용: API 호출 없이 페이지 데이터를 state에 로드 (덮어쓰기)
|
||||
* (이미 백엔드에서 받아온 데이터를 로드할 때 사용)
|
||||
*/
|
||||
const loadItemPages = (pages: ItemPage[]) => {
|
||||
setItemPages(pages); // 덮어쓰기 (append가 아님!)
|
||||
console.log('[ItemMasterContext] 페이지 데이터 로드 완료:', pages.length);
|
||||
};
|
||||
|
||||
/**
|
||||
* 새 페이지 생성: API 호출 + state 업데이트
|
||||
* (사용자가 새 페이지를 만들 때 사용)
|
||||
* @returns 생성된 페이지 반환
|
||||
*/
|
||||
const addItemPage = async (pageData: Omit<ItemPage, 'id' | 'created_at' | 'updated_at'>): Promise<ItemPage> => {
|
||||
try {
|
||||
// API 요청 데이터 변환
|
||||
const requestData: ItemPageRequest = {
|
||||
@@ -1268,15 +1389,12 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
// API 호출
|
||||
const response = await itemMasterApi.pages.create(requestData);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || '페이지 생성 실패');
|
||||
}
|
||||
|
||||
// 응답 데이터 변환 및 state 업데이트
|
||||
const newPage = transformPageResponse(response.data);
|
||||
const newPage = transformPageResponse(response);
|
||||
setItemPages(prev => [...prev, newPage]);
|
||||
|
||||
console.log('[ItemMasterContext] 페이지 생성 성공:', newPage);
|
||||
return newPage;
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
console.error('[ItemMasterContext] 페이지 생성 실패:', errorMessage);
|
||||
@@ -1333,8 +1451,6 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
const reorderPages = async (newOrder: Array<{ id: number; order_no: number }>) => {
|
||||
try {
|
||||
// Optimistic UI 업데이트 (즉시 반영)
|
||||
const previousPages = [...itemPages];
|
||||
|
||||
setItemPages(prev => {
|
||||
const updated = [...prev];
|
||||
updated.sort((a, b) => {
|
||||
@@ -1456,7 +1572,7 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
// API 호출
|
||||
const response = await itemMasterApi.sections.reorder(pageId, {
|
||||
items: sectionIds.map((id, index) => ({ id, order_no: index }))
|
||||
section_orders: sectionIds.map((id, index) => ({ id, order_no: index }))
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -1482,17 +1598,17 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
// Field CRUD with API
|
||||
const addFieldToSection = async (sectionId: number, fieldData: Omit<ItemField, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
try {
|
||||
// API 호출
|
||||
// API 호출 (null → undefined 변환)
|
||||
const response = await itemMasterApi.fields.create(sectionId, {
|
||||
field_name: fieldData.field_name,
|
||||
field_type: fieldData.field_type,
|
||||
is_required: fieldData.is_required,
|
||||
default_value: fieldData.default_value,
|
||||
placeholder: fieldData.placeholder,
|
||||
display_condition: fieldData.display_condition,
|
||||
validation_rules: fieldData.validation_rules,
|
||||
options: fieldData.options,
|
||||
properties: fieldData.properties,
|
||||
default_value: fieldData.default_value ?? undefined,
|
||||
placeholder: fieldData.placeholder ?? undefined,
|
||||
display_condition: fieldData.display_condition ?? undefined,
|
||||
validation_rules: fieldData.validation_rules ?? undefined,
|
||||
options: fieldData.options ?? undefined,
|
||||
properties: fieldData.properties ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -1538,17 +1654,20 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const updateField = async (fieldId: number, updates: Partial<ItemField>) => {
|
||||
try {
|
||||
// API 호출
|
||||
// API 호출 (null → undefined 변환)
|
||||
// 주의: display_condition은 현재 백엔드에서 지원하는 형식과 프론트엔드 형식이 다르므로 API에 전송하지 않음
|
||||
// 백엔드 형식: {"field_id": "1", "operator": "equals", "value": "true"}
|
||||
// 프론트엔드 형식: {"targetType": "field", "fieldConditions": [...], "sectionIds": [...]}
|
||||
const requestData: any = {};
|
||||
if (updates.field_name !== undefined) requestData.field_name = updates.field_name;
|
||||
if (updates.field_type !== undefined) requestData.field_type = updates.field_type;
|
||||
if (updates.is_required !== undefined) requestData.is_required = updates.is_required;
|
||||
if (updates.default_value !== undefined) requestData.default_value = updates.default_value;
|
||||
if (updates.placeholder !== undefined) requestData.placeholder = updates.placeholder;
|
||||
if (updates.display_condition !== undefined) requestData.display_condition = updates.display_condition;
|
||||
if (updates.validation_rules !== undefined) requestData.validation_rules = updates.validation_rules;
|
||||
if (updates.options !== undefined) requestData.options = updates.options;
|
||||
if (updates.properties !== undefined) requestData.properties = updates.properties;
|
||||
if (updates.default_value !== undefined) requestData.default_value = updates.default_value ?? undefined;
|
||||
if (updates.placeholder !== undefined) requestData.placeholder = updates.placeholder ?? undefined;
|
||||
// display_condition은 API 형식 불일치로 전송하지 않음 (로컬 상태에서만 관리)
|
||||
if (updates.validation_rules !== undefined) requestData.validation_rules = updates.validation_rules ?? undefined;
|
||||
if (updates.options !== undefined) requestData.options = updates.options ?? undefined;
|
||||
if (updates.properties !== undefined) requestData.properties = updates.properties ?? undefined;
|
||||
|
||||
const response = await itemMasterApi.fields.update(fieldId, requestData);
|
||||
|
||||
@@ -1617,7 +1736,7 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
// API 호출
|
||||
const response = await itemMasterApi.fields.reorder(sectionId, {
|
||||
items: fieldIds.map((id, index) => ({ id, order_no: index }))
|
||||
field_orders: fieldIds.map((id, index) => ({ id, order_no: index }))
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -1664,16 +1783,16 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
// BOM CRUD with API
|
||||
const addBOMItem = async (sectionId: number, bomData: Omit<BOMItem, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
try {
|
||||
// API 호출
|
||||
// API 호출 (null → undefined 변환)
|
||||
const response = await itemMasterApi.bomItems.create(sectionId, {
|
||||
item_code: bomData.item_code,
|
||||
item_code: bomData.item_code ?? undefined,
|
||||
item_name: bomData.item_name,
|
||||
quantity: bomData.quantity,
|
||||
unit: bomData.unit,
|
||||
unit_price: bomData.unit_price,
|
||||
total_price: bomData.total_price,
|
||||
spec: bomData.spec,
|
||||
note: bomData.note,
|
||||
unit: bomData.unit ?? undefined,
|
||||
unit_price: bomData.unit_price ?? undefined,
|
||||
total_price: bomData.total_price ?? undefined,
|
||||
spec: bomData.spec ?? undefined,
|
||||
note: bomData.note ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -1716,16 +1835,16 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const updateBOMItem = async (bomId: number, updates: Partial<BOMItem>) => {
|
||||
try {
|
||||
// API 호출
|
||||
// API 호출 (null → undefined 변환)
|
||||
const requestData: any = {};
|
||||
if (updates.item_code !== undefined) requestData.item_code = updates.item_code;
|
||||
if (updates.item_code !== undefined) requestData.item_code = updates.item_code ?? undefined;
|
||||
if (updates.item_name !== undefined) requestData.item_name = updates.item_name;
|
||||
if (updates.quantity !== undefined) requestData.quantity = updates.quantity;
|
||||
if (updates.unit !== undefined) requestData.unit = updates.unit;
|
||||
if (updates.unit_price !== undefined) requestData.unit_price = updates.unit_price;
|
||||
if (updates.total_price !== undefined) requestData.total_price = updates.total_price;
|
||||
if (updates.spec !== undefined) requestData.spec = updates.spec;
|
||||
if (updates.note !== undefined) requestData.note = updates.note;
|
||||
if (updates.unit !== undefined) requestData.unit = updates.unit ?? undefined;
|
||||
if (updates.unit_price !== undefined) requestData.unit_price = updates.unit_price ?? undefined;
|
||||
if (updates.total_price !== undefined) requestData.total_price = updates.total_price ?? undefined;
|
||||
if (updates.spec !== undefined) requestData.spec = updates.spec ?? undefined;
|
||||
if (updates.note !== undefined) requestData.note = updates.note ?? undefined;
|
||||
|
||||
const response = await itemMasterApi.bomItems.update(bomId, requestData);
|
||||
|
||||
@@ -1837,16 +1956,19 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
deleteMaterialItemName,
|
||||
|
||||
itemMasterFields,
|
||||
loadItemMasterFields,
|
||||
addItemMasterField,
|
||||
updateItemMasterField,
|
||||
deleteItemMasterField,
|
||||
|
||||
sectionTemplates,
|
||||
loadSectionTemplates,
|
||||
addSectionTemplate,
|
||||
updateSectionTemplate,
|
||||
deleteSectionTemplate,
|
||||
|
||||
itemPages,
|
||||
loadItemPages,
|
||||
addItemPage,
|
||||
updateItemPage,
|
||||
deleteItemPage,
|
||||
@@ -1902,6 +2024,8 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
clearCache,
|
||||
resetAllData,
|
||||
|
||||
tenantId,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,13 +3,19 @@ import { useState, useEffect } from 'react';
|
||||
/**
|
||||
* 현재 시간을 반환하는 최적화된 훅
|
||||
* 1분마다 자동 업데이트
|
||||
*
|
||||
* ✅ SSR-safe: useEffect를 사용하여 클라이언트에서만 시간 초기화
|
||||
* 이를 통해 서버/클라이언트 하이드레이션 불일치 방지
|
||||
*/
|
||||
export function useCurrentTime(updateInterval = 60000) {
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
new Date().toLocaleString('ko-KR')
|
||||
);
|
||||
// ✅ 초기값을 빈 문자열로 설정 (서버 렌더링 시 안전)
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// ✅ 클라이언트 마운트 시 즉시 현재 시간 설정
|
||||
setCurrentTime(new Date().toLocaleString('ko-KR'));
|
||||
|
||||
// 주기적 업데이트
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date().toLocaleString('ko-KR'));
|
||||
}, updateInterval);
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -156,6 +156,7 @@ export interface ItemFieldResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
section_id: number;
|
||||
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
|
||||
field_name: string;
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
order_no: number;
|
||||
|
||||
Reference in New Issue
Block a user