Files
sam-docs/history/2025-11/item-master-archived/[ANALYSIS] item-master-data-management.md
hskwon ceae830e41 docs: 2025-11 문서 아카이브 이동
- history/2025-11/front-requests/ 프론트 요청 문서 이동
- history/2025-11/item-master-archived/ Item Master 구버전 문서 이동
2025-12-09 20:30:39 +09:00

72 KiB

1# 품목기준관리 시스템 분석 및 구현 계획

작성일: 2025-11-17 소스: React 프로젝트 ItemMasterDataManagement.tsx (1,413줄) 타겟: Next.js 15 마이그레이션 상태: 📊 분석 완료, 구현 대기


📑 목차

  1. 프로젝트 배경 및 목표
  2. 시스템 개요
  3. 데이터 구조 분석
  4. 기능 분석
  5. API 요구사항
  6. 버전관리 시스템
  7. 멀티테넌시 및 데이터 로딩 전략
  8. Next.js 15 마이그레이션 계획
  9. 구현 우선순위
  10. 다음 단계

1. 프로젝트 배경 및 목표

1.1 배경

현재 구현된 품목등록 화면 (ItemForm.tsx)은 고정된 값(템플릿)으로 구성되어 있습니다. 실제 운영 환경에서는 이 고정값들을 동적으로 설정/편집할 수 있어야 하며, 이를 위한 별도의 품목기준관리 페이지가 필요합니다.

품목기준관리 페이지 (ItemMasterDataManagement)
    ↓ 설정/편집
품목등록 화면 (ItemForm)
    ↓ 사용자 입력
실제 품목 데이터

1.2 핵심 요구사항

  1. 드롭다운 옵션 관리: 단위, 재질, 표면처리 등
  2. 페이지 구조 관리: 섹션, 하위섹션, 항목 계층 구조
  3. 마스터 항목 템플릿: 재사용 가능한 항목 정의
  4. 조건부 표시 로직: 특정 값에 따라 항목 표시/숨김
  5. 품목 코드 자동생성 규칙: 동적으로 설정 가능한 규칙 체계
  6. 다양한 업종 지원: 고객사별 자유로운 페이지 구성
  7. 버전관리 시스템: 품목 수정 이력 추적 및 이전 버전 조회

1.3 목표

1차 목표: 품목기준관리 페이지 구축 및 Laravel API 연동 2차 목표: 품목등록 화면을 동적 템플릿 시스템으로 전환 (선택적)


2. 시스템 개요

2.1 React 프로젝트 파일 정보

파일 경로: /sma-react-v2.0/src/components/ItemMasterDataManagement.tsx 파일 크기: 1,413줄 주요 의존성:

  • DataContext - 전역 상태 관리 (6,697줄)
  • SpecificationManagement - 규격 관리 서브 컴포넌트
  • DraggableField - 드래그 앤 드롭 항목

2.2 6개 탭 구조

┌─────────────────────────────────────────────────────────────┐
│                     품목기준관리 메인 화면                    │
└─────────────────────────────────────────────────────────────┘
    │
    ├─ 📊 계층구조 (hierarchy)
    │   └─ 섹션 > 하위섹션 > 항목 트리 구조
    │
    ├─ 📋 항목 (items)
    │   └─ 마스터 항목 템플릿 (제품/부품/부자재/원자재별)
    │
    ├─ 📏 단위 (units)
    │   └─ EA, SET, KG, M, L, BOX, PCS 등
    │
    ├─ 🧱 재질 (materials)
    │   └─ EGI 1.2T, SUS 1.2T, SPHC-SD 등
    │
    ├─ 🎨 표면처리 (surface)
    │   └─ 무도장, 파우더도장, 아연도금 등
    │
    └─ 📐 규격 (specifications)
        └─ 별도 SpecificationManagement 컴포넌트

2.3 데이터 흐름

사용자
   ↓
품목기준관리 페이지
   ↓ 설정 저장
Laravel API (DB)
   ↓ 조회
품목등록 화면 (ItemForm)
   ↓ 드롭다운, 필드 옵션 표시
사용자 입력

3. 데이터 구조 분석

3.1 계층구조 데이터

ItemPage (섹션)

interface ItemPage {
  id: string;                        // 예: "PAGE-1234567890"
  pageName: string;                  // 예: "품목 등록"
  itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목 유형
  sections: ItemSection[];           // 하위섹션 배열
  isActive: boolean;                 // 활성 여부
  absolutePath?: string;             // 절대경로 (선택적)
  createdAt: string;                 // 생성일
  updatedAt?: string;                // 수정일
}

예시:

{
  "id": "PAGE-001",
  "pageName": "제품(FG) 등록",
  "itemType": "FG",
  "sections": [...],
  "isActive": true,
  "createdAt": "2025-01-10"
}

ItemSection (하위섹션)

interface ItemSection {
  id: string;                        // 예: "SECTION-1234567890"
  title: string;                     // 예: "기본정보"
  description?: string;              // 설명 (선택)
  category?: string[];               // 카테고리 조건 (선택)
  fields: ItemField[];               // 항목 배열
  type?: 'fields' | 'bom';           // 섹션 타입 (선택)
  order: number;                     // 표시 순서
  isCollapsible: boolean;            // 접기/펼치기 가능 여부
  isCollapsed: boolean;              // 기본 접힘 상태
  createdAt: string;                 // 생성일
}

예시:

{
  "id": "SECTION-001",
  "title": "기본정보",
  "description": "품목의 기본 정보를 입력합니다",
  "fields": [...],
  "order": 1,
  "isCollapsible": true,
  "isCollapsed": false,
  "createdAt": "2025-01-10"
}

ItemField (항목)

interface ItemField {
  id: string;                        // 예: "FIELD-1234567890"
  name: string;                      // 예: "품목코드"
  fieldKey: string;                  // 예: "itemCode"
  property: ItemFieldProperty;       // 입력 속성
  description?: string;              // 설명 (선택)
  displayCondition?: FieldDisplayCondition; // 조건부 표시 (선택)
  masterFieldId?: string;            // 마스터 항목 ID (선택)
  order?: number;                    // 표시 순서 (선택)
  createdAt: string;                 // 생성일
}

예시:

{
  "id": "FIELD-001",
  "name": "품목코드",
  "fieldKey": "itemCode",
  "property": {
    "inputType": "textbox",
    "required": true,
    "row": 1,
    "col": 1
  },
  "description": "품목을 식별하는 고유 코드",
  "createdAt": "2025-01-10"
}

ItemFieldProperty (입력 속성)

interface ItemFieldProperty {
  inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
  required: boolean;                 // 필수 여부
  row: number;                       // 레이아웃 행
  col: number;                       // 레이아웃 열
  options?: string[];                // 드롭다운 옵션 (dropdown일 때)
}

예시 (드롭다운):

{
  "inputType": "dropdown",
  "required": true,
  "row": 1,
  "col": 1,
  "options": ["EA", "SET", "KG", "M"]
}

FieldDisplayCondition (조건부 표시)

interface FieldDisplayCondition {
  fieldKey: string;                  // 조건 필드 키
  expectedValue: string;             // 기대 값
}

예시:

{
  "fieldKey": "itemType",
  "expectedValue": "FG"
}

의미: itemType 필드 값이 "FG"일 때만 이 항목을 표시


3.2 마스터 항목 데이터

ItemMasterField

interface ItemMasterField {
  id: string;                        // 예: "MASTER-1234567890"
  name: string;                      // 예: "품목코드"
  fieldKey: string;                  // 예: "itemCode"
  property: ItemFieldProperty;       // 입력 속성
  category?: string;                 // 카테고리 (예: "공통", "제품", "부품")
  description?: string;              // 설명
  isActive: boolean;                 // 활성 여부
  createdAt: string;                 // 생성일
}

역할:

  • 재사용 가능한 항목 템플릿
  • 여러 섹션/페이지에서 참조 가능
  • 품목 분류별로 관리 (공통, 제품, 부품, 부자재, 원자재)

예시:

{
  "id": "MASTER-001",
  "name": "품목코드",
  "fieldKey": "itemCode",
  "property": {
    "inputType": "textbox",
    "required": true,
    "row": 1,
    "col": 1
  },
  "category": "공통",
  "description": "모든 품목에 공통으로 사용되는 품목코드",
  "isActive": true,
  "createdAt": "2025-01-10"
}

3.3 옵션 데이터 (단위/재질/표면처리)

MasterOption

interface MasterOption {
  id: string;                        // 예: "unit-1", "mat-1", "surf-1"
  value: string;                     // 코드 값 (예: "EA", "EGI 1.2T")
  label: string;                     // 표시명 (예: "EA (개)", "EGI 1.2T")
  isActive: boolean;                 // 활성 여부
}

관리 대상:

  1. 단위 (units): EA, SET, KG, M, L, BOX, PCS
  2. 재질 (materials): EGI 1.2T, EGI 2.0T, SUS 1.2T, SPHC-SD 1.6T
  3. 표면처리 (surface): 무도장, 파우더도장, 아연도금

예시 (단위):

[
  { "id": "unit-1", "value": "EA", "label": "EA (개)", "isActive": true },
  { "id": "unit-2", "value": "SET", "label": "SET (세트)", "isActive": true },
  { "id": "unit-3", "value": "KG", "label": "KG (킬로그램)", "isActive": true }
]

3.4 로컬스토리지 키 구조

React 프로젝트에서는 로컬스토리지를 사용하지만, Next.js에서는 Laravel API로 대체합니다.

현재 (React):

const UNIT_OPTIONS_KEY = 'unit-options';
const MATERIAL_OPTIONS_KEY = 'material-options';
const SURFACE_TREATMENT_OPTIONS_KEY = 'surface-treatment-options';
const ITEM_CLASSIFICATIONS_KEY = 'item-classifications';

목표 (Next.js + Laravel):

GET /api/master-data/units
GET /api/master-data/materials
GET /api/master-data/surface-treatments
GET /api/master-data/pages
GET /api/master-data/fields

4. 기능 분석

4.1 계층구조 탭

주요 기능:

  1. 섹션 생성: 품목 유형별 페이지 생성 (FG/PT/SM/RM/CS)
  2. 하위섹션 추가: 섹션 내 그룹 추가 (기본정보, BOM, 가격정보 등)
  3. 항목 추가/편집: 입력 필드 정의 및 수정
  4. 드래그 앤 드롭: 항목 순서 변경
  5. 조건부 표시: 특정 값에 따라 항목 표시/숨김
  6. 마스터 항목 연동: 마스터 항목 템플릿 선택

UI 구조:

┌─────────────────────────────────────────────────────┐
│  섹션 목록 (좌측)     │   계층구조 상세 (우측)       │
│                       │                             │
│  - 제품(FG) 등록      │   ┌─ 기본정보 섹션          │
│  - 부품(PT) 등록      │   │  - 품목코드             │
│  - 부자재(SM) 등록    │   │  - 품목명               │
│  - 원자재(RM) 등록    │   │  - 단위                 │
│  - 소모품(CS) 등록    │   │  [+ 항목 추가]          │
│                       │   │                         │
│  [+ 섹션 추가]        │   └─ BOM 섹션               │
│                       │      - 하위 품목코드         │
│                       │      - 수량                 │
│                       │      [+ 항목 추가]          │
└─────────────────────────────────────────────────────┘

항목 추가 다이얼로그:

  • 마스터 항목에서 불러오기 (토글)
  • 항목명, 필드 키
  • 입력 방식 (텍스트박스, 드롭다운, 체크박스, 숫자, 날짜, 텍스트영역)
  • 필수 항목 여부
  • 드롭다운 옵션 (쉼표로 구분)
  • 조건부 항목 설정 (조건 필드, 조건 값)
  • 설명

4.2 항목 탭

주요 기능:

  1. 마스터 항목 추가/편집/삭제
  2. 품목 분류별 필터링: 공통, 제품, 부품, 부자재, 원자재
  3. 재사용 가능한 템플릿 관리

UI 구조:

┌─────────────────────────────────────────────────────┐
│  마스터 항목 관리                    [+ 항목 추가]   │
├─────────────────────────────────────────────────────┤
│  [제품] [부품] [부자재] [원자재]  (서브 탭)         │
├─────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────┐   │
│  │ 품목코드  [텍스트박스] [필수] [공통]         │   │
│  │ 필드키: itemCode                       [편집][삭제]│
│  └─────────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────────┐   │
│  │ 단위  [드롭다운] [필수] [공통]               │   │
│  │ 필드키: unit                           [편집][삭제]│
│  │ 옵션: EA, SET, KG, M                         │   │
│  └─────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

4.3 단위/재질/표면처리 탭

공통 기능:

  1. 옵션 추가: 값(코드), 라벨(표시명)
  2. 옵션 삭제
  3. 활성/비활성 관리

UI 구조 (단위 탭 예시):

┌─────────────────────────────────────────────────────┐
│  단위 관리                               [+ 추가]    │
├─────────────────────────────────────────────────────┤
│  번호 │ 값 (Value) │ 라벨 (Label)     │ 작업       │
│  1    │ EA         │ EA (개)          │ [삭제]     │
│  2    │ SET        │ SET (세트)       │ [삭제]     │
│  3    │ KG         │ KG (킬로그램)    │ [삭제]     │
└─────────────────────────────────────────────────────┘

4.4 규격 탭

구현:

  • 별도 SpecificationManagement 컴포넌트
  • React 프로젝트에서 추가 분석 필요

5. API 요구사항

5.1 계층구조 관리 API

섹션 (ItemPage) 관리

// 섹션 목록 조회
GET /api/master-data/pages
Response: {
  success: true,
  data: ItemPage[]
}

// 섹션 생성
POST /api/master-data/pages
Request: {
  pageName: string,
  itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'
}
Response: {
  success: true,
  data: ItemPage
}

// 섹션 수정
PUT /api/master-data/pages/:pageId
Request: {
  pageName?: string,
  isActive?: boolean
}

// 섹션 삭제
DELETE /api/master-data/pages/:pageId

하위섹션 (ItemSection) 관리

// 하위섹션 추가
POST /api/master-data/pages/:pageId/sections
Request: {
  title: string,
  description?: string,
  order: number,
  isCollapsible: boolean,
  isCollapsed: boolean
}
Response: {
  success: true,
  data: ItemSection
}

// 하위섹션 수정
PUT /api/master-data/pages/:pageId/sections/:sectionId
Request: {
  title?: string,
  description?: string,
  order?: number
}

// 하위섹션 삭제
DELETE /api/master-data/pages/:pageId/sections/:sectionId

항목 (ItemField) 관리

// 항목 추가
POST /api/master-data/pages/:pageId/sections/:sectionId/fields
Request: {
  name: string,
  fieldKey: string,
  property: ItemFieldProperty,
  description?: string,
  displayCondition?: FieldDisplayCondition,
  masterFieldId?: string,
  order?: number
}
Response: {
  success: true,
  data: ItemField
}

// 항목 수정
PUT /api/master-data/pages/:pageId/sections/:sectionId/fields/:fieldId
Request: {
  name?: string,
  fieldKey?: string,
  property?: ItemFieldProperty,
  description?: string,
  displayCondition?: FieldDisplayCondition
}

// 항목 순서 변경
PUT /api/master-data/pages/:pageId/sections/:sectionId/fields/reorder
Request: {
  fieldIds: string[]  // 새로운 순서의 필드 ID 배열
}

// 항목 삭제
DELETE /api/master-data/pages/:pageId/sections/:sectionId/fields/:fieldId

5.2 마스터 항목 API

// 마스터 항목 목록 조회 (카테고리별)
GET /api/master-data/fields?category=공통
GET /api/master-data/fields?category=제품
GET /api/master-data/fields?category=부품
Response: {
  success: true,
  data: ItemMasterField[]
}

// 마스터 항목 생성
POST /api/master-data/fields
Request: {
  name: string,
  fieldKey: string,
  property: ItemFieldProperty,
  category?: string,
  description?: string
}
Response: {
  success: true,
  data: ItemMasterField
}

// 마스터 항목 수정
PUT /api/master-data/fields/:fieldId
Request: {
  name?: string,
  fieldKey?: string,
  property?: ItemFieldProperty,
  category?: string,
  description?: string
}

// 마스터 항목 삭제
DELETE /api/master-data/fields/:fieldId

5.3 옵션 관리 API

// 단위 목록 조회
GET /api/master-data/units
Response: {
  success: true,
  data: MasterOption[]
}

// 단위 추가
POST /api/master-data/units
Request: {
  value: string,
  label: string
}

// 단위 삭제
DELETE /api/master-data/units/:unitId

// 재질 목록 조회
GET /api/master-data/materials
Response: {
  success: true,
  data: MasterOption[]
}

// 재질 추가
POST /api/master-data/materials
Request: {
  value: string,
  label: string
}

// 재질 삭제
DELETE /api/master-data/materials/:materialId

// 표면처리 목록 조회
GET /api/master-data/surface-treatments
Response: {
  success: true,
  data: MasterOption[]
}

// 표면처리 추가
POST /api/master-data/surface-treatments
Request: {
  value: string,
  label: string
}

// 표면처리 삭제
DELETE /api/master-data/surface-treatments/:surfaceId

5.4 통합 조회 API

품목등록 화면에서 사용할 마스터 데이터를 한 번에 조회:

GET /api/master-data
Response: {
  success: true,
  data: {
    units: MasterOption[],
    materials: MasterOption[],
    surfaceTreatments: MasterOption[],
    pages: ItemPage[],
    masterFields: ItemMasterField[]
  }
}

6. 버전관리 시스템

6.1 시스템 개요

품목기준관리 시스템은 포괄적인 버전관리 시스템을 포함하여 모든 데이터 수정 이력을 추적합니다. 이는 단순한 감사 로그가 아니라, 이전 버전으로의 롤백과 변경 이력 비교가 가능한 완전한 버전 관리 시스템입니다.

핵심 특징:

  • 전체 데이터 스냅샷: 수정 시마다 이전 상태 전체를 저장
  • 필수 수정 사유: 모든 수정 작업에 사유(revision reason) 입력 강제
  • 버전 번호 자동 증가: currentRevision 필드로 현재 버전 추적
  • 이력 조회: 특정 품목의 전체 수정 이력 확인
  • 이전 버전 복원: previousData를 활용한 롤백 기능
  • 다중 엔티티 지원: 품목, 견적, 수주, 계산식, 단가 등 시스템 전반 적용

버전관리 대상 엔티티:

✅ ItemMaster (품목)
✅ Quote (견적)
✅ Order (수주)
✅ CalculationFormula (계산식)
✅ PricingData (단가)
✅ FormulaRule (수식 규칙)

6.2 데이터 구조

ItemRevision (품목 수정 이력)

export interface ItemRevision {
  revisionNumber: number;           // 수정 버전 번호 (1부터 시작)
  revisionDate: string;              // 수정 날짜 (ISO 8601 형식)
  revisionBy: string;                // 수정자 (사용자 ID 또는 이름)
  revisionReason?: string;           // 수정 사유 (필수)
  changedFields?: string[];          // 변경된 필드 목록 (선택)
  previousData: any;                 // 이전 상태 전체 스냅샷
}

예시:

{
  "revisionNumber": 3,
  "revisionDate": "2025-01-15T10:30:00Z",
  "revisionBy": "admin@example.com",
  "revisionReason": "품목코드 생성 규칙 변경",
  "changedFields": ["autoGenerationRule", "codePattern"],
  "previousData": {
    "id": "PAGE-001",
    "pageName": "제품(FG) 등록",
    "itemType": "FG",
    "sections": [...],
    "currentRevision": 2,
    "revisions": [...]
  }
}

6.3 버전관리 필드

모든 버전관리 대상 엔티티는 다음 필드를 포함합니다:

interface VersionedEntity {
  // ... 기본 필드들

  // 버전관리 필드
  currentRevision: number;           // 현재 버전 번호 (기본값: 0)
  revisions: ItemRevision[];         // 수정 이력 배열
  isFinal?: boolean;                 // 최종 확정 여부 (선택)
}

예시 (ItemPage with Version):

export interface ItemPage {
  id: string;
  pageName: string;
  itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
  sections: ItemSection[];
  isActive: boolean;
  absolutePath?: string;
  createdAt: string;
  updatedAt?: string;

  // 버전관리 필드
  currentRevision: number;           // 현재 버전 (예: 3)
  revisions: ItemRevision[];         // 수정 이력 배열
  isFinal?: boolean;                 // 최종 확정 여부
}

6.4 버전관리 워크플로우

수정 프로세스

사용자가 수정 요청
    ↓
수정 사유 다이얼로그 표시 (필수)
    ↓
사용자가 수정 사유 입력
    ↓
현재 데이터 전체를 previousData로 저장
    ↓
currentRevision 증가 (예: 2 → 3)
    ↓
revisions 배열에 새 ItemRevision 추가
    ↓
수정된 데이터 저장
    ↓
성공 메시지 표시

React 프로젝트 코드 예시 (ItemManagement.tsx:1637-1651):

// 버전 관리 - 수정 시 이력 저장
const currentRevision = selectedItem.currentRevision || 0;
const newRevisionNumber = currentRevision + 1;

const revision = {
  revisionNumber: newRevisionNumber,
  revisionDate: new Date().toISOString().split("T")[0],
  revisionBy: "관리자", // TODO: 실제 사용자 정보로 교체
  revisionReason: revisionReason,
  previousData: { ...selectedItem }
};

pendingUpdates.currentRevision = newRevisionNumber;
pendingUpdates.revisions = [...(selectedItem.revisions || []), revision];

6.5 UI/UX 구성

수정 사유 입력 다이얼로그

<Dialog open={isRevisionDialogOpen}>
  <DialogHeader>
    <DialogTitle>수정 사유 입력</DialogTitle>
    <DialogDescription>
      데이터를 수정하는 이유를 입력해주세요.  정보는 수정 이력에 기록됩니다.
    </DialogDescription>
  </DialogHeader>

  <div className="space-y-4">
    <Textarea
      placeholder="수정 사유를 입력하세요 (필수)"
      value={revisionReason}
      onChange={(e) => setRevisionReason(e.target.value)}
      required
    />
  </div>

  <DialogFooter>
    <Button variant="outline" onClick={cancelRevision}>
      취소
    </Button>
    <Button
      onClick={confirmRevision}
      disabled={!revisionReason.trim()}
    >
      확인  저장
    </Button>
  </DialogFooter>
</Dialog>

수정 이력 조회 UI

┌─────────────────────────────────────────────────────────────┐
│  품목 수정 이력                              [이력 보기]     │
├─────────────────────────────────────────────────────────────┤
│  버전 │ 수정일      │ 수정자       │ 수정 사유             │
│  3    │ 2025-01-15 │ admin       │ 품목코드 규칙 변경     │
│  2    │ 2025-01-10 │ manager     │ 단위 옵션 추가        │
│  1    │ 2025-01-05 │ admin       │ 초기 생성             │
└─────────────────────────────────────────────────────────────┘

[특정 버전 클릭 시]
┌─────────────────────────────────────────────────────────────┐
│  버전 2 상세 정보                                [이전으로]  │
├─────────────────────────────────────────────────────────────┤
│  수정 번호: 2                                               │
│  수정일: 2025-01-10                                         │
│  수정자: manager                                            │
│  수정 사유: 단위 옵션 추가                                  │
│                                                             │
│  변경 내용:                                                 │
│  - 섹션 "기본정보" > 항목 "단위" 옵션 추가: "BOX", "PCS"   │
│                                                             │
│  [이 버전으로 복원]  [변경 내용 비교]                       │
└─────────────────────────────────────────────────────────────┘

6.6 API 요구사항

수정 이력 조회 API

// 특정 품목의 수정 이력 조회
GET /api/master-data/pages/:pageId/revisions
Response: {
  success: true,
  data: {
    currentRevision: number,
    revisions: ItemRevision[]
  }
}

// 특정 버전 상세 조회
GET /api/master-data/pages/:pageId/revisions/:revisionNumber
Response: {
  success: true,
  data: {
    revision: ItemRevision,
    current: ItemPage,
    previous: ItemPage  // previousData에서 복원
  }
}

버전 복원 API

// 특정 버전으로 롤백
POST /api/master-data/pages/:pageId/restore
Request: {
  revisionNumber: number,
  revisionReason: string  // 복원 사유 (필수)
}
Response: {
  success: true,
  data: ItemPage  // 복원된 데이터
}

수정 시 버전 생성

// 모든 PUT 요청에 revisionReason 필수
PUT /api/master-data/pages/:pageId
Request: {
  pageName?: string,
  isActive?: boolean,
  revisionReason: string  // 필수!
}
Response: {
  success: true,
  data: {
    ...updatedPage,
    currentRevision: 3,
    revisions: [...]
  }
}

6.7 데이터베이스 스키마

버전관리 필드 추가 (기존 테이블 확장)

-- item_pages 테이블에 버전 필드 추가
ALTER TABLE item_pages
ADD COLUMN current_revision INT DEFAULT 0,
ADD COLUMN revisions JSON,
ADD COLUMN is_final BOOLEAN DEFAULT FALSE;

-- item_master_fields 테이블에 버전 필드 추가
ALTER TABLE item_master_fields
ADD COLUMN current_revision INT DEFAULT 0,
ADD COLUMN revisions JSON,
ADD COLUMN is_final BOOLEAN DEFAULT FALSE;

-- 옵션 테이블들에도 동일하게 적용
ALTER TABLE master_units
ADD COLUMN current_revision INT DEFAULT 0,
ADD COLUMN revisions JSON;

ALTER TABLE master_materials
ADD COLUMN current_revision INT DEFAULT 0,
ADD COLUMN revisions JSON;

ALTER TABLE master_surface_treatments
ADD COLUMN current_revision INT DEFAULT 0,
ADD COLUMN revisions JSON;

수정 이력 JSON 구조 예시

{
  "revisions": [
    {
      "revisionNumber": 1,
      "revisionDate": "2025-01-05T09:00:00Z",
      "revisionBy": "admin@example.com",
      "revisionReason": "초기 생성",
      "previousData": null
    },
    {
      "revisionNumber": 2,
      "revisionDate": "2025-01-10T14:30:00Z",
      "revisionBy": "manager@example.com",
      "revisionReason": "단위 옵션 추가",
      "changedFields": ["sections[0].fields[2].property.options"],
      "previousData": {
        "id": "PAGE-001",
        "pageName": "제품(FG) 등록",
        "sections": [...]
      }
    },
    {
      "revisionNumber": 3,
      "revisionDate": "2025-01-15T10:30:00Z",
      "revisionBy": "admin@example.com",
      "revisionReason": "품목코드 생성 규칙 변경",
      "changedFields": ["autoGenerationRule"],
      "previousData": {
        "id": "PAGE-001",
        "pageName": "제품(FG) 등록",
        "sections": [...],
        "currentRevision": 2
      }
    }
  ]
}

6.8 구현 고려사항

성능 최적화

문제: previousData에 전체 객체를 저장하면 데이터베이스 용량이 빠르게 증가

해결 방안:

// 옵션 1: 변경된 필드만 저장 (delta 방식)
{
  revisionNumber: 3,
  changedFields: ["pageName", "sections[0].title"],
  delta: {
    pageName: { from: "제품 등록", to: "제품(FG) 등록" },
    "sections[0].title": { from: "기본 정보", to: "기본정보" }
  }
}

// 옵션 2: 압축 저장 (gzip)
{
  revisionNumber: 3,
  previousData: "H4sIAAAAAAAAA..." // gzipped base64
}

// 옵션 3: 주요 버전만 전체 저장
{
  revisionNumber: 3,
  fullSnapshot: false,  // 매 10번째 버전만 true
  changedFields: ["pageName"],
  delta: {...}
}

보안 및 권한

// 버전 관리 권한 체크
interface RevisionPermissions {
  canView: boolean;      // 수정 이력 조회
  canRestore: boolean;   // 이전 버전 복원
  canDelete: boolean;    // 이력 삭제 (관리자만)
}

// API 요청 시 권한 확인
if (!user.hasPermission('VIEW_REVISION_HISTORY')) {
  throw new ForbiddenException('수정 이력을 조회할 권한이 없습니다');
}

이력 보존 정책

// 데이터 보존 정책 설정
interface RetentionPolicy {
  maxRevisions: number;       // 최대 보관 이력 수 (예: 100)
  retentionDays: number;      // 보관 기간 (예: 365일)
  autoArchive: boolean;       // 자동 아카이브 여부
  archiveAfterDays: number;   // 아카이브 시점 (예: 180일)
}

// 오래된 이력 자동 정리
async function cleanupOldRevisions(pageId: string, policy: RetentionPolicy) {
  const page = await fetchPage(pageId);

  // 최대 이력 수 초과 시 가장 오래된 것부터 제거
  if (page.revisions.length > policy.maxRevisions) {
    page.revisions = page.revisions.slice(-policy.maxRevisions);
  }

  // 보존 기간 지난 이력 아카이브
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - policy.archiveAfterDays);

  page.revisions = page.revisions.filter(
    r => new Date(r.revisionDate) > cutoffDate
  );
}

6.9 시스템 전반 적용 범위

버전관리 시스템은 품목기준관리뿐만 아니라 시스템 전체에 적용됩니다:

적용 대상:

item_management:
  - ItemMaster (품목)
  - ItemPage (품목기준관리 페이지)
  - ItemMasterField (마스터 항목)

quote_management:
  - Quote (견적)
  - QuoteTemplate (견적 템플릿)

order_management:
  - Order (수주)
  - OrderTemplate (수주 템플릿)

calculation:
  - CalculationFormula (계산식)
  - FormulaRule (수식 규칙)

pricing:
  - PricingData (단가)
  - PriceTemplate (단가 템플릿)

master_data:
  - MasterUnits (단위)
  - MasterMaterials (재질)
  - MasterSurfaceTreatments (표면처리)

공통 Hook 구현 (React 프로젝트 참조):

// useVersionControl.ts
export function useVersionControl(
  entityName: string,
  filePath: string
) {
  const [isVersionDialogOpen, setIsVersionDialogOpen] = useState(false);
  const [revisionReason, setRevisionReason] = useState("");
  const [pendingUpdates, setPendingUpdates] = useState<any>(null);

  const requestVersionUpdate = (updates: any) => {
    setPendingUpdates(updates);
    setIsVersionDialogOpen(true);
  };

  const confirmVersionUpdate = async () => {
    if (!revisionReason.trim()) {
      alert("수정 사유를 입력해주세요");
      return;
    }

    // 버전 업데이트 로직
    const currentRevision = entity.currentRevision || 0;
    const newRevisionNumber = currentRevision + 1;

    const revision = {
      revisionNumber: newRevisionNumber,
      revisionDate: new Date().toISOString(),
      revisionBy: getCurrentUser(),
      revisionReason: revisionReason,
      previousData: { ...entity }
    };

    const updated = {
      ...entity,
      ...pendingUpdates,
      currentRevision: newRevisionNumber,
      revisions: [...(entity.revisions || []), revision]
    };

    await saveEntity(updated);

    setIsVersionDialogOpen(false);
    setRevisionReason("");
    setPendingUpdates(null);
  };

  return {
    isVersionDialogOpen,
    setIsVersionDialogOpen,
    revisionReason,
    setRevisionReason,
    requestVersionUpdate,
    confirmVersionUpdate
  };
}

7. 멀티테넌시 및 데이터 로딩 전략

📌 상세 구현 가이드: [REF-2025-11-19] multi-tenancy-implementation.md

실제 로그인 응답 구조(tenant.id) 기반 구현 방법, TenantAwareCache 유틸리티, Phase별 로드맵 등 상세 내용 참고

7.1 멀티테넌시 개요

핵심 요구사항: 테넌트(고객사)별로 품목기준관리 구성이 다르게 설정되어야 함

┌─────────────────────────────────────────────────────────────┐
│                      멀티테넌시 구조                          │
└─────────────────────────────────────────────────────────────┘

테넌트 A (제조업)                테넌트 B (유통업)
├─ 품목 유형: FG, PT, RM         ├─ 품목 유형: FG, SM
├─ 필수 필드: 재질, 표면처리     ├─ 필수 필드: 바코드, 원산지
├─ 섹션 구성: 생산정보 포함      ├─ 섹션 구성: 유통정보 포함
└─ 단위: EA, KG, M               └─ 단위: EA, BOX, PCS

구현 방식:

  • 사용자 로그인 시 tenant_id 포함
  • 모든 API 요청에 테넌트 컨텍스트 자동 포함
  • 데이터베이스에서 테넌트별 필터링

7.2 데이터 로딩 전략: 하이브리드 방식

전략 비교

방식 초기 로딩 페이지 전환 데이터 최신성 메모리 사용
Eager Loading 느림 빠름 낮음 높음
Lazy Loading 빠름 느림 높음 낮음
Hybrid (채택) 빠름 빠름 높음 낮음

하이브리드 방식 구조

1단계: 로그인 시 기본 정보만 로드

// POST /api/auth/login 응답
{
  user: {
    id: "user123",
    name: "홍길동",
    tenant_id: "tenant_001"  // 테넌트 식별자
  },
  menu: [...],  // 메뉴 구조만 (구성 데이터 X)
  roles: [...],
  tenant: {
    id: "tenant_001",
    name: "A제조업체"
  }
}

// localStorage 저장 (최소 정보)
localStorage.setItem('user', JSON.stringify({
  ...user,
  menu: transformedMenus,
  tenant_id: tenant.id
}));

2단계: 페이지 접근 시 구성 정보 로드 (캐싱)

// /master-data/item-master-data-management 접근 시
const usePageConfig = (pageType: string) => {
  const [config, setConfig] = useState(null);

  useEffect(() => {
    // 1. 메모리 캐시 확인
    if (pageConfigStore[pageType]) {
      setConfig(pageConfigStore[pageType]);
      return;
    }

    // 2. sessionStorage 캐시 확인
    const cached = sessionStorage.getItem(`page_config_${pageType}`);
    if (cached) {
      const data = JSON.parse(cached);
      setConfig(data);
      pageConfigStore[pageType] = data;
      return;
    }

    // 3. API 요청 (tenant_id 자동 포함)
    fetch(`/api/master-data/pages/${pageType}`)
      .then(res => res.json())
      .then(data => {
        setConfig(data);
        // 캐시 저장
        pageConfigStore[pageType] = data;
        sessionStorage.setItem(`page_config_${pageType}`, JSON.stringify(data));
      });
  }, [pageType]);

  return config;
};

3단계: 구성 변경 시 캐시 무효화

const updatePageConfig = async (pageId: string, updates: any) => {
  await api.put(`/api/master-data/pages/${pageId}`, updates);

  // 캐시 무효화
  delete pageConfigStore[pageType];
  sessionStorage.removeItem(`page_config_${pageType}`);

  // 재로드
  const freshConfig = await api.get(`/api/master-data/pages/${pageType}`);
  setConfig(freshConfig);
};

7.3 Zustand Store 구현

// src/stores/pageConfigStore.ts
import { create } from 'zustand';

interface PageConfig {
  itemType: string;
  sections: ItemSection[];
  fields: ItemField[];
  validationRules: any[];
}

interface PageConfigStore {
  // 메모리 캐시
  configs: Record<string, PageConfig>;

  // 구성 로드
  fetchConfig: (pageType: string) => Promise<PageConfig>;

  // 캐시 무효화
  invalidateConfig: (pageType: string) => void;

  // 구성 업데이트
  updateConfig: (pageType: string, updates: Partial<PageConfig>) => Promise<void>;
}

export const usePageConfigStore = create<PageConfigStore>((set, get) => ({
  configs: {},

  fetchConfig: async (pageType: string) => {
    const { configs } = get();

    // 1. 메모리 캐시 확인
    if (configs[pageType]) {
      console.log(`✅ Cache hit (memory): ${pageType}`);
      return configs[pageType];
    }

    // 2. sessionStorage 확인
    const cached = sessionStorage.getItem(`page_config_${pageType}`);
    if (cached) {
      console.log(`✅ Cache hit (session): ${pageType}`);
      const config = JSON.parse(cached);
      set({ configs: { ...configs, [pageType]: config } });
      return config;
    }

    // 3. API 요청 (tenant_id는 쿠키/헤더에서 자동)
    console.log(`🌐 API request: ${pageType}`);
    const response = await fetch(`/api/master-data/pages/${pageType}`);
    const config = await response.json();

    // 캐시 저장
    set({ configs: { ...configs, [pageType]: config } });
    sessionStorage.setItem(`page_config_${pageType}`, JSON.stringify(config));

    return config;
  },

  invalidateConfig: (pageType: string) => {
    const { configs } = get();
    const newConfigs = { ...configs };
    delete newConfigs[pageType];

    set({ configs: newConfigs });
    sessionStorage.removeItem(`page_config_${pageType}`);

    console.log(`🗑️ Cache invalidated: ${pageType}`);
  },

  updateConfig: async (pageType: string, updates: Partial<PageConfig>) => {
    // API 업데이트
    await fetch(`/api/master-data/pages/${pageType}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updates)
    });

    // 캐시 무효화 후 재로드
    get().invalidateConfig(pageType);
    await get().fetchConfig(pageType);
  }
}));

7.4 API 설계 (멀티테넌시 지원)

테넌트 컨텍스트 자동 포함

// 백엔드: Laravel Middleware
class TenantContextMiddleware {
  public function handle(Request $request, Closure $next) {
    // JWT 토큰 또는 세션에서 tenant_id 추출
    $tenantId = $request->user()->tenant_id;

    // Request에 tenant_id 주입
    $request->merge(['tenant_id' => $tenantId]);

    return $next($request);
  }
}

// 모든 API 쿼리에 자동 적용
ItemPage::where('tenant_id', $tenantId)->get();

API 엔드포인트

// 1. 페이지 구성 조회 (tenant_id 자동 필터링)
GET /api/master-data/pages/{itemType}
Response: {
  success: true,
  data: {
    id: "PAGE-FG-001",
    itemType: "FG",
    tenant_id: "tenant_001",  // 자동 포함
    sections: [...],
    fields: [...]
  }
}

// 2. 구성 저장 (tenant_id 자동 설정)
PUT /api/master-data/pages/{pageId}
Request: {
  sections: [...],
  revisionReason: "단위 옵션 추가"
  // tenant_id는 백엔드에서 자동 설정
}
Response: {
  success: true,
  data: {
    ...updatedPage,
    tenant_id: "tenant_001"  // 자동 설정됨
  }
}

// 3. 테넌트별 옵션 조회
GET /api/master-data/options/units
Response: {
  success: true,
  data: ["EA", "SET", "KG"]  // tenant_001 전용 단위
}

7.5 데이터베이스 스키마 (멀티테넌시)

-- 모든 마스터 테이블에 tenant_id 추가
ALTER TABLE item_pages
ADD COLUMN tenant_id VARCHAR(255) NOT NULL,
ADD INDEX idx_tenant_pages (tenant_id, item_type);

ALTER TABLE item_sections
ADD COLUMN tenant_id VARCHAR(255) NOT NULL,
ADD INDEX idx_tenant_sections (tenant_id);

ALTER TABLE item_master_fields
ADD COLUMN tenant_id VARCHAR(255) NOT NULL,
ADD INDEX idx_tenant_fields (tenant_id);

ALTER TABLE master_units
ADD COLUMN tenant_id VARCHAR(255) NOT NULL,
ADD INDEX idx_tenant_units (tenant_id);

ALTER TABLE master_materials
ADD COLUMN tenant_id VARCHAR(255) NOT NULL,
ADD INDEX idx_tenant_materials (tenant_id);

-- 복합 유니크 제약 (테넌트별 중복 방지)
ALTER TABLE item_pages
ADD UNIQUE KEY uk_tenant_page (tenant_id, page_name, item_type);

7.6 성능 최적화

캐싱 계층

┌──────────────────────────────────────────────────────────┐
│  레벨 1: 메모리 캐시 (Zustand)                            │
│  - 수명: 페이지 새로고침 전까지                           │
│  - 속도: 즉시                                            │
└──────────────────────────────────────────────────────────┘
         ↓ (캐시 미스)
┌──────────────────────────────────────────────────────────┐
│  레벨 2: sessionStorage                                   │
│  - 수명: 브라우저 탭 닫기 전까지                          │
│  - 속도: ~1ms                                            │
└──────────────────────────────────────────────────────────┘
         ↓ (캐시 미스)
┌──────────────────────────────────────────────────────────┐
│  레벨 3: API 요청 (백엔드 캐싱)                           │
│  - 수명: Redis 캐시 (10분)                               │
│  - 속도: ~50-200ms                                       │
└──────────────────────────────────────────────────────────┘
         ↓ (캐시 미스)
┌──────────────────────────────────────────────────────────┐
│  레벨 4: 데이터베이스                                     │
│  - 수명: 영구                                            │
│  - 속도: ~100-500ms                                      │
└──────────────────────────────────────────────────────────┘

백엔드 캐싱 (Laravel)

// Redis 캐시 활용
class PageConfigController {
  public function show($itemType) {
    $tenantId = auth()->user()->tenant_id;
    $cacheKey = "page_config:{$tenantId}:{$itemType}";

    return Cache::remember($cacheKey, 600, function() use ($tenantId, $itemType) {
      return ItemPage::where('tenant_id', $tenantId)
                     ->where('item_type', $itemType)
                     ->with(['sections', 'fields'])
                     ->first();
    });
  }

  public function update($pageId, Request $request) {
    $page = ItemPage::findOrFail($pageId);
    $page->update($request->all());

    // 캐시 무효화
    $cacheKey = "page_config:{$page->tenant_id}:{$page->item_type}";
    Cache::forget($cacheKey);

    return $page;
  }
}

7.7 보안 고려사항

테넌트 격리 (Tenant Isolation)

// 1. 미들웨어에서 tenant_id 검증
if (page.tenant_id !== user.tenant_id) {
  throw new ForbiddenException('다른 테넌트의 데이터에 접근할 수 없습니다');
}

// 2. 모든 쿼리에 tenant_id 필터 강제
ItemPage::where('tenant_id', auth()->user()->tenant_id)->get();

// 3. Global Scope 적용 (Laravel)
class ItemPage extends Model {
  protected static function booted() {
    static::addGlobalScope('tenant', function (Builder $builder) {
      $builder->where('tenant_id', auth()->user()->tenant_id);
    });
  }
}

7.8 하이브리드 로딩 흐름도

사용자 로그인
     ↓
┌─────────────────────────────────────┐
│  기본 정보 로드 (Eager)              │
│  - user { tenant_id }               │
│  - menu 구조                        │
│  - roles                            │
└─────────────────────────────────────┘
     ↓
대시보드 접속 (메뉴만 표시)
     ↓
품목기준관리 페이지 클릭
     ↓
┌─────────────────────────────────────┐
│  페이지 구성 로드 (Lazy + Cache)     │
│  1. 메모리 캐시 확인 → 없음          │
│  2. sessionStorage 확인 → 없음      │
│  3. API 요청 (tenant_id 자동)       │
│  4. 캐시 저장                        │
└─────────────────────────────────────┘
     ↓
페이지 렌더링 (동적 구성)
     ↓
사용자가 구성 변경
     ↓
┌─────────────────────────────────────┐
│  구성 업데이트                       │
│  1. API 업데이트                     │
│  2. 캐시 무효화                      │
│  3. 재로드                           │
└─────────────────────────────────────┘

8. Next.js 15 마이그레이션 계획

8.1 디렉토리 구조

src/
├── app/
│   └── [locale]/
│       └── (protected)/
│           └── master-data/                   # 품목기준관리
│               ├── page.tsx                   # 메인 페이지 (Server Component)
│               └── layout.tsx                 # 레이아웃
│
├── components/
│   ├── master-data/                           # 품목기준관리 컴포넌트
│   │   ├── HierarchyTab.tsx                   # 계층구조 탭 (Client)
│   │   ├── MasterFieldsTab.tsx                # 마스터 항목 탭 (Client)
│   │   ├── UnitsTab.tsx                       # 단위 탭 (Client)
│   │   ├── MaterialsTab.tsx                   # 재질 탭 (Client)
│   │   ├── SurfaceTab.tsx                     # 표면처리 탭 (Client)
│   │   ├── SpecificationsTab.tsx              # 규격 탭 (Client)
│   │   ├── PageManager.tsx                    # 섹션 관리 (Client)
│   │   ├── SectionManager.tsx                 # 하위섹션 관리 (Client)
│   │   ├── FieldManager.tsx                   # 항목 관리 (Client)
│   │   ├── DraggableField.tsx                 # 드래그 앤 드롭 (Client)
│   │   └── dialogs/
│   │       ├── PageDialog.tsx                 # 섹션 추가 다이얼로그
│   │       ├── SectionDialog.tsx              # 하위섹션 추가 다이얼로그
│   │       ├── FieldDialog.tsx                # 항목 추가 다이얼로그
│   │       ├── MasterFieldDialog.tsx          # 마스터 항목 다이얼로그
│   │       └── OptionDialog.tsx               # 옵션 추가 다이얼로그
│   │
│   └── ui/                                    # shadcn/ui 컴포넌트
│       ├── tabs.tsx
│       ├── dialog.tsx
│       ├── switch.tsx
│       └── ...
│
├── stores/
│   └── masterDataStore.ts                     # Zustand - 품목기준관리 상태
│
├── lib/
│   └── api/
│       └── master-data.ts                     # 품목기준관리 API 클라이언트
│
└── types/
    └── master-data.ts                         # 품목기준관리 타입

8.2 타입 정의

src/types/master-data.ts

// 섹션 (ItemPage)
export interface ItemPage {
  id: string;
  pageName: string;
  itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
  sections: ItemSection[];
  isActive: boolean;
  absolutePath?: string;
  createdAt: string;
  updatedAt?: string;
}

// 하위섹션 (ItemSection)
export interface ItemSection {
  id: string;
  title: string;
  description?: string;
  category?: string[];
  fields: ItemField[];
  type?: 'fields' | 'bom';
  order: number;
  isCollapsible: boolean;
  isCollapsed: boolean;
  createdAt: string;
}

// 항목 (ItemField)
export interface ItemField {
  id: string;
  name: string;
  fieldKey: string;
  property: ItemFieldProperty;
  description?: string;
  displayCondition?: FieldDisplayCondition;
  masterFieldId?: string;
  order?: number;
  createdAt: string;
}

// 입력 속성
export interface ItemFieldProperty {
  inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
  required: boolean;
  row: number;
  col: number;
  options?: string[];
}

// 조건부 표시
export interface FieldDisplayCondition {
  fieldKey: string;
  expectedValue: string;
}

// 마스터 항목
export interface ItemMasterField {
  id: string;
  name: string;
  fieldKey: string;
  property: ItemFieldProperty;
  category?: string;
  description?: string;
  isActive: boolean;
  createdAt: string;
}

// 옵션
export interface MasterOption {
  id: string;
  value: string;
  label: string;
  isActive: boolean;
}

// 통합 마스터 데이터
export interface MasterData {
  units: MasterOption[];
  materials: MasterOption[];
  surfaceTreatments: MasterOption[];
  pages: ItemPage[];
  masterFields: ItemMasterField[];
}

8.3 API 클라이언트

src/lib/api/master-data.ts

import type {
  ItemPage,
  ItemSection,
  ItemField,
  ItemMasterField,
  MasterOption,
  MasterData
} from '@/types/master-data';

const API_URL = process.env.NEXT_PUBLIC_API_URL;

// 통합 조회
export async function fetchMasterData(): Promise<MasterData> {
  const response = await fetch(`${API_URL}/api/master-data`, {
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    throw new Error('Failed to fetch master data');
  }

  const data = await response.json();
  return data.data;
}

// 섹션 관리
export async function fetchPages(): Promise<ItemPage[]> {
  const response = await fetch(`${API_URL}/api/master-data/pages`, {
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    throw new Error('Failed to fetch pages');
  }

  const data = await response.json();
  return data.data;
}

export async function createPage(page: Partial<ItemPage>): Promise<ItemPage> {
  const response = await fetch(`${API_URL}/api/master-data/pages`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(page),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to create page');
  }

  const data = await response.json();
  return data.data;
}

export async function updatePage(pageId: string, updates: Partial<ItemPage>): Promise<ItemPage> {
  const response = await fetch(`${API_URL}/api/master-data/pages/${pageId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(updates),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to update page');
  }

  const data = await response.json();
  return data.data;
}

export async function deletePage(pageId: string): Promise<void> {
  const response = await fetch(`${API_URL}/api/master-data/pages/${pageId}`, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to delete page');
  }
}

// 하위섹션 관리
export async function createSection(pageId: string, section: Partial<ItemSection>): Promise<ItemSection> {
  const response = await fetch(`${API_URL}/api/master-data/pages/${pageId}/sections`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(section),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to create section');
  }

  const data = await response.json();
  return data.data;
}

export async function updateSection(
  pageId: string,
  sectionId: string,
  updates: Partial<ItemSection>
): Promise<ItemSection> {
  const response = await fetch(`${API_URL}/api/master-data/pages/${pageId}/sections/${sectionId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(updates),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to update section');
  }

  const data = await response.json();
  return data.data;
}

export async function deleteSection(pageId: string, sectionId: string): Promise<void> {
  const response = await fetch(`${API_URL}/api/master-data/pages/${pageId}/sections/${sectionId}`, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to delete section');
  }
}

// 항목 관리
export async function createField(
  pageId: string,
  sectionId: string,
  field: Partial<ItemField>
): Promise<ItemField> {
  const response = await fetch(`${API_URL}/api/master-data/pages/${pageId}/sections/${sectionId}/fields`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(field),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to create field');
  }

  const data = await response.json();
  return data.data;
}

export async function updateField(
  pageId: string,
  sectionId: string,
  fieldId: string,
  updates: Partial<ItemField>
): Promise<ItemField> {
  const response = await fetch(
    `${API_URL}/api/master-data/pages/${pageId}/sections/${sectionId}/fields/${fieldId}`,
    {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${getToken()}`,
      },
      body: JSON.stringify(updates),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to update field');
  }

  const data = await response.json();
  return data.data;
}

export async function reorderFields(
  pageId: string,
  sectionId: string,
  fieldIds: string[]
): Promise<void> {
  const response = await fetch(
    `${API_URL}/api/master-data/pages/${pageId}/sections/${sectionId}/fields/reorder`,
    {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${getToken()}`,
      },
      body: JSON.stringify({ fieldIds }),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to reorder fields');
  }
}

export async function deleteField(pageId: string, sectionId: string, fieldId: string): Promise<void> {
  const response = await fetch(
    `${API_URL}/api/master-data/pages/${pageId}/sections/${sectionId}/fields/${fieldId}`,
    {
      method: 'DELETE',
      headers: {
        'Authorization': `Bearer ${getToken()}`,
      },
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to delete field');
  }
}

// 마스터 항목 관리
export async function fetchMasterFields(category?: string): Promise<ItemMasterField[]> {
  const url = category
    ? `${API_URL}/api/master-data/fields?category=${category}`
    : `${API_URL}/api/master-data/fields`;

  const response = await fetch(url, {
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    throw new Error('Failed to fetch master fields');
  }

  const data = await response.json();
  return data.data;
}

export async function createMasterField(field: Partial<ItemMasterField>): Promise<ItemMasterField> {
  const response = await fetch(`${API_URL}/api/master-data/fields`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(field),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to create master field');
  }

  const data = await response.json();
  return data.data;
}

export async function updateMasterField(
  fieldId: string,
  updates: Partial<ItemMasterField>
): Promise<ItemMasterField> {
  const response = await fetch(`${API_URL}/api/master-data/fields/${fieldId}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(updates),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to update master field');
  }

  const data = await response.json();
  return data.data;
}

export async function deleteMasterField(fieldId: string): Promise<void> {
  const response = await fetch(`${API_URL}/api/master-data/fields/${fieldId}`, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to delete master field');
  }
}

// 옵션 관리 (단위, 재질, 표면처리)
export async function fetchUnits(): Promise<MasterOption[]> {
  const response = await fetch(`${API_URL}/api/master-data/units`, {
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    throw new Error('Failed to fetch units');
  }

  const data = await response.json();
  return data.data;
}

export async function createUnit(option: Partial<MasterOption>): Promise<MasterOption> {
  const response = await fetch(`${API_URL}/api/master-data/units`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(option),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to create unit');
  }

  const data = await response.json();
  return data.data;
}

export async function deleteUnit(unitId: string): Promise<void> {
  const response = await fetch(`${API_URL}/api/master-data/units/${unitId}`, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to delete unit');
  }
}

// 재질, 표면처리도 동일한 패턴으로 구현...

// Helper function to get token from cookies
function getToken(): string {
  // Implement token retrieval from cookies
  return '';
}

8.4 Zustand Store

src/stores/masterDataStore.ts

import { create } from 'zustand';
import type {
  ItemPage,
  ItemMasterField,
  MasterOption,
  MasterData
} from '@/types/master-data';

interface MasterDataStore {
  // State
  pages: ItemPage[];
  masterFields: ItemMasterField[];
  units: MasterOption[];
  materials: MasterOption[];
  surfaceTreatments: MasterOption[];
  isLoading: boolean;
  error: string | null;

  // Actions
  setMasterData: (data: MasterData) => void;
  setPages: (pages: ItemPage[]) => void;
  addPage: (page: ItemPage) => void;
  updatePage: (pageId: string, updates: Partial<ItemPage>) => void;
  deletePage: (pageId: string) => void;

  setMasterFields: (fields: ItemMasterField[]) => void;
  addMasterField: (field: ItemMasterField) => void;
  updateMasterField: (fieldId: string, updates: Partial<ItemMasterField>) => void;
  deleteMasterField: (fieldId: string) => void;

  setUnits: (units: MasterOption[]) => void;
  addUnit: (unit: MasterOption) => void;
  deleteUnit: (unitId: string) => void;

  setLoading: (isLoading: boolean) => void;
  setError: (error: string | null) => void;
}

export const useMasterDataStore = create<MasterDataStore>((set, get) => ({
  // Initial state
  pages: [],
  masterFields: [],
  units: [],
  materials: [],
  surfaceTreatments: [],
  isLoading: false,
  error: null,

  // Actions
  setMasterData: (data) => set({
    pages: data.pages,
    masterFields: data.masterFields,
    units: data.units,
    materials: data.materials,
    surfaceTreatments: data.surfaceTreatments,
  }),

  setPages: (pages) => set({ pages }),

  addPage: (page) => set((state) => ({
    pages: [...state.pages, page],
  })),

  updatePage: (pageId, updates) => set((state) => ({
    pages: state.pages.map((page) =>
      page.id === pageId ? { ...page, ...updates } : page
    ),
  })),

  deletePage: (pageId) => set((state) => ({
    pages: state.pages.filter((page) => page.id !== pageId),
  })),

  setMasterFields: (fields) => set({ masterFields: fields }),

  addMasterField: (field) => set((state) => ({
    masterFields: [...state.masterFields, field],
  })),

  updateMasterField: (fieldId, updates) => set((state) => ({
    masterFields: state.masterFields.map((field) =>
      field.id === fieldId ? { ...field, ...updates } : field
    ),
  })),

  deleteMasterField: (fieldId) => set((state) => ({
    masterFields: state.masterFields.filter((field) => field.id !== fieldId),
  })),

  setUnits: (units) => set({ units }),

  addUnit: (unit) => set((state) => ({
    units: [...state.units, unit],
  })),

  deleteUnit: (unitId) => set((state) => ({
    units: state.units.filter((unit) => unit.id !== unitId),
  })),

  setLoading: (isLoading) => set({ isLoading }),

  setError: (error) => set({ error }),
}));

8.5 메인 페이지

src/app/[locale]/(protected)/master-data/page.tsx

import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import MasterDataClient from '@/components/master-data/MasterDataClient';

export async function generateMetadata(): Promise<Metadata> {
  const t = await getTranslations('MasterData');

  return {
    title: t('title'), // "품목기준관리"
    description: t('description'), // "품목관리에서 사용되는 기준 정보를 설정하고 관리합니다"
  };
}

export default function MasterDataPage() {
  return <MasterDataClient />;
}

src/components/master-data/MasterDataClient.tsx

'use client'

import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PageHeader } from '@/components/layout/PageHeader';
import HierarchyTab from './HierarchyTab';
import MasterFieldsTab from './MasterFieldsTab';
import UnitsTab from './UnitsTab';
import MaterialsTab from './MaterialsTab';
import SurfaceTab from './SurfaceTab';
import SpecificationsTab from './SpecificationsTab';
import { useMasterDataStore } from '@/stores/masterDataStore';
import { fetchMasterData } from '@/lib/api/master-data';

export default function MasterDataClient() {
  const [activeTab, setActiveTab] = useState('hierarchy');
  const { setMasterData, setLoading, setError } = useMasterDataStore();

  useEffect(() => {
    async function loadData() {
      setLoading(true);
      try {
        const data = await fetchMasterData();
        setMasterData(data);
      } catch (error: any) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    }

    loadData();
  }, []);

  return (
    <div>
      <PageHeader
        title="품목기준관리"
        description="품목관리에서 사용되는 기준 정보를 설정하고 관리합니다"
        icon={Database}
      />

      <Tabs value={activeTab} onValueChange={setActiveTab}>
        <TabsList className="grid w-full grid-cols-6">
          <TabsTrigger value="hierarchy">계층구조</TabsTrigger>
          <TabsTrigger value="items">항목</TabsTrigger>
          <TabsTrigger value="units">단위</TabsTrigger>
          <TabsTrigger value="materials">재질</TabsTrigger>
          <TabsTrigger value="surface">표면처리</TabsTrigger>
          <TabsTrigger value="specifications">규격</TabsTrigger>
        </TabsList>

        <TabsContent value="hierarchy">
          <HierarchyTab />
        </TabsContent>

        <TabsContent value="items">
          <MasterFieldsTab />
        </TabsContent>

        <TabsContent value="units">
          <UnitsTab />
        </TabsContent>

        <TabsContent value="materials">
          <MaterialsTab />
        </TabsContent>

        <TabsContent value="surface">
          <SurfaceTab />
        </TabsContent>

        <TabsContent value="specifications">
          <SpecificationsTab />
        </TabsContent>
      </Tabs>
    </div>
  );
}

9. 구현 우선순위

Phase 1: 기반 구축 (1주)

✅ 타입 정의 (types/master-data.ts)
✅ API 클라이언트 (lib/api/master-data.ts)
✅ Zustand Store (stores/masterDataStore.ts)
✅ 메인 페이지 구조 (app/[locale]/(protected)/master-data/page.tsx)

Phase 2: 단순 탭 구현 (1주)

✅ 단위 탭 (UnitsTab.tsx)
✅ 재질 탭 (MaterialsTab.tsx)
✅ 표면처리 탭 (SurfaceTab.tsx)
✅ 옵션 추가 다이얼로그 (dialogs/OptionDialog.tsx)

Phase 3: 마스터 항목 탭 (1주)

✅ 마스터 항목 탭 (MasterFieldsTab.tsx)
✅ 품목 분류별 필터링
✅ 마스터 항목 다이얼로그 (dialogs/MasterFieldDialog.tsx)
✅ CRUD 기능 구현

Phase 4: 계층구조 탭 (2주)

✅ 섹션 관리 (PageManager.tsx)
✅ 하위섹션 관리 (SectionManager.tsx)
✅ 항목 관리 (FieldManager.tsx)
✅ 드래그 앤 드롭 (DraggableField.tsx)
✅ 조건부 표시 로직
✅ 마스터 항목 연동

Phase 5: Laravel API 연동 (1주)

✅ 백엔드 API 구현 협의
✅ 데이터베이스 스키마 확정
✅ API 엔드포인트 개발
✅ 프론트엔드 연동 테스트

Phase 6: 품목등록 화면 동적화 (선택적, 2주)

⏳ ItemForm.tsx를 동적 템플릿으로 전환
⏳ MasterData 조회 및 렌더링 로직
⏳ 조건부 표시 적용
⏳ 통합 테스트

총 예상 소요 기간: 6-8주


10. 다음 단계

10.1 즉시 시작 가능한 작업

1. 타입 정의 작성

src/types/master-data.ts 작성
- ItemPage, ItemSection, ItemField
- ItemMasterField
- MasterOption
- MasterData

2. API 클라이언트 작성

src/lib/api/master-data.ts 작성
- fetchMasterData()
- Page CRUD functions
- Section CRUD functions
- Field CRUD functions
- MasterField CRUD functions
- Option CRUD functions

3. Zustand Store 작성

src/stores/masterDataStore.ts 작성
- 기본 상태 정의
- CRUD 액션 구현

4. Laravel API 스펙 확정

- 백엔드 팀과 API 요구사항 논의
- 데이터베이스 스키마 설계
- API 엔드포인트 목록 확정

10.2 백엔드 협의 사항

데이터베이스 테이블 제안:

-- 섹션 (item_pages)
CREATE TABLE item_pages (
  id VARCHAR(50) PRIMARY KEY,
  page_name VARCHAR(100) NOT NULL,
  item_type ENUM('FG', 'PT', 'SM', 'RM', 'CS') NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  absolute_path VARCHAR(255),
  created_at TIMESTAMP,
  updated_at TIMESTAMP
);

-- 하위섹션 (item_sections)
CREATE TABLE item_sections (
  id VARCHAR(50) PRIMARY KEY,
  page_id VARCHAR(50) NOT NULL,
  title VARCHAR(100) NOT NULL,
  description TEXT,
  `order` INT NOT NULL,
  is_collapsible BOOLEAN DEFAULT TRUE,
  is_collapsed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP,
  FOREIGN KEY (page_id) REFERENCES item_pages(id) ON DELETE CASCADE
);

-- 항목 (item_fields)
CREATE TABLE item_fields (
  id VARCHAR(50) PRIMARY KEY,
  section_id VARCHAR(50) NOT NULL,
  name VARCHAR(100) NOT NULL,
  field_key VARCHAR(100) NOT NULL,
  input_type ENUM('textbox', 'dropdown', 'checkbox', 'number', 'date', 'textarea') NOT NULL,
  required BOOLEAN DEFAULT FALSE,
  row_position INT DEFAULT 1,
  col_position INT DEFAULT 1,
  options JSON,
  description TEXT,
  display_condition JSON,
  master_field_id VARCHAR(50),
  `order` INT,
  created_at TIMESTAMP,
  FOREIGN KEY (section_id) REFERENCES item_sections(id) ON DELETE CASCADE,
  FOREIGN KEY (master_field_id) REFERENCES item_master_fields(id) ON DELETE SET NULL
);

-- 마스터 항목 (item_master_fields)
CREATE TABLE item_master_fields (
  id VARCHAR(50) PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  field_key VARCHAR(100) NOT NULL,
  input_type ENUM('textbox', 'dropdown', 'checkbox', 'number', 'date', 'textarea') NOT NULL,
  required BOOLEAN DEFAULT FALSE,
  row_position INT DEFAULT 1,
  col_position INT DEFAULT 1,
  options JSON,
  category VARCHAR(50),
  description TEXT,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP
);

-- 단위 (master_units)
CREATE TABLE master_units (
  id VARCHAR(50) PRIMARY KEY,
  value VARCHAR(50) NOT NULL UNIQUE,
  label VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE
);

-- 재질 (master_materials)
CREATE TABLE master_materials (
  id VARCHAR(50) PRIMARY KEY,
  value VARCHAR(50) NOT NULL UNIQUE,
  label VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE
);

-- 표면처리 (master_surface_treatments)
CREATE TABLE master_surface_treatments (
  id VARCHAR(50) PRIMARY KEY,
  value VARCHAR(50) NOT NULL UNIQUE,
  label VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE
);

API 인증 방식:

  • Laravel Sanctum (쿠키 기반)
  • CSRF 토큰 검증

CORS 설정:

  • Next.js 프론트엔드 도메인 허용
  • Credentials 포함 요청 허용

부록

A. React 프로젝트 참조 파일

주요 파일:

  • ItemMasterDataManagement.tsx (1,413줄) - 메인 컴포넌트
  • DataContext.tsx (6,697줄) - 전역 상태 관리
  • SpecificationManagement.tsx - 규격 관리
  • DraggableField.tsx - 드래그 앤 드롭

로컬스토리지 키:

  • unit-options
  • material-options
  • surface-treatment-options
  • item-classifications

B. 용어 정의

용어 설명
섹션 (ItemPage) 품목 유형별 페이지 (예: 제품(FG) 등록)
하위섹션 (ItemSection) 섹션 내 그룹 (예: 기본정보, BOM)
항목 (ItemField) 입력 필드 (예: 품목코드, 단위)
마스터 항목 (ItemMasterField) 재사용 가능한 항목 템플릿
옵션 (MasterOption) 드롭다운 선택지 (단위, 재질, 표면처리)
조건부 표시 (FieldDisplayCondition) 특정 값일 때만 항목 표시

C. 다음 세션 시작 시 전달 내용

"품목기준관리 시스템 구현을 시작하려고 합니다. [ANALYSIS] item-master-data-management.md 문서를 참조해주세요."


문서 끝