feat: 카테고리 컴포넌트 메뉴 스코프 전환 완료

 구현 내용:
1. 백엔드 API 추가
   - GET /api/table-management/menu/:menuObjid/category-columns
   - 형제 메뉴들의 테이블에서 카테고리 타입 컬럼 조회
   - menuService.getSiblingMenuObjids() 재사용

2. 프론트엔드 CategoryWidget 수정
   - menuObjid를 props로 받아 CategoryColumnList에 전달
   - effectiveMenuObjid로 props.menuObjid도 처리
   - 선택된 컬럼에 tableName 포함하여 상태 관리

3. CategoryColumnList 수정
   - menuObjid 기반으로 형제 메뉴의 모든 카테고리 컬럼 조회
   - 테이블명+컬럼명 함께 표시
   - onColumnSelect에 tableName 전달

4. 메뉴 네비게이션 수정
   - AppLayout.tsx: 화면 이동 시 menuObjid를 URL 쿼리 파라미터로 전달
   - useMenu.ts: 동일하게 menuObjid 전달
   - page.tsx: 자식 컴포넌트에도 menuObjid 전달

🎯 효과:
- 이제 형제 메뉴들이 서로 다른 테이블을 사용해도 카테고리 공유 가능
- 메뉴 클릭 → 화면 이동 시 자동으로 menuObjid 전달
- 카테고리 위젯이 형제 메뉴의 모든 카테고리 컬럼 표시
This commit is contained in:
kjs 2025-11-11 14:44:22 +09:00
parent 668b45d4ea
commit 23911d3dd8
7 changed files with 218 additions and 82 deletions

View File

@ -1599,3 +1599,96 @@ export async function toggleLogTable(
res.status(500).json(response);
}
}
/**
*
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description input_type='category'
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const companyCode = req.user?.companyCode;
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "메뉴 OBJID가 필요합니다.",
});
}
// 1. 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
// 2. 형제 메뉴들이 사용하는 테이블 조회
const { getPool } = await import("../database/db");
const pool = getPool();
const tablesQuery = `
SELECT DISTINCT table_name
FROM screen_definitions
WHERE menu_objid = ANY($1)
AND company_code = $2
AND table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames });
if (tableNames.length === 0) {
return res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
}
// 3. 테이블들의 카테고리 타입 컬럼 조회
const columnsQuery = `
SELECT
table_name AS "tableName",
column_name AS "columnName",
column_label AS "columnLabel",
input_type AS "inputType"
FROM table_type_columns
WHERE table_name = ANY($1)
AND company_code = $2
AND input_type = 'category'
ORDER BY table_name, column_name
`;
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length
});
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패", {
error: error.message,
errorStack: error.stack,
});
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회에 실패했습니다.",
error: error.message,
});
}
}

View File

@ -23,6 +23,7 @@ import {
getLogConfig,
getLogData,
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
} from "../controllers/tableManagementController";
const router = express.Router();
@ -187,4 +188,14 @@ router.get("/tables/:tableName/log", getLogData);
*/
router.post("/tables/:tableName/log/toggle", toggleLogTable);
// ========================================
// 메뉴 기반 카테고리 관리 API
// ========================================
/**
*
* GET /api/table-management/menu/:menuObjid/category-columns
*/
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
export default router;

View File

@ -473,6 +473,7 @@ export default function ScreenViewPage() {
userId={user?.userId}
userName={userName}
companyCode={companyCode}
menuObjid={menuObjid}
selectedRowsData={selectedRowsData}
sortBy={tableSortBy}
sortOrder={tableSortOrder}

View File

@ -274,10 +274,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
// 관리자 모드 상태를 쿼리 파라미터로 전달
const screenPath = isAdminMode
? `/screens/${firstScreen.screenId}?mode=admin`
: `/screens/${firstScreen.screenId}`;
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
const params = new URLSearchParams();
if (isAdminMode) {
params.set("mode", "admin");
}
params.set("menuObjid", menuObjid.toString());
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
router.push(screenPath);
if (isMobile) {

View File

@ -6,20 +6,52 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM
import { GripVertical } from "lucide-react";
interface CategoryWidgetProps {
widgetId: string;
tableName: string; // 현재 화면의 테이블
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
widgetId?: string;
tableName?: string; // 현재 화면의 테이블 (옵션 - 형제 메뉴 전체 표시)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) - 필수
component?: any; // DynamicComponentRenderer에서 전달되는 컴포넌트 정보
[key: string]: any; // 추가 props 허용
}
/**
* ( )
* - 좌측: 현재
* - 좌측: 형제 ( )
* - 우측: 선택된 ( )
*/
export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidgetProps) {
export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...props }: CategoryWidgetProps) {
// menuObjid가 없으면 경고 로그
React.useEffect(() => {
console.log("🔍 CategoryWidget 받은 props:", {
widgetId,
tableName,
menuObjid,
hasComponent: !!component,
propsKeys: Object.keys(props),
propsMenuObjid: props.menuObjid,
allProps: { widgetId, tableName, menuObjid, ...props },
});
if (!menuObjid && !props.menuObjid) {
console.warn("⚠️ CategoryWidget: menuObjid가 전달되지 않았습니다", {
component,
props,
allAvailableProps: { widgetId, tableName, menuObjid, ...props }
});
} else {
console.log("✅ CategoryWidget 렌더링", {
widgetId,
tableName,
menuObjid: menuObjid || props.menuObjid
});
}
}, [menuObjid, widgetId, tableName, component, props]);
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
const effectiveMenuObjid = menuObjid || props.menuObjid;
const [selectedColumn, setSelectedColumn] = useState<{
columnName: string;
columnLabel: string;
tableName: string;
} | null>(null);
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
@ -67,10 +99,10 @@ export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidge
<CategoryColumnList
tableName={tableName}
selectedColumn={selectedColumn?.columnName || null}
onColumnSelect={(columnName, columnLabel) =>
setSelectedColumn({ columnName, columnLabel })
onColumnSelect={(columnName, columnLabel, tableName) =>
setSelectedColumn({ columnName, columnLabel, tableName })
}
menuObjid={menuObjid}
menuObjid={effectiveMenuObjid}
/>
</div>
@ -86,10 +118,10 @@ export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidge
<div style={{ width: `${100 - leftWidth - 1}%` }} className="pl-3">
{selectedColumn ? (
<CategoryValueManager
tableName={tableName}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={menuObjid}
menuObjid={effectiveMenuObjid}
/>
) : (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">

View File

@ -6,6 +6,7 @@ import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { FolderTree, Loader2 } from "lucide-react";
interface CategoryColumn {
tableName: string;
columnName: string;
columnLabel: string;
inputType: string;
@ -13,95 +14,84 @@ interface CategoryColumn {
}
interface CategoryColumnListProps {
tableName: string;
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시)
selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void;
menuObjid?: number; // 현재 메뉴 OBJID (필수)
}
/**
* ( )
* - input_type='category' ( )
* - ( )
*/
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadCategoryColumns();
}, [tableName]);
if (menuObjid) {
loadCategoryColumnsByMenu();
} else {
console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다");
setColumns([]);
}
}, [menuObjid]);
const loadCategoryColumns = async () => {
const loadCategoryColumnsByMenu = async () => {
setIsLoading(true);
try {
// table_type_columns에서 input_type = 'category'인 컬럼 조회
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
console.log("🔍 형제 메뉴의 카테고리 컬럼 조회 시작", { menuObjid });
console.log("🔍 테이블 컬럼 API 응답:", {
tableName,
// 새 API: 형제 메뉴들의 카테고리 컬럼 조회
const response = await apiClient.get(`/table-management/menu/${menuObjid}/category-columns`);
console.log("✅ 메뉴별 카테고리 컬럼 API 응답:", {
menuObjid,
response: response.data,
type: typeof response.data,
isArray: Array.isArray(response.data),
});
// API 응답 구조 파싱 (여러 가능성 대응)
let allColumns: any[] = [];
let categoryColumns: any[] = [];
if (Array.isArray(response.data)) {
// response.data가 직접 배열인 경우
allColumns = response.data;
} else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) {
// response.data.data.columns가 배열인 경우 (table-management API)
allColumns = response.data.data.columns;
} else if (response.data.data && Array.isArray(response.data.data)) {
// response.data.data가 배열인 경우
allColumns = response.data.data;
} else if (response.data.columns && Array.isArray(response.data.columns)) {
// response.data.columns가 배열인 경우
allColumns = response.data.columns;
if (response.data.success && response.data.data) {
categoryColumns = response.data.data;
} else if (Array.isArray(response.data)) {
categoryColumns = response.data;
} else {
console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data);
allColumns = [];
categoryColumns = [];
}
console.log("🔍 파싱된 컬럼 목록:", {
totalColumns: allColumns.length,
sample: allColumns.slice(0, 3),
});
// category 타입만 필터링
const categoryColumns = allColumns.filter(
(col: any) => col.inputType === "category" || col.input_type === "category",
);
console.log("✅ 카테고리 컬럼:", {
console.log("✅ 카테고리 컬럼 파싱 완료:", {
count: categoryColumns.length,
columns: categoryColumns.map((c: any) => ({
name: c.columnName || c.column_name,
type: c.inputType || c.input_type,
table: c.tableName,
column: c.columnName,
label: c.columnLabel,
})),
});
// 각 컬럼의 값 개수 가져오기
const columnsWithCount = await Promise.all(
categoryColumns.map(async (col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.columnLabel || col.column_label || col.displayName || colName;
const colTable = col.tableName;
const colName = col.columnName;
const colLabel = col.columnLabel || colName;
// 각 컬럼의 값 개수 가져오기
let valueCount = 0;
try {
const valuesResult = await getCategoryValues(tableName, colName, false, menuObjid);
const valuesResult = await getCategoryValues(colTable, colName, false, menuObjid);
if (valuesResult.success && valuesResult.data) {
valueCount = valuesResult.data.length;
}
} catch (error) {
console.error(`항목 개수 조회 실패 (${colName}):`, error);
console.error(`항목 개수 조회 실패 (${colTable}.${colName}):`, error);
}
return {
tableName: colTable,
columnName: colName,
columnLabel: colLabel,
inputType: col.inputType || col.input_type,
inputType: col.inputType,
valueCount,
};
}),
@ -112,7 +102,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(firstCol.columnName, firstCol.columnLabel);
onColumnSelect(firstCol.columnName, firstCol.columnLabel, firstCol.tableName);
}
} catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error);
@ -153,27 +143,31 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
</div>
<div className="space-y-2">
{columns.map((column) => (
<div
key={column.columnName}
onClick={() => onColumnSelect(column.columnName, column.columnLabel || column.columnName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${selectedColumn === column.columnName ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
{columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(column.columnName, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${selectedColumn === column.columnName ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
<p className="text-muted-foreground text-xs">{column.tableName}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@ -176,7 +176,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
router.push(`/screens/${firstScreen.screenId}`);
// menuObjid를 쿼리 파라미터로 전달
router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`);
return;
}
}