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:
parent
668b45d4ea
commit
23911d3dd8
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -473,6 +473,7 @@ export default function ScreenViewPage() {
|
|||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
menuObjid={menuObjid}
|
||||
selectedRowsData={selectedRowsData}
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue