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); 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, getLogConfig,
getLogData, getLogData,
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -187,4 +188,14 @@ router.get("/tables/:tableName/log", getLogData);
*/ */
router.post("/tables/:tableName/log/toggle", toggleLogTable); 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; export default router;

View File

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

View File

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

View File

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

View File

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