16 KiB
16 KiB
카테고리 시스템 재구현 계획서
기존 구조의 문제점
❌ 잘못 이해한 부분
-
테이블 타입 관리에서 직접 카테고리 값 관리
- 카테고리가 전역으로 관리됨
- 메뉴별 스코프가 없음
-
모든 메뉴에서 사용 가능한 전역 카테고리
- 구매관리에서 만든 카테고리를 영업관리에서도 사용 가능
- 메뉴 간 격리가 안됨
올바른 구조
✅ 메뉴 계층 기반 카테고리 스코프
구매관리 (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}
/>
);
// ...
}
}
테이블 타입 관리 통합 제거
기존 코드 제거
-
app/(main)/admin/tableMng/page.tsx에서 제거:- "카테고리 값 관리" 버튼 제거
- CategoryValueManagerDialog import 제거
- 관련 상태 및 핸들러 제거
-
CategoryValueManagerDialog.tsx삭제:- Dialog 래퍼 컴포넌트 삭제
이유: 카테고리는 화면관리 시스템에서만 관리해야 함
사용 시나리오
1. 카테고리 관리 화면 생성
- 메뉴 등록: 구매관리 > 카테고리 관리 (menu_id: 103)
- 화면 생성: 카테고리 관리 화면 생성
- 테이블 연결: 테이블 선택 (예:
purchase_orders) - 위젯 배치: 카테고리 관리 위젯 드래그앤드롭
2. 카테고리 값 등록
-
좌측 패널:
purchase_orders테이블의 카테고리 컬럼 목록 표시order_type(발주 유형)order_status(발주 상태)priority(우선순위)
-
컬럼 선택:
order_type클릭 -
우측 패널: 카테고리 값 관리
- "추가" 버튼 클릭
- 코드:
MATERIAL, 라벨:자재 발주 - 색상:
#3b82f6, 설명:생산 자재 발주 - 저장 시
menu_id = 103으로 자동 저장됨
3. 다른 화면에서 카테고리 사용
-
발주 관리 화면 (menu_id: 101, 형제 메뉴)
order_type컬럼을 Code Select 위젯으로 배치- 드롭다운에
자재 발주,외주 발주등 표시됨 ✅
-
영업관리 > 주문 관리 (다른 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시간)
- ✅ DB 마이그레이션:
menu_id컬럼 추가 - ⏳ 백엔드 타입 수정:
menuId필드 추가 - ⏳ 백엔드 서비스: 메뉴 스코프 로직 구현
- ⏳ API 컨트롤러:
menuId파라미터 추가
Phase 2: 프론트엔드 컴포넌트 (2-3시간)
- ⏳ CategoryWidget 생성 (좌우 분할)
- ⏳ CategoryColumnList 복원 및 수정
- ⏳ CategoryValueManager에
menuId추가 - ⏳ API 클라이언트 수정
Phase 3: 화면관리 시스템 통합 (1-2시간)
- ⏳ ComponentType에
category-manager추가 - ⏳ 위젯 팔레트에 추가
- ⏳ RealtimePreview 렌더링 추가
- ⏳ Config Panel 생성
Phase 4: 정리 (30분)
- ⏳ 테이블 타입 관리에서 카테고리 Dialog 제거
- ⏳ 불필요한 파일 제거
- ⏳ 테스트 및 문서화
예상 소요 시간
- 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에
menuIdprops 추가 - API 클라이언트 수정
화면관리 시스템
- ComponentType 추가
- 위젯 팔레트 추가
- RealtimePreview 렌더링
- Config Panel 생성
정리
- 테이블 타입 관리 Dialog 제거
- 불필요한 파일 삭제
- 테스트
- 문서 작성
지금 바로 구현을 시작할까요?