ERP-node/카테고리_시스템_재구현_계획서.md

16 KiB

카테고리 시스템 재구현 계획서

기존 구조의 문제점

잘못 이해한 부분

  1. 테이블 타입 관리에서 직접 카테고리 값 관리

    • 카테고리가 전역으로 관리됨
    • 메뉴별 스코프가 없음
  2. 모든 메뉴에서 사용 가능한 전역 카테고리

    • 구매관리에서 만든 카테고리를 영업관리에서도 사용 가능
    • 메뉴 간 격리가 안됨

올바른 구조

메뉴 계층 기반 카테고리 스코프

구매관리 (2레벨 메뉴, menu_id: 100)
├── 발주 관리 (menu_id: 101)
├── 입고 관리 (menu_id: 102)
├── 카테고리 관리 (menu_id: 103) ← 여기서 카테고리 생성 (menuId = 103)
└── 거래처 관리 (menu_id: 104)

카테고리 스코프 규칙:

  • 카테고리 관리 화면의 menu_id = 103으로 카테고리 생성
  • 이 카테고리는 같은 부모를 가진 형제 메뉴 (101, 102, 103, 104)에서만 사용 가능
  • 다른 2레벨 메뉴 (예: 영업관리)의 하위에서는 사용 불가

화면관리 시스템 통합

화면 편집기
├── 위젯 팔레트
│   ├── 텍스트 입력
│   ├── 코드 선택
│   ├── 엔티티 조인
│   └── 카테고리 관리 ← 신규 위젯
└── 캔버스
    └── 카테고리 관리 위젯 드래그앤드롭
        ├── 좌측: 현재 화면 테이블의 카테고리 컬럼 목록
        └── 우측: 선택된 컬럼의 카테고리 값 관리

데이터베이스 구조

table_column_category_values 테이블

CREATE TABLE table_column_category_values (
  value_id SERIAL PRIMARY KEY,
  table_name VARCHAR(100) NOT NULL,
  column_name VARCHAR(100) NOT NULL,

  -- 값 정보
  value_code VARCHAR(50) NOT NULL,
  value_label VARCHAR(100) NOT NULL,
  value_order INTEGER DEFAULT 0,

  -- 계층 구조
  parent_value_id INTEGER,
  depth INTEGER DEFAULT 1,

  -- 추가 정보
  description TEXT,
  color VARCHAR(20),
  icon VARCHAR(50),
  is_active BOOLEAN DEFAULT true,
  is_default BOOLEAN DEFAULT false,

  -- 멀티테넌시
  company_code VARCHAR(20) NOT NULL,

  -- 메뉴 스코프 (핵심!)
  menu_id INTEGER NOT NULL,

  -- 메타 정보
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  created_by VARCHAR(50),
  updated_by VARCHAR(50),

  FOREIGN KEY (company_code) REFERENCES company_mng(company_code),
  FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id),
  FOREIGN KEY (parent_value_id) REFERENCES table_column_category_values(value_id),
  UNIQUE (table_name, column_name, value_code, menu_id, company_code)
);

변경사항:

  • menu_id 컬럼 추가 (필수)
  • 외래키: menu_info(menu_id)
  • UNIQUE 제약조건에 menu_id 추가

백엔드 구현

1. 메뉴 스코프 로직

메뉴 계층 구조 조회

/**
 * 메뉴의 형제 메뉴 ID 목록 조회
 * (같은 부모를 가진 메뉴들)
 */
async function getSiblingMenuIds(menuId: number): Promise<number[]> {
  const query = `
    WITH RECURSIVE menu_tree AS (
      -- 현재 메뉴
      SELECT menu_id, parent_id, 0 AS level
      FROM menu_info
      WHERE menu_id = $1
      
      UNION ALL
      
      -- 부모로 올라가기
      SELECT m.menu_id, m.parent_id, mt.level + 1
      FROM menu_info m
      INNER JOIN menu_tree mt ON m.menu_id = mt.parent_id
    )
    -- 현재 메뉴의 직접 부모 찾기
    SELECT parent_id FROM menu_tree WHERE level = 1
  `;

  const parentResult = await pool.query(query, [menuId]);

  if (parentResult.rows.length === 0) {
    // 최상위 메뉴인 경우 자기 자신만 반환
    return [menuId];
  }

  const parentId = parentResult.rows[0].parent_id;

  // 같은 부모를 가진 형제 메뉴들 조회
  const siblingsQuery = `
    SELECT menu_id FROM menu_info WHERE parent_id = $1
  `;
  const siblingsResult = await pool.query(siblingsQuery, [parentId]);

  return siblingsResult.rows.map((row) => row.menu_id);
}

2. API 엔드포인트 수정

기존 API 문제점

// ❌ 잘못된 방식: menu_id 없이 조회
GET /api/table-categories/:tableName/:columnName/values

올바른 API

// ✅ 올바른 방식: menu_id로 필터링
GET /api/table-categories/:tableName/:columnName/values?menuId=103

쿼리 로직:

async getCategoryValues(
  tableName: string,
  columnName: string,
  menuId: number,
  companyCode: string,
  includeInactive: boolean = false
): Promise<TableCategoryValue[]> {
  // 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
  const siblingMenuIds = await this.getSiblingMenuIds(menuId);

  // 2. 카테고리 값 조회
  const query = `
    SELECT *
    FROM table_column_category_values
    WHERE table_name = $1
      AND column_name = $2
      AND menu_id = ANY($3)  -- 형제 메뉴들의 카테고리 포함
      AND (company_code = $4 OR company_code = '*')
      ${!includeInactive ? 'AND is_active = true' : ''}
    ORDER BY value_order, value_label
  `;

  const result = await pool.query(query, [
    tableName,
    columnName,
    siblingMenuIds,
    companyCode,
  ]);

  return result.rows;
}

3. 카테고리 추가 시 menu_id 저장

async addCategoryValue(
  value: TableCategoryValue,
  menuId: number,
  companyCode: string,
  userId: string
): Promise<TableCategoryValue> {
  const query = `
    INSERT INTO table_column_category_values (
      table_name, column_name,
      value_code, value_label, value_order,
      description, color, icon,
      is_active, is_default,
      menu_id, company_code,
      created_by
    ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
    RETURNING *
  `;

  const result = await pool.query(query, [
    value.tableName,
    value.columnName,
    value.valueCode,
    value.valueLabel,
    value.valueOrder || 0,
    value.description,
    value.color,
    value.icon,
    value.isActive !== false,
    value.isDefault || false,
    menuId,  // ← 카테고리 관리 화면의 menu_id
    companyCode,
    userId,
  ]);

  return result.rows[0];
}

프론트엔드 구현

1. 화면관리 위젯: CategoryWidget

// frontend/components/screen/widgets/CategoryWidget.tsx

interface CategoryWidgetProps {
  widgetId: string;
  config: CategoryWidgetConfig;
  menuId: number; // 현재 화면의 menuId
  tableName: string; // 현재 화면의 테이블
}

export function CategoryWidget({
  widgetId,
  config,
  menuId,
  tableName,
}: CategoryWidgetProps) {
  const [selectedColumn, setSelectedColumn] = useState<string | null>(null);

  return (
    <div className="flex h-full gap-6">
      {/* 좌측: 카테고리 컬럼 리스트 */}
      <div className="w-[30%] border-r pr-6">
        <CategoryColumnList
          tableName={tableName}
          menuId={menuId}
          selectedColumn={selectedColumn}
          onColumnSelect={setSelectedColumn}
        />
      </div>

      {/* 우측: 카테고리 값 관리 */}
      <div className="w-[70%]">
        {selectedColumn ? (
          <CategoryValueManager
            tableName={tableName}
            columnName={selectedColumn}
            menuId={menuId}
          />
        ) : (
          <EmptyState message="좌측에서 카테고리 컬럼을 선택하세요" />
        )}
      </div>
    </div>
  );
}

2. 좌측 패널: CategoryColumnList

// frontend/components/table-category/CategoryColumnList.tsx

interface CategoryColumnListProps {
  tableName: string;
  menuId: number;
  selectedColumn: string | null;
  onColumnSelect: (columnName: string) => void;
}

export function CategoryColumnList({
  tableName,
  menuId,
  selectedColumn,
  onColumnSelect,
}: CategoryColumnListProps) {
  const [columns, setColumns] = useState<CategoryColumn[]>([]);

  useEffect(() => {
    loadCategoryColumns();
  }, [tableName, menuId]);

  const loadCategoryColumns = async () => {
    // table_type_columns에서 input_type = 'category'인 컬럼 조회
    const response = await apiClient.get(
      `/table-management/tables/${tableName}/columns`
    );

    const categoryColumns = response.data.columns.filter(
      (col: any) => col.inputType === "category"
    );

    setColumns(categoryColumns);
  };

  return (
    <div className="space-y-3">
      <h3 className="text-lg font-semibold">카테고리 컬럼</h3>
      <div className="space-y-2">
        {columns.map((column) => (
          <div
            key={column.columnName}
            onClick={() => onColumnSelect(column.columnName)}
            className={`cursor-pointer rounded-lg border p-4 transition-all ${
              selectedColumn === column.columnName
                ? "border-primary bg-primary/10"
                : "hover:bg-muted/50"
            }`}
          >
            <h4 className="text-sm font-semibold">{column.columnLabel}</h4>
            <p className="text-xs text-muted-foreground mt-1">
              {column.columnName}
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

3. 우측 패널: CategoryValueManager (수정)

// frontend/components/table-category/CategoryValueManager.tsx

interface CategoryValueManagerProps {
  tableName: string;
  columnName: string;
  menuId: number; // ← 추가
  columnLabel?: string;
}

export function CategoryValueManager({
  tableName,
  columnName,
  menuId,
  columnLabel,
}: CategoryValueManagerProps) {
  const [values, setValues] = useState<TableCategoryValue[]>([]);

  useEffect(() => {
    loadCategoryValues();
  }, [tableName, columnName, menuId]);

  const loadCategoryValues = async () => {
    const response = await getCategoryValues(
      tableName,
      columnName,
      menuId // ← menuId 전달
    );

    if (response.success && response.data) {
      setValues(response.data);
    }
  };

  const handleAddValue = async (newValue: TableCategoryValue) => {
    const response = await addCategoryValue({
      ...newValue,
      tableName,
      columnName,
      menuId, // ← menuId 포함
    });

    if (response.success) {
      loadCategoryValues();
      toast.success("카테고리 값이 추가되었습니다");
    }
  };

  // ... 나머지 CRUD 로직
}

4. API 클라이언트 수정

// frontend/lib/api/tableCategoryValue.ts

export async function getCategoryValues(
  tableName: string,
  columnName: string,
  menuId: number, // ← 추가
  includeInactive: boolean = false
) {
  try {
    const response = await apiClient.get<{
      success: boolean;
      data: TableCategoryValue[];
    }>(`/table-categories/${tableName}/${columnName}/values`, {
      params: { menuId, includeInactive }, // ← menuId 쿼리 파라미터
    });
    return response.data;
  } catch (error: any) {
    console.error("카테고리 값 조회 실패:", error);
    return { success: false, error: error.message };
  }
}

화면관리 시스템 통합

1. ComponentType에 추가

// frontend/types/screen.ts

export type ComponentType =
  | "text-input"
  | "code-select"
  | "entity-join"
  | "category-manager"  // ← 신규
  | "number-input"
  | ...

2. 위젯 팔레트에 추가

// frontend/components/screen/WidgetPalette.tsx

const WIDGET_CATEGORIES = {
  input: [
    { type: "text-input", label: "텍스트 입력", icon: Type },
    { type: "number-input", label: "숫자 입력", icon: Hash },
    // ...
  ],
  reference: [
    { type: "code-select", label: "코드 선택", icon: Code },
    { type: "entity-join", label: "엔티티 조인", icon: Database },
    { type: "category-manager", label: "카테고리 관리", icon: FolderTree }, // ← 신규
  ],
  // ...
};

3. RealtimePreview에 렌더링 추가

// frontend/components/screen/RealtimePreview.tsx

function renderWidget(widget: ScreenWidget) {
  switch (widget.type) {
    case "text-input":
      return <TextInputWidget {...widget} />;
    case "code-select":
      return <CodeSelectWidget {...widget} />;
    case "category-manager": // ← 신규
      return (
        <CategoryWidget
          widgetId={widget.id}
          config={widget.config}
          menuId={currentScreen.menuId}
          tableName={currentScreen.tableName}
        />
      );
    // ...
  }
}

테이블 타입 관리 통합 제거

기존 코드 제거

  1. app/(main)/admin/tableMng/page.tsx에서 제거:

    • "카테고리 값 관리" 버튼 제거
    • CategoryValueManagerDialog import 제거
    • 관련 상태 및 핸들러 제거
  2. CategoryValueManagerDialog.tsx 삭제:

    • Dialog 래퍼 컴포넌트 삭제

이유: 카테고리는 화면관리 시스템에서만 관리해야 함


사용 시나리오

1. 카테고리 관리 화면 생성

  1. 메뉴 등록: 구매관리 > 카테고리 관리 (menu_id: 103)
  2. 화면 생성: 카테고리 관리 화면 생성
  3. 테이블 연결: 테이블 선택 (예: purchase_orders)
  4. 위젯 배치: 카테고리 관리 위젯 드래그앤드롭

2. 카테고리 값 등록

  1. 좌측 패널: purchase_orders 테이블의 카테고리 컬럼 목록 표시

    • order_type (발주 유형)
    • order_status (발주 상태)
    • priority (우선순위)
  2. 컬럼 선택: order_type 클릭

  3. 우측 패널: 카테고리 값 관리

    • "추가" 버튼 클릭
    • 코드: MATERIAL, 라벨: 자재 발주
    • 색상: #3b82f6, 설명: 생산 자재 발주
    • 저장 시 menu_id = 103으로 자동 저장됨

3. 다른 화면에서 카테고리 사용

  1. 발주 관리 화면 (menu_id: 101, 형제 메뉴)

    • order_type 컬럼을 Code Select 위젯으로 배치
    • 드롭다운에 자재 발주, 외주 발주 등 표시됨
  2. 영업관리 > 주문 관리 (다른 2레벨 메뉴)

    • 같은 order_type 컬럼이 있어도
    • 구매관리의 카테고리는 표시되지 않음
    • 영업관리 자체 카테고리만 사용 가능

마이그레이션 작업

1. DB 마이그레이션 실행

psql -U postgres -d plm < db/migrations/036_create_table_column_category_values.sql

2. 기존 카테고리 데이터 마이그레이션

-- 기존 데이터에 menu_id 추가 (임시로 1번 메뉴로 설정)
ALTER TABLE table_column_category_values
ADD COLUMN IF NOT EXISTS menu_id INTEGER DEFAULT 1;

-- 외래키 추가
ALTER TABLE table_column_category_values
ADD CONSTRAINT fk_category_value_menu
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);

구현 순서

Phase 1: DB 및 백엔드 (1-2시간)

  1. DB 마이그레이션: menu_id 컬럼 추가
  2. 백엔드 타입 수정: menuId 필드 추가
  3. 백엔드 서비스: 메뉴 스코프 로직 구현
  4. API 컨트롤러: menuId 파라미터 추가

Phase 2: 프론트엔드 컴포넌트 (2-3시간)

  1. CategoryWidget 생성 (좌우 분할)
  2. CategoryColumnList 복원 및 수정
  3. CategoryValueManager에 menuId 추가
  4. API 클라이언트 수정

Phase 3: 화면관리 시스템 통합 (1-2시간)

  1. ComponentType에 category-manager 추가
  2. 위젯 팔레트에 추가
  3. RealtimePreview 렌더링 추가
  4. Config Panel 생성

Phase 4: 정리 (30분)

  1. 테이블 타입 관리에서 카테고리 Dialog 제거
  2. 불필요한 파일 제거
  3. 테스트 및 문서화

예상 소요 시간

  • Phase 1: 1-2시간
  • Phase 2: 2-3시간
  • Phase 3: 1-2시간
  • Phase 4: 30분
  • 총 예상 시간: 5-8시간

완료 체크리스트

DB

  • menu_id 컬럼 추가
  • 외래키 menu_info(menu_id) 추가
  • UNIQUE 제약조건에 menu_id 추가
  • 인덱스 추가

백엔드

  • 타입에 menuId 추가
  • getSiblingMenuIds() 함수 구현
  • 모든 쿼리에 menu_id 필터링 추가
  • API 파라미터에 menuId 추가

프론트엔드

  • CategoryWidget 생성
  • CategoryColumnList 수정
  • CategoryValueManager에 menuId props 추가
  • API 클라이언트 수정

화면관리 시스템

  • ComponentType 추가
  • 위젯 팔레트 추가
  • RealtimePreview 렌더링
  • Config Panel 생성

정리

  • 테이블 타입 관리 Dialog 제거
  • 불필요한 파일 삭제
  • 테스트
  • 문서 작성

지금 바로 구현을 시작할까요?