From a9d85b780bb82d51b622f4b1833622e4694f945e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 27 Oct 2025 09:49:13 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=81?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=EB=B3=84=20=EC=BB=AC=EB=9F=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/flowController.ts | 2 + backend-node/src/services/flowStepService.ts | 27 +- backend-node/src/types/flow.ts | 14 + frontend/components/flow/FlowStepPanel.tsx | 167 +++++++ .../components/screen/widgets/FlowWidget.tsx | 60 ++- frontend/lib/api/tableManagement.ts | 4 +- frontend/next.config.mjs | 5 +- frontend/types/flow.ts | 14 + frontend/types/screen-management.ts | 12 + 플로우_위젯_컬럼_표시_설정_구현_완료.md | 414 ++++++++++++++++++ 10 files changed, 707 insertions(+), 12 deletions(-) create mode 100644 플로우_위젯_컬럼_표시_설정_구현_완료.md diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index f596af97..b13d6755 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -312,6 +312,7 @@ export class FlowController { fieldMappings, integrationType, integrationConfig, + displayConfig, } = req.body; const step = await this.flowStepService.update(id, { @@ -329,6 +330,7 @@ export class FlowController { fieldMappings, integrationType, integrationConfig, + displayConfig, }); if (!step) { diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index e8cf1fb9..ccb793eb 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -26,9 +26,9 @@ export class FlowStepService { flow_definition_id, step_name, step_order, table_name, condition_json, color, position_x, position_y, move_type, status_column, status_value, target_table, field_mappings, required_fields, - integration_type, integration_config + integration_type, integration_config, display_config ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING * `; @@ -51,6 +51,7 @@ export class FlowStepService { request.integrationConfig ? JSON.stringify(request.integrationConfig) : null, + request.displayConfig ? JSON.stringify(request.displayConfig) : null, ]); return this.mapToFlowStep(result[0]); @@ -209,6 +210,15 @@ export class FlowStepService { paramIndex++; } + // 표시 설정 (displayConfig) + if (request.displayConfig !== undefined) { + fields.push(`display_config = $${paramIndex}`); + params.push( + request.displayConfig ? JSON.stringify(request.displayConfig) : null + ); + paramIndex++; + } + if (fields.length === 0) { return this.findById(id); } @@ -262,6 +272,17 @@ export class FlowStepService { * DB 행을 FlowStep 객체로 변환 */ private mapToFlowStep(row: any): FlowStep { + // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 + const displayConfig = row.display_config; + + // 디버깅 로그 (개발 환경에서만) + if (displayConfig && process.env.NODE_ENV === "development") { + console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, { + type: typeof displayConfig, + value: displayConfig, + }); + } + return { id: row.id, flowDefinitionId: row.flow_definition_id, @@ -282,6 +303,8 @@ export class FlowStepService { // 외부 연동 필드 integrationType: row.integration_type || "internal", integrationConfig: row.integration_config || undefined, + // 표시 설정 + displayConfig: displayConfig || undefined, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 4368ae1a..02510366 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -66,6 +66,14 @@ export interface FlowConditionGroup { conditions: FlowCondition[]; } +// 플로우 단계 표시 설정 +export interface FlowStepDisplayConfig { + visibleColumns?: string[]; // 표시할 컬럼 목록 + columnOrder?: string[]; // 컬럼 순서 (선택사항) + columnLabels?: Record; // 컬럼별 커스텀 라벨 (선택사항) + columnWidths?: Record; // 컬럼별 너비 설정 (px, 선택사항) +} + // 플로우 단계 export interface FlowStep { id: number; @@ -87,6 +95,8 @@ export interface FlowStep { // 외부 연동 필드 integrationType?: FlowIntegrationType; // 연동 타입 (기본값: internal) integrationConfig?: FlowIntegrationConfig; // 연동 설정 (JSONB) + // 🆕 표시 설정 (플로우 위젯에서 사용) + displayConfig?: FlowStepDisplayConfig; // 단계별 컬럼 표시 설정 createdAt: Date; updatedAt: Date; } @@ -111,6 +121,8 @@ export interface CreateFlowStepRequest { // 외부 연동 필드 integrationType?: FlowIntegrationType; integrationConfig?: FlowIntegrationConfig; + // 🆕 표시 설정 + displayConfig?: FlowStepDisplayConfig; } // 플로우 단계 수정 요청 @@ -132,6 +144,8 @@ export interface UpdateFlowStepRequest { // 외부 연동 필드 integrationType?: FlowIntegrationType; integrationConfig?: FlowIntegrationConfig; + // 🆕 표시 설정 + displayConfig?: FlowStepDisplayConfig; } // 플로우 단계 연결 diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index 8dc59a28..71b1be53 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -70,6 +70,8 @@ export function FlowStepPanel({ // 외부 연동 필드 integrationType: step.integrationType || "internal", integrationConfig: step.integrationConfig, + // 🆕 표시 설정 + displayConfig: step.displayConfig || { visibleColumns: [] }, }); const [tableList, setTableList] = useState([]); @@ -90,6 +92,10 @@ export function FlowStepPanel({ const [externalConnections, setExternalConnections] = useState([]); const [loadingConnections, setLoadingConnections] = useState(false); + // 🆕 표시 설정용 컬럼 목록 + const [availableColumns, setAvailableColumns] = useState([]); + const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false); + // 테이블 목록 조회 useEffect(() => { const loadTables = async () => { @@ -157,6 +163,73 @@ export function FlowStepPanel({ loadConnections(); }, []); + // 🆕 테이블이 선택되면 해당 테이블의 컬럼 목록 조회 (표시 설정용) + useEffect(() => { + const loadAvailableColumns = async () => { + const tableName = formData.tableName || flowTableName; + if (!tableName) { + setAvailableColumns([]); + return; + } + + try { + setLoadingAvailableColumns(true); + const response = await getTableColumns(tableName); + + console.log("🎨 [FlowStepPanel] 컬럼 목록 API 응답:", { + tableName, + success: response.success, + dataType: typeof response.data, + dataKeys: response.data ? Object.keys(response.data) : [], + isArray: Array.isArray(response.data), + message: response.message, + fullResponse: response, + }); + + if (response.success && response.data) { + // response.data가 객체일 경우 columns 배열 찾기 + let columnsArray: any[] = []; + + if (Array.isArray(response.data)) { + columnsArray = response.data; + } else if (response.data.columns && Array.isArray(response.data.columns)) { + columnsArray = response.data.columns; + } else if (response.data.data && Array.isArray(response.data.data)) { + columnsArray = response.data.data; + } else { + console.warn("⚠️ 예상치 못한 data 구조:", response.data); + } + + const columnNames = columnsArray.map((col: any) => col.columnName || col.column_name); + setAvailableColumns(columnNames); + + console.log("✅ [FlowStepPanel] 컬럼 목록 로드 성공:", { + tableName, + columns: columnNames, + }); + } else { + console.warn("⚠️ [FlowStepPanel] 컬럼 목록 조회 실패:", { + tableName, + message: response.message, + success: response.success, + hasData: !!response.data, + }); + setAvailableColumns([]); + } + } catch (error) { + console.error("❌ [FlowStepPanel] 컬럼 목록 로드 에러:", { + tableName, + error, + }); + setAvailableColumns([]); + } finally { + setLoadingAvailableColumns(false); + } + }; + + loadAvailableColumns(); + }, [formData.tableName, flowTableName]); + // 외부 DB 선택 시 해당 DB의 테이블 목록 조회 (JWT 토큰 사용) useEffect(() => { const loadExternalTables = async () => { @@ -250,6 +323,8 @@ export function FlowStepPanel({ // 외부 연동 필드 integrationType: step.integrationType || "internal", integrationConfig: step.integrationConfig, + // 표시 설정 (displayConfig 반드시 초기화) + displayConfig: step.displayConfig || { visibleColumns: [] }, }; console.log("✅ Setting formData:", newFormData); @@ -988,6 +1063,98 @@ export function FlowStepPanel({ + {/* 🆕 표시 설정 */} + + + 표시 설정 + 플로우 위젯에서 이 단계의 데이터를 표시할 때 보여줄 컬럼을 선택합니다 + + + {loadingAvailableColumns ? ( +
+ 컬럼 목록을 불러오는 중... +
+ ) : availableColumns.length === 0 ? ( +
+

+ ⚠️ 테이블의 컬럼 목록을 불러올 수 없습니다. +
+ 플로우 테이블:{" "} + {formData.tableName || flowTableName || "없음"} +
+ + 테이블이 존재하지 않거나, 컬럼이 없거나, 접근 권한이 없을 수 있습니다. + +

+
+ ) : ( + <> +
+ +

선택하지 않으면 모든 컬럼이 표시됩니다

+
+
+ { + setFormData({ + ...formData, + displayConfig: { + ...formData.displayConfig, + visibleColumns: e.target.checked ? [...availableColumns] : [], + }, + }); + }} + className="h-4 w-4" + /> + +
+
+ {availableColumns.map((colName) => ( +
+ { + const currentColumns = formData.displayConfig.visibleColumns || []; + const newColumns = e.target.checked + ? [...currentColumns, colName] + : currentColumns.filter((c) => c !== colName); + + setFormData({ + ...formData, + displayConfig: { + ...formData.displayConfig, + visibleColumns: newColumns, + }, + }); + }} + className="h-4 w-4" + /> + +
+ ))} +
+
+ +
+

+ 💡 선택된 컬럼: {formData.displayConfig.visibleColumns?.length || 0}개 + {formData.displayConfig.visibleColumns?.length === 0 && " (모든 컬럼이 표시됩니다)"} +

+
+ + )} + + + {/* 액션 버튼 */}
+
+ ) : ( + setActiveTab(v as any)} className="w-full"> + {recordId && ( + + + 타임라인 ({timeline.length}) + + + 상세 내역 ({detailRecords.length}) + + + )} + {!recordId && ( + <> + + + 전체 변경 이력 ({detailRecords.length}) + + + + {/* 검색창 (전체 테이블 모드) */} +
+ + handleSearch(e.target.value)} + className="h-9 pr-10 pl-10 text-xs sm:h-10 sm:text-sm" + /> + {searchTerm && ( + + )} +
+ + {searchTerm && ( +

+ 검색 결과: {detailRecords.length}개 / 전체 {allRecords.length}개 +

+ )} + + )} + + {/* 타임라인 뷰 */} + + + {timeline.length === 0 ? ( +
+ +

변경 이력이 없습니다

+
+ ) : ( +
+ {timeline.map((event, index) => ( +
+
+ {getOperationIcon(event.operation_type)} +
+ +
+
+ {getOperationBadge(event.operation_type)} + {formatDate(event.changed_at)} +
+ +
+ + {event.changed_by} + {event.ip_address && ( + ({event.ip_address}) + )} +
+ + {event.changes && event.changes.length > 0 && ( +
+

변경된 항목:

+
+ {event.changes.map((change, idx) => ( +
+ {change.column} +
+ {change.oldValue || "(없음)"} + + {change.newValue || "(없음)"} +
+
+ ))} +
+
+ )} +
+
+ ))} +
+ )} +
+
+ + {/* 상세 내역 뷰 */} + + + {detailRecords.length === 0 ? ( +
+ +

변경 내역이 없습니다

+
+ ) : ( + + + + {!recordId && } + + + + + + + + + + {detailRecords.map((record) => { + const displayValue = getDisplayValue(record); + return ( + + {!recordId && ( + + )} + + + + + + + + ); + })} + +
레코드작업컬럼이전 값새 값변경자일시
+ {displayValue ? ( +
+ {displayValue} + (ID: {record.original_id}) +
+ ) : ( + {record.original_id} + )} +
{getOperationBadge(record.operation_type)}{record.changed_column}{record.old_value || "-"}{record.new_value || "-"}{record.changed_by}{formatDate(record.changed_at)}
+ )} +
+
+
+ )} + +
+ +
+ + + ); +} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5cf9f4c3..35e82c7c 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Check, ChevronsUpDown, Search } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -19,6 +20,7 @@ interface ButtonConfigPanelProps { component: ComponentData; onUpdateProperty: (path: string, value: any) => void; allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용 + currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) } interface ScreenOption { @@ -27,20 +29,23 @@ interface ScreenOption { description?: string; } -export const ButtonConfigPanel: React.FC = ({ - component, +export const ButtonConfigPanel: React.FC = ({ + component, onUpdateProperty, allComponents = [], // 🆕 기본값 빈 배열 + currentTableName, // 현재 화면의 테이블명 }) => { - console.log("🎨 ButtonConfigPanel 렌더링:", { - componentId: component.id, - "component.componentConfig?.action?.type": component.componentConfig?.action?.type, - }); - // 🔧 component에서 직접 읽기 (useMemo 제거) const config = component.componentConfig || {}; const currentAction = component.componentConfig?.action || {}; + console.log("🎨 ButtonConfigPanel 렌더링:", { + componentId: component.id, + "component.componentConfig?.action?.type": component.componentConfig?.action?.type, + currentTableName: currentTableName, + "config.action?.historyTableName": config.action?.historyTableName, + }); + // 로컬 상태 관리 (실시간 입력 반영) const [localInputs, setLocalInputs] = useState({ text: config.text !== undefined ? config.text : "버튼", @@ -57,6 +62,12 @@ export const ButtonConfigPanel: React.FC = ({ const [modalSearchTerm, setModalSearchTerm] = useState(""); const [navSearchTerm, setNavSearchTerm] = useState(""); + // 테이블 컬럼 목록 상태 + const [tableColumns, setTableColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + const [displayColumnOpen, setDisplayColumnOpen] = useState(false); + const [displayColumnSearch, setDisplayColumnSearch] = useState(""); + // 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만) useEffect(() => { const latestConfig = component.componentConfig || {}; @@ -103,6 +114,115 @@ export const ButtonConfigPanel: React.FC = ({ fetchScreens(); }, []); + // 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때) + useEffect(() => { + const fetchTableColumns = async () => { + // 테이블 이력 보기 액션이 아니면 스킵 + if (config.action?.type !== "view_table_history") { + return; + } + + // 1. 수동 입력된 테이블명 우선 + // 2. 없으면 현재 화면의 테이블명 사용 + const tableName = config.action?.historyTableName || currentTableName; + + // 테이블명이 없으면 스킵 + if (!tableName) { + return; + } + + try { + setColumnsLoading(true); + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, { + params: { + page: 1, + size: 9999, // 전체 컬럼 가져오기 + }, + }); + + console.log("📋 [ButtonConfigPanel] API 응답:", { + tableName, + success: response.data.success, + hasData: !!response.data.data, + hasColumns: !!response.data.data?.columns, + totalColumns: response.data.data?.columns?.length, + }); + + // API 응답 구조: { success, data: { columns: [...], total, page, totalPages } } + const columnData = response.data.data?.columns; + + if (!columnData || !Array.isArray(columnData)) { + console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData); + setTableColumns([]); + return; + } + + if (response.data.success) { + // ID 컬럼과 날짜 관련 컬럼 제외 + const filteredColumns = columnData + .filter((col: any) => { + const colName = col.columnName.toLowerCase(); + const dataType = col.dataType?.toLowerCase() || ""; + + console.log(`🔍 [필터링 체크] ${col.columnName}:`, { + colName, + dataType, + isId: colName === "id" || colName.endsWith("_id"), + hasDateInType: dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp"), + hasDateInName: + colName.includes("date") || + colName.includes("time") || + colName.endsWith("_at") || + colName.startsWith("created") || + colName.startsWith("updated"), + }); + + // ID 컬럼 제외 (id, _id로 끝나는 컬럼) + if (colName === "id" || colName.endsWith("_id")) { + console.log(` ❌ 제외: ID 컬럼`); + return false; + } + + // 날짜/시간 타입 제외 (데이터 타입 기준) + if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) { + console.log(` ❌ 제외: 날짜/시간 타입`); + return false; + } + + // 날짜/시간 관련 컬럼명 제외 (컬럼명에 date, time, at 포함) + if ( + colName.includes("date") || + colName.includes("time") || + colName.endsWith("_at") || + colName.startsWith("created") || + colName.startsWith("updated") + ) { + console.log(` ❌ 제외: 날짜 관련 컬럼명`); + return false; + } + + console.log(` ✅ 통과`); + return true; + }) + .map((col: any) => col.columnName); + + console.log("✅ [ButtonConfigPanel] 필터링된 컬럼:", { + totalFiltered: filteredColumns.length, + columns: filteredColumns, + }); + + setTableColumns(filteredColumns); + } + } catch (error) { + console.error("❌ 테이블 컬럼 로딩 실패:", error); + } finally { + setColumnsLoading(false); + } + }; + + fetchTableColumns(); + }, [config.action?.type, config.action?.historyTableName, currentTableName]); + // 검색 필터링 함수 const filterScreens = (searchTerm: string) => { if (!searchTerm.trim()) return screens; @@ -185,20 +305,20 @@ export const ButtonConfigPanel: React.FC = ({ key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`} value={component.componentConfig?.action?.type || "save"} onValueChange={(value) => { - console.log("🎯 버튼 액션 드롭다운 변경:", { - oldValue: component.componentConfig?.action?.type, - newValue: value, - }); - - // 🔥 action.type 업데이트 - onUpdateProperty("componentConfig.action.type", value); - - // 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후) - setTimeout(() => { - const newColor = value === "delete" ? "#ef4444" : "#212121"; - console.log("🎨 라벨 색상 업데이트:", { value, newColor }); - onUpdateProperty("style.labelColor", newColor); - }, 100); // 0 → 100ms로 증가 + console.log("🎯 버튼 액션 드롭다운 변경:", { + oldValue: component.componentConfig?.action?.type, + newValue: value, + }); + + // 🔥 action.type 업데이트 + onUpdateProperty("componentConfig.action.type", value); + + // 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후) + setTimeout(() => { + const newColor = value === "delete" ? "#ef4444" : "#212121"; + console.log("🎨 라벨 색상 업데이트:", { value, newColor }); + onUpdateProperty("style.labelColor", newColor); + }, 100); // 0 → 100ms로 증가 }} > @@ -211,6 +331,7 @@ export const ButtonConfigPanel: React.FC = ({ 페이지 이동 모달 열기 제어 흐름 + 테이블 이력 보기
@@ -476,6 +597,162 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 테이블 이력 보기 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "view_table_history" && ( +
+

📜 테이블 이력 보기 설정

+ +
+ + { + onUpdateProperty("componentConfig.action.historyTableName", e.target.value); + }} + /> +

비워두면 현재 화면의 테이블을 자동으로 사용합니다

+
+ +
+ + { + onUpdateProperty("componentConfig.action.historyRecordIdField", e.target.value); + }} + /> +

기본키 컬럼명입니다. 대부분 "id"입니다.

+
+ +
+ + +

테이블 리스트에서 선택된 행의 ID를 사용합니다

+
+ +
+ + { + onUpdateProperty("componentConfig.action.historyRecordLabelField", e.target.value); + }} + /> +

+ 이력 모달에서 "ID 123의 이력" 대신 "홍길동의 이력" 처럼 표시할 때 사용 +

+
+ +
+ + + {!config.action?.historyTableName && !currentTableName ? ( +
+

+ ⚠️ 먼저 테이블명을 입력하거나, 현재 화면에 테이블을 연결해주세요. +

+
+ ) : ( + <> + {!config.action?.historyTableName && currentTableName && ( +
+

+ ✓ 현재 화면의 테이블 {currentTableName}을(를) 자동으로 사용합니다. +

+
+ )} + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {tableColumns.map((column) => ( + { + onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue); + setDisplayColumnOpen(false); + }} + className="text-sm" + > + + {column} + + ))} + + + + + + +

+ 전체 테이블 이력에서 레코드를 구분하기 위한 컬럼입니다. +
+ 예: device_code를 설정하면 "레코드 ID: 5" + 대신 "DTG-001 (ID: 5)"로 표시됩니다. +
이 컬럼으로 검색도 가능합니다. +

+ + {tableColumns.length === 0 && !columnsLoading && ( +

+ ⚠️ ID 및 날짜 타입 컬럼을 제외한 사용 가능한 컬럼이 없습니다. +

+ )} + + )} +
+
+ )} + {/* 페이지 이동 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "navigate" && (
@@ -580,13 +857,12 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 플로우 단계별 표시 제어 섹션 */}
-
); }; - diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 9540c21b..1320778e 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -50,13 +50,13 @@ export const DetailSettingsPanel: React.FC = ({ // 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기 const { webTypes } = useWebTypes({ active: "Y" }); - // console.log(`🔍 DetailSettingsPanel props:`, { - // selectedComponent: selectedComponent?.id, - // componentType: selectedComponent?.type, - // currentTableName, - // currentTable: currentTable?.tableName, - // selectedComponentTableName: selectedComponent?.tableName, - // }); + console.log(`🔍 DetailSettingsPanel props:`, { + selectedComponent: selectedComponent?.id, + componentType: selectedComponent?.type, + currentTableName, + currentTable: currentTable?.tableName, + selectedComponentTableName: selectedComponent?.tableName, + }); // console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개"); // console.log(`🔍 webTypes:`, webTypes); // console.log(`🔍 DetailSettingsPanel selectedComponent:`, selectedComponent); @@ -823,7 +823,14 @@ export const DetailSettingsPanel: React.FC = ({ case "button-primary": case "button-secondary": // 🔧 component.id만 key로 사용 (unmount 방지) - return ; + return ( + + ); case "card": return ; diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 237a94a8..bb6ccf66 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -108,16 +108,13 @@ export const UnifiedPropertiesPanel: React.FC = ({ {currentResolution && onResolutionChange && (
- +

해상도 설정

- +
)} - + {/* 안내 메시지 */}
@@ -156,7 +153,15 @@ export const UnifiedPropertiesPanel: React.FC = ({ case "button-primary": case "button-secondary": // 🔧 component.id만 key로 사용 (unmount 방지) - return ; + return ( + + ); case "card": return ; @@ -198,12 +203,12 @@ export const UnifiedPropertiesPanel: React.FC = ({ return (
{/* 컴포넌트 정보 - 간소화 */} -
+
- - {selectedComponent.type} + + {selectedComponent.type}
- {selectedComponent.id.slice(0, 8)} + {selectedComponent.id.slice(0, 8)}
{/* 라벨 + 최소 높이 (같은 행) */} @@ -609,7 +614,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ {/* 헤더 - 간소화 */}
{selectedComponent.type === "widget" && ( -
+
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
)} @@ -623,13 +628,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ <>
- +

해상도 설정

- +
@@ -637,7 +639,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ {/* 기본 설정 */} {renderBasicTab()} - + {/* 상세 설정 */} {renderDetailTab()} @@ -648,7 +650,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
- +

컴포넌트 스타일

| null; + full_row_after: Record | null; +} + +export interface TableHistoryResponse { + success: boolean; + data?: { + records: TableHistoryRecord[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; + }; + message?: string; + error?: string; + errorCode?: string; +} + +export interface TableHistoryTimelineEvent { + changed_at: string; + changed_by: string; + operation_type: "INSERT" | "UPDATE" | "DELETE"; + ip_address: string | null; + changes: Array<{ + column: string; + oldValue: string | null; + newValue: string | null; + }>; + full_row_before: Record | null; + full_row_after: Record | null; +} + +export interface TableHistoryTimelineResponse { + success: boolean; + data?: TableHistoryTimelineEvent[]; + message?: string; + error?: string; +} + +export interface TableHistorySummary { + operation_type: string; + count: number; + affected_records: number; + unique_users: number; + first_change: string; + last_change: string; +} + +export interface TableHistorySummaryResponse { + success: boolean; + data?: TableHistorySummary[]; + message?: string; + error?: string; +} + +export interface TableHistoryCheckResponse { + success: boolean; + data?: { + tableName: string; + logTableName: string; + exists: boolean; + historyEnabled: boolean; + }; + message?: string; + error?: string; +} + +/** + * 레코드 변경 이력 조회 (recordId가 null이면 전체 테이블 이력) + */ +export async function getRecordHistory( + tableName: string, + recordId: string | number | null, + params?: { + limit?: number; + offset?: number; + operationType?: "INSERT" | "UPDATE" | "DELETE"; + changedBy?: string; + startDate?: string; + endDate?: string; + }, +): Promise { + try { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append("limit", params.limit.toString()); + if (params?.offset) queryParams.append("offset", params.offset.toString()); + if (params?.operationType) queryParams.append("operationType", params.operationType); + if (params?.changedBy) queryParams.append("changedBy", params.changedBy); + if (params?.startDate) queryParams.append("startDate", params.startDate); + if (params?.endDate) queryParams.append("endDate", params.endDate); + + // recordId가 null이면 전체 테이블 이력 조회 + const url = recordId + ? `/table-history/${tableName}/${recordId}?${queryParams.toString()}` + : `/table-history/${tableName}/all?${queryParams.toString()}`; + + const response = await apiClient.get(url); + return response.data; + } catch (error: any) { + console.error("❌ 레코드 이력 조회 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "이력 조회 중 오류가 발생했습니다.", + errorCode: error.response?.data?.errorCode, + }; + } +} + +/** + * 특정 레코드의 타임라인 조회 (그룹화된 이벤트) + */ +export async function getRecordTimeline( + tableName: string, + recordId: string | number, +): Promise { + try { + const response = await apiClient.get(`/table-history/${tableName}/${recordId}/timeline`); + return response.data; + } catch (error: any) { + console.error("❌ 타임라인 조회 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "타임라인 조회 중 오류가 발생했습니다.", + }; + } +} + +/** + * 테이블 전체 이력 요약 + */ +export async function getTableHistorySummary(tableName: string): Promise { + try { + const response = await apiClient.get(`/table-history/${tableName}/summary`); + return response.data; + } catch (error: any) { + console.error("❌ 이력 요약 조회 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "이력 요약 조회 중 오류가 발생했습니다.", + }; + } +} + +/** + * 이력 테이블 존재 여부 확인 + */ +export async function checkHistoryTableExists(tableName: string): Promise { + try { + const response = await apiClient.get(`/table-history/${tableName}/check`); + return response.data; + } catch (error: any) { + console.error("❌ 이력 테이블 확인 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "이력 테이블 확인 중 오류가 발생했습니다.", + }; + } +} diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index d483f9af..00819387 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -38,7 +38,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - + // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -187,12 +187,6 @@ export const ButtonPrimaryComponent: React.FC = ({ const buttonDarkColor = getDarkColor(buttonColor); - console.log("🎨 동적 색상 연동:", { - labelColor: component.style?.labelColor, - buttonColor, - buttonDarkColor, - }); - // 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환 const processedConfig = { ...componentConfig }; if (componentConfig.action && typeof componentConfig.action === "string") { @@ -213,7 +207,6 @@ export const ButtonPrimaryComponent: React.FC = ({ }; } - // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 const componentStyle: React.CSSProperties = { @@ -223,7 +216,6 @@ export const ButtonPrimaryComponent: React.FC = ({ ...style, }; - // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) if (isDesignMode) { componentStyle.borderWidth = "1px"; @@ -279,7 +271,7 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } // 기본 에러 메시지 결정 - const defaultErrorMessage = + const defaultErrorMessage = actionConfig.type === "save" ? "저장 중 오류가 발생했습니다." : actionConfig.type === "delete" @@ -287,16 +279,15 @@ export const ButtonPrimaryComponent: React.FC = ({ : actionConfig.type === "submit" ? "제출 중 오류가 발생했습니다." : "처리 중 오류가 발생했습니다."; - + // 커스텀 메시지 사용 조건: // 1. 커스텀 메시지가 있고 // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) - const useCustomMessage = - actionConfig.errorMessage && - (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장")); - + const useCustomMessage = + actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장")); + const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage; - + toast.error(errorMessage); return; } @@ -305,7 +296,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요) if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") { // 기본 성공 메시지 결정 - const defaultSuccessMessage = + const defaultSuccessMessage = actionConfig.type === "save" ? "저장되었습니다." : actionConfig.type === "delete" @@ -313,14 +304,14 @@ export const ButtonPrimaryComponent: React.FC = ({ : actionConfig.type === "submit" ? "제출되었습니다." : "완료되었습니다."; - + // 커스텀 메시지 사용 조건: // 1. 커스텀 메시지가 있고 // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) - const useCustomMessage = + const useCustomMessage = actionConfig.successMessage && (actionConfig.type === "save" || !actionConfig.successMessage.includes("저장")); - + const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage; toast.success(successMessage); @@ -539,7 +530,8 @@ export const ButtonPrimaryComponent: React.FC = ({ alignItems: "center", justifyContent: "center", // 🔧 크기에 따른 패딩 조정 - padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", + padding: + componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", margin: "0", lineHeight: "1.25", boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`, diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 22486ac9..f6205057 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { toast } from "sonner"; import { screenApi } from "@/lib/api/screen"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; @@ -15,7 +16,8 @@ export type ButtonActionType = | "edit" // 편집 | "navigate" // 페이지 이동 | "modal" // 모달 열기 - | "control"; // 제어 흐름 + | "control" // 제어 흐름 + | "view_table_history"; // 테이블 이력 보기 /** * 버튼 액션 설정 @@ -46,6 +48,13 @@ export interface ButtonActionConfig { enableDataflowControl?: boolean; dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용) dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍 + + // 테이블 이력 보기 관련 + historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정) + historyRecordIdField?: string; // PK 필드명 (기본: "id") + historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스 + historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항) + historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name) } /** @@ -64,7 +73,7 @@ export interface ButtonActionContext { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - + // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -105,6 +114,9 @@ export class ButtonActionExecutor { case "control": return this.handleControl(config, context); + case "view_table_history": + return this.handleViewTableHistory(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -932,7 +944,7 @@ export class ButtonActionExecutor { console.log("🔄 플로우 새로고침 호출"); context.onFlowRefresh(); } - + // 테이블 새로고침 (일반 테이블용) if (context.onRefresh) { console.log("🔄 테이블 새로고침 호출"); @@ -1473,6 +1485,113 @@ export class ButtonActionExecutor { } } + /** + * 테이블 이력 보기 액션 처리 + */ + private static async handleViewTableHistory( + config: ButtonActionConfig, + context: ButtonActionContext, + ): Promise { + console.log("📜 테이블 이력 보기 액션 실행:", { config, context }); + + // 테이블명 결정 (설정 > 컨텍스트 > 폼 데이터) + const tableName = config.historyTableName || context.tableName; + if (!tableName) { + toast.error("테이블명이 지정되지 않았습니다."); + return false; + } + + // 레코드 ID 가져오기 (선택사항 - 없으면 전체 테이블 이력) + const recordIdField = config.historyRecordIdField || "id"; + const recordIdSource = config.historyRecordIdSource || "selected_row"; + + let recordId: any = null; + let recordLabel: string | undefined; + + switch (recordIdSource) { + case "selected_row": + // 선택된 행에서 가져오기 (선택사항) + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + const selectedRow = context.selectedRowsData[0]; + recordId = selectedRow[recordIdField]; + + // 라벨 필드가 지정되어 있으면 사용 + if (config.historyRecordLabelField) { + recordLabel = selectedRow[config.historyRecordLabelField]; + } + } else if (context.flowSelectedData && context.flowSelectedData.length > 0) { + // 플로우 선택 데이터 폴백 + const selectedRow = context.flowSelectedData[0]; + recordId = selectedRow[recordIdField]; + + if (config.historyRecordLabelField) { + recordLabel = selectedRow[config.historyRecordLabelField]; + } + } + break; + + case "form_field": + // 폼 필드에서 가져오기 + recordId = context.formData?.[recordIdField]; + if (config.historyRecordLabelField) { + recordLabel = context.formData?.[config.historyRecordLabelField]; + } + break; + + case "context": + // 원본 데이터에서 가져오기 + recordId = context.originalData?.[recordIdField]; + if (config.historyRecordLabelField) { + recordLabel = context.originalData?.[config.historyRecordLabelField]; + } + break; + } + + // recordId가 없어도 괜찮음 - 전체 테이블 이력 보기 + console.log("📋 이력 조회 대상:", { + tableName, + recordId: recordId || "전체", + recordLabel, + mode: recordId ? "단일 레코드" : "전체 테이블", + }); + + // 이력 모달 열기 (동적 import) + try { + const { TableHistoryModal } = await import("@/components/common/TableHistoryModal"); + const { createRoot } = await import("react-dom/client"); + + // 모달 컨테이너 생성 + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(TableHistoryModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + tableName, + recordId, + recordLabel, + displayColumn: config.historyDisplayColumn, + }), + ); + + return true; + } catch (error) { + console.error("❌ 이력 모달 열기 실패:", error); + toast.error("이력 조회 중 오류가 발생했습니다."); + return false; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -1539,4 +1658,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record Date: Mon, 27 Oct 2025 11:41:30 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=EC=9D=B4=EB=A0=A5=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B8=B0=EC=A4=80=20=EC=BB=AC=EB=9F=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/TableHistoryModal.tsx | 7 +- .../screen/RealtimePreviewDynamic.tsx | 5 - frontend/components/screen/ScreenDesigner.tsx | 193 ------------------ .../config-panels/ButtonConfigPanel.tsx | 47 +---- .../components/screen/widgets/FlowWidget.tsx | 32 --- .../AutoRegisteringComponentRenderer.ts | 13 -- frontend/lib/registry/ComponentRegistry.ts | 17 +- .../flow-widget/FlowWidgetRenderer.tsx | 6 +- frontend/lib/registry/components/index.ts | 6 - .../table-list/TableListRenderer.tsx | 16 +- .../utils/createComponentDefinition.ts | 5 +- frontend/lib/registry/utils/hotReload.ts | 19 +- .../registry/utils/performanceOptimizer.ts | 3 - .../lib/utils/getComponentConfigPanel.tsx | 25 --- frontend/lib/utils/widthToColumnSpan.ts | 11 - 15 files changed, 26 insertions(+), 379 deletions(-) diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index 1f0dee4d..535061dc 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -359,12 +359,9 @@ export function TableHistoryModal({ {!recordId && ( {displayValue ? ( -
- {displayValue} - (ID: {record.original_id}) -
+ {displayValue} ) : ( - {record.original_id} + - )} )} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 0cc6ca7f..31f493e7 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -136,11 +136,6 @@ export const RealtimePreviewDynamic: React.FC = ({ // 컴포넌트의 실제 size.height도 업데이트 (중복 업데이트 방지) if (onConfigChange && finalHeight !== lastUpdatedHeight.current && finalHeight !== component.size?.height) { lastUpdatedHeight.current = finalHeight; - console.log("🔄 플로우 위젯 높이 업데이트 이벤트 발송:", { - componentId: component.id, - oldHeight: component.size?.height, - newHeight: finalHeight, - }); // size는 별도 속성이므로 직접 업데이트 const event = new CustomEvent("updateComponentSize", { detail: { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 4784fe38..f3d5d537 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -258,16 +258,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD window.dispatchEvent(syncEvent); }); - console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", { - totalComponents: components.length, - restoredFileComponents: restoredCount, - totalFiles: fileResponse.totalFiles, - globalFileState: Object.keys(globalFileState).map((id) => ({ - id, - fileCount: globalFileState[id]?.length || 0, - })), - }); - if (restoredCount > 0) { toast.success( `${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`, @@ -322,15 +312,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD snapToGrid: layout.gridSettings.snapToGrid || false, }); - console.log("🧮 격자 정보 재계산:", { - resolution: `${width}x${height}`, - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - columnWidth: newGridInfo.columnWidth.toFixed(2), - snapToGrid: layout.gridSettings.snapToGrid, - }); - return newGridInfo; }, [layout.gridSettings, screenResolution]); @@ -425,13 +406,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 컴포넌트 속성 업데이트 const updateComponentProperty = useCallback( (componentId: string, path: string, value: any) => { - console.log("⚙️ 컴포넌트 속성 업데이트:", { - componentId, - path, - value, - timestamp: new Date().toISOString(), - }); - // 🔥 함수형 업데이트로 변경하여 최신 layout 사용 setLayout((prevLayout) => { const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); @@ -494,26 +468,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 중첩 경로를 고려한 안전한 복사 const newComp = { ...comp }; - console.log("🔍 업데이트 전 상태:", { - path, - value, - "기존 componentConfig": newComp.componentConfig, - "기존 action": (newComp as any).componentConfig?.action, - }); - // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { const key = pathParts[i]; - console.log(`🔍 경로 탐색 [${i}]: key="${key}", current[key]=`, current[key]); // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { - console.log(`🆕 새 객체 생성: ${key}`); current[key] = {}; } else { // 기존 객체를 복사하여 불변성 유지 - console.log(`📋 기존 객체 복사: ${key}`, { ...current[key] }); current[key] = { ...current[key] }; } current = current[key]; @@ -521,58 +485,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 최종 값 설정 const finalKey = pathParts[pathParts.length - 1]; - console.log(`✍️ 최종 값 설정: ${finalKey} = ${value}`); current[finalKey] = value; - console.log("✅ 컴포넌트 업데이트 완료:", { - componentId, - path, - newValue: current[pathParts[pathParts.length - 1]], - fullComponent: newComp, - webTypeConfig: newComp.type === "widget" ? (newComp as any).webTypeConfig : null, - }); - - // webTypeConfig 업데이트의 경우 추가 검증 - if (path === "webTypeConfig") { - console.log("🎛️ webTypeConfig 특별 처리:", { - componentId, - oldConfig: comp.type === "widget" ? (comp as any).webTypeConfig : null, - newConfig: current[pathParts[pathParts.length - 1]], - configType: typeof current[pathParts[pathParts.length - 1]], - configStringified: JSON.stringify(current[pathParts[pathParts.length - 1]]), - oldConfigStringified: JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null), - isConfigChanged: - JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null) !== - JSON.stringify(current[pathParts[pathParts.length - 1]]), - timestamp: new Date().toISOString(), - }); - } - // gridColumns 변경 시 크기 자동 업데이트 - console.log("🔍 gridColumns 변경 감지:", { - path, - value, - componentType: newComp.type, - hasGridInfo: !!gridInfo, - hasGridSettings: !!layout.gridSettings, - currentGridColumns: (newComp as any).gridColumns, - }); - if (path === "gridColumns" && gridInfo) { const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings); newComp.size = updatedSize; - console.log("📏 gridColumns 변경으로 크기 업데이트:", { - gridColumns: value, - oldSize: comp.size, - newSize: updatedSize, - }); - } else if (path === "gridColumns") { - console.log("❌ gridColumns 변경 실패:", { - hasGridInfo: !!gridInfo, - hasGridSettings: !!layout.gridSettings, - gridInfo, - gridSettings: layout.gridSettings, - }); } // 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외) @@ -604,11 +522,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ); if (newComp.gridColumns !== adjustedColumns) { newComp.gridColumns = adjustedColumns; - console.log("📏 크기 변경으로 gridColumns 자동 조정:", { - oldColumns: comp.gridColumns, - newColumns: adjustedColumns, - newSize: snappedSize, - }); } } @@ -631,15 +544,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...newComp.size, width: newWidth, }; - - console.log("📐 gridColumns 변경으로 크기 자동 조정:", { - componentId, - gridColumns: newComp.gridColumns, - oldWidth: comp.size.width, - newWidth: newWidth, - columnWidth: currentGridInfo.columnWidth, - gap: layout.gridSettings.gap, - }); } // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) @@ -687,26 +591,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD width: snappedWidth, height: snappedHeight, }; - - console.log("🎯 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", { - componentId, - parentId: newComp.parentId, - originalPosition: comp.position, - originalSize: comp.size, - calculation: { - effectiveX, - effectiveY, - columnIndex, - rowIndex, - columnWidth, - fullColumnWidth, - widthInColumns, - gap: gap || 16, - padding, - }, - snappedPosition: newComp.position, - snappedSize: newComp.size, - }); } else if (newComp.type !== "group") { // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 const snappedPosition = snapToGrid( @@ -715,18 +599,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD layout.gridSettings as GridUtilSettings, ); newComp.position = snappedPosition; - - console.log("🧲 일반 컴포넌트 격자 스냅:", { - componentId, - originalPosition: comp.position, - snappedPosition, - }); - } else { - console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", { - componentId, - type: newComp.type, - position: newComp.position, - }); } } @@ -736,13 +608,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 🔥 새로운 layout 생성 const newLayout = { ...prevLayout, components: updatedComponents }; - console.log("🔄 setLayout 실행:", { - componentId, - path, - value, - 업데이트된컴포넌트: updatedComponents.find((c) => c.id === componentId), - }); - saveToHistory(newLayout); // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 @@ -752,20 +617,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD if (updatedSelectedComponent) { // 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함 const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); - - console.log("🔄 selectedComponent 동기화:", { - componentId, - path, - oldAction: (prevSelected as any).componentConfig?.action, - newAction: (newSelectedComponent as any).componentConfig?.action, - oldColumnsCount: prevSelected.type === "datatable" ? (prevSelected as any).columns?.length : "N/A", - newColumnsCount: - newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).columns?.length : "N/A", - oldFiltersCount: prevSelected.type === "datatable" ? (prevSelected as any).filters?.length : "N/A", - newFiltersCount: - newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).filters?.length : "N/A", - timestamp: new Date().toISOString(), - }); return newSelectedComponent; } } @@ -989,16 +840,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD let layoutToUse = response; if (needsMigration(response)) { - console.log("🔄 픽셀 기반 레이아웃 감지 - 그리드 시스템으로 마이그레이션 시작..."); - const canvasWidth = response.screenResolution?.width || 1920; layoutToUse = safeMigrateLayout(response, canvasWidth); - - console.log("✅ 마이그레이션 완료:", { - originalComponents: response.components.length, - migratedComponents: layoutToUse.components.length, - sampleComponent: layoutToUse.components[0], - }); } // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) @@ -2224,16 +2067,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...component.defaultSize, width: Math.max(calculatedWidth, minWidth), }; - - console.log(`📐 ${component.name} 초기 크기 자동 조정:`, { - componentId: component.id, - gridColumns, - defaultWidth: component.defaultSize.width, - calculatedWidth, - finalWidth: componentSize.width, - gridInfo, - gridSettings: layout.gridSettings, - }); } console.log("🎨 최종 컴포넌트 크기:", { @@ -2858,18 +2691,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return; } - console.log("🔍 컴포넌트 클릭 시 최신 버전 확인:", { - componentId: component.id, - 파라미터로받은버전: { - actionType: (component as any).componentConfig?.action?.type, - fullAction: (component as any).componentConfig?.action, - }, - layout에서찾은최신버전: { - actionType: (latestComponent as any).componentConfig?.action?.type, - fullAction: (latestComponent as any).componentConfig?.action, - }, - }); - const isShiftPressed = event?.shiftKey || false; const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; const isGroupContainer = latestComponent.type === "group"; @@ -4218,30 +4039,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const handleComponentSizeUpdate = (event: CustomEvent) => { const { componentId, height } = event.detail; - console.log("📥 ScreenDesigner에서 높이 업데이트 이벤트 수신:", { - componentId, - height, - }); - // 해당 컴포넌트 찾기 const targetComponent = layout.components.find((c) => c.id === componentId); if (!targetComponent) { - console.log("⚠️ 컴포넌트를 찾을 수 없음:", componentId); return; } // 이미 같은 높이면 업데이트 안함 if (targetComponent.size?.height === height) { - console.log("ℹ️ 이미 같은 높이:", height); return; } - console.log("✅ 컴포넌트 높이 업데이트 중:", { - componentId, - oldHeight: targetComponent.size?.height, - newHeight: height, - }); - // 컴포넌트 높이 업데이트 const updatedComponents = layout.components.map((comp) => { if (comp.id === componentId) { @@ -4269,7 +4077,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const updatedComponent = updatedComponents.find((c) => c.id === componentId); if (updatedComponent) { setSelectedComponent(updatedComponent); - console.log("✅ 선택된 컴포넌트도 업데이트됨"); } } }; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 35e82c7c..be002f14 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -39,13 +39,6 @@ export const ButtonConfigPanel: React.FC = ({ const config = component.componentConfig || {}; const currentAction = component.componentConfig?.action || {}; - console.log("🎨 ButtonConfigPanel 렌더링:", { - componentId: component.id, - "component.componentConfig?.action?.type": component.componentConfig?.action?.type, - currentTableName: currentTableName, - "config.action?.historyTableName": config.action?.historyTableName, - }); - // 로컬 상태 관리 (실시간 입력 반영) const [localInputs, setLocalInputs] = useState({ text: config.text !== undefined ? config.text : "버튼", @@ -140,14 +133,6 @@ export const ButtonConfigPanel: React.FC = ({ }, }); - console.log("📋 [ButtonConfigPanel] API 응답:", { - tableName, - success: response.data.success, - hasData: !!response.data.data, - hasColumns: !!response.data.data?.columns, - totalColumns: response.data.data?.columns?.length, - }); - // API 응답 구조: { success, data: { columns: [...], total, page, totalPages } } const columnData = response.data.data?.columns; @@ -164,28 +149,13 @@ export const ButtonConfigPanel: React.FC = ({ const colName = col.columnName.toLowerCase(); const dataType = col.dataType?.toLowerCase() || ""; - console.log(`🔍 [필터링 체크] ${col.columnName}:`, { - colName, - dataType, - isId: colName === "id" || colName.endsWith("_id"), - hasDateInType: dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp"), - hasDateInName: - colName.includes("date") || - colName.includes("time") || - colName.endsWith("_at") || - colName.startsWith("created") || - colName.startsWith("updated"), - }); - // ID 컬럼 제외 (id, _id로 끝나는 컬럼) if (colName === "id" || colName.endsWith("_id")) { - console.log(` ❌ 제외: ID 컬럼`); return false; } // 날짜/시간 타입 제외 (데이터 타입 기준) if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) { - console.log(` ❌ 제외: 날짜/시간 타입`); return false; } @@ -197,20 +167,13 @@ export const ButtonConfigPanel: React.FC = ({ colName.startsWith("created") || colName.startsWith("updated") ) { - console.log(` ❌ 제외: 날짜 관련 컬럼명`); return false; } - console.log(` ✅ 통과`); return true; }) .map((col: any) => col.columnName); - console.log("✅ [ButtonConfigPanel] 필터링된 컬럼:", { - totalFiltered: filteredColumns.length, - columns: filteredColumns, - }); - setTableColumns(filteredColumns); } } catch (error) { @@ -305,18 +268,12 @@ export const ButtonConfigPanel: React.FC = ({ key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`} value={component.componentConfig?.action?.type || "save"} onValueChange={(value) => { - console.log("🎯 버튼 액션 드롭다운 변경:", { - oldValue: component.componentConfig?.action?.type, - newValue: value, - }); - // 🔥 action.type 업데이트 onUpdateProperty("componentConfig.action.type", value); // 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후) setTimeout(() => { const newColor = value === "delete" ? "#ef4444" : "#212121"; - console.log("🎨 라벨 색상 업데이트:", { value, newColor }); onUpdateProperty("style.labelColor", newColor); }, 100); // 0 → 100ms로 증가 }} @@ -737,8 +694,8 @@ export const ButtonConfigPanel: React.FC = ({

전체 테이블 이력에서 레코드를 구분하기 위한 컬럼입니다.
- 예: device_code를 설정하면 "레코드 ID: 5" - 대신 "DTG-001 (ID: 5)"로 표시됩니다. + 예: device_code를 설정하면 이력에 "DTG-001"로 + 표시됩니다.
이 컬럼으로 검색도 가능합니다.

diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 380aa418..de401bea 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -79,27 +79,13 @@ export function FlowWidget({ const effectiveSteps = stepsArray || steps; // 1순위: 플로우 스텝 기본 설정 - console.log(`🔍 [FlowWidget] steps 배열 상태:`, { - stepsLength: effectiveSteps.length, - stepsIds: effectiveSteps.map((s) => s.id), - targetStepId: stepId, - }); - const currentStep = effectiveSteps.find((s) => s.id === stepId); - console.log(`🔍 [FlowWidget] currentStep 찾기 (스텝 ${stepId}):`, { - found: !!currentStep, - hasDisplayConfig: !!currentStep?.displayConfig, - displayConfig: currentStep?.displayConfig, - displayConfigType: typeof currentStep?.displayConfig, - }); if (currentStep?.displayConfig?.visibleColumns && currentStep.displayConfig.visibleColumns.length > 0) { - console.log(`🎨 [FlowWidget] 플로우 기본 설정 적용 (스텝 ${stepId}):`, currentStep.displayConfig.visibleColumns); return currentStep.displayConfig.visibleColumns; } // 2순위: 모든 컬럼 표시 - console.log(`🎨 [FlowWidget] 전체 컬럼 표시 (스텝 ${stepId}):`, allColumns); return allColumns; }; @@ -211,15 +197,6 @@ export function FlowWidget({ } if (stepsResponse.data) { const sortedSteps = stepsResponse.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder); - console.log("📋 [FlowWidget] 스텝 목록 로드:", { - stepsCount: sortedSteps.length, - steps: sortedSteps.map((s: FlowStep) => ({ - id: s.id, - name: s.stepName, - hasDisplayConfig: !!s.displayConfig, - displayConfig: s.displayConfig, - })), - }); setSteps(sortedSteps); // 연결 정보 조회 @@ -246,11 +223,6 @@ export function FlowWidget({ const firstStep = sortedSteps[0]; setSelectedStepId(firstStep.id); setSelectedStep(flowComponentId, firstStep.id); - console.log("✅ [FlowWidget] 첫 번째 단계 자동 선택:", { - flowComponentId, - stepId: firstStep.id, - stepName: firstStep.stepName, - }); // 첫 번째 스텝의 데이터 로드 try { @@ -292,7 +264,6 @@ export function FlowWidget({ // 🆕 언마운트 시 전역 상태 초기화 useEffect(() => { return () => { - console.log("🧹 [FlowWidget] 언마운트 - 전역 상태 초기화:", flowComponentId); resetFlow(flowComponentId); }; }, [flowComponentId, resetFlow]); @@ -314,7 +285,6 @@ export function FlowWidget({ setStepDataPage(1); // 🆕 페이지 리셋 onSelectedDataChange?.([], null); - console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId }); return; } @@ -326,8 +296,6 @@ export function FlowWidget({ setStepDataPage(1); // 🆕 페이지 리셋 onSelectedDataChange?.([], stepId); - console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName }); - try { const response = await getStepDataList(flowId!, stepId, 1, 100); diff --git a/frontend/lib/registry/AutoRegisteringComponentRenderer.ts b/frontend/lib/registry/AutoRegisteringComponentRenderer.ts index f79a3396..450f9da8 100644 --- a/frontend/lib/registry/AutoRegisteringComponentRenderer.ts +++ b/frontend/lib/registry/AutoRegisteringComponentRenderer.ts @@ -333,7 +333,6 @@ export class AutoRegisteringComponentRenderer { } if (this.registeredComponents.has(definition.id)) { - console.warn(`⚠️ ${definition.id} 컴포넌트가 이미 등록되어 있습니다.`); return; } @@ -341,18 +340,6 @@ export class AutoRegisteringComponentRenderer { // 레지스트리에 등록 ComponentRegistry.registerComponent(definition); this.registeredComponents.add(definition.id); - - console.log(`✅ 컴포넌트 자동 등록 완료: ${definition.id} (${definition.name})`); - - // 개발 모드에서 추가 정보 출력 - if (process.env.NODE_ENV === "development") { - console.log(`📦 ${definition.id}:`, { - name: definition.name, - category: definition.category, - webType: definition.webType, - tags: definition.tags?.join(", ") || "none", - }); - } } catch (error) { console.error(`❌ ${definition.id} 컴포넌트 등록 실패:`, error); } diff --git a/frontend/lib/registry/ComponentRegistry.ts b/frontend/lib/registry/ComponentRegistry.ts index 75f02a74..00866c68 100644 --- a/frontend/lib/registry/ComponentRegistry.ts +++ b/frontend/lib/registry/ComponentRegistry.ts @@ -52,8 +52,6 @@ export class ComponentRegistry { timestamp: new Date(), }); - console.log(`✅ 컴포넌트 등록: ${definition.id} (${definition.name})`); - // 개발자 도구 등록 (개발 모드에서만) if (process.env.NODE_ENV === "development") { this.registerGlobalDevTools(); @@ -399,9 +397,6 @@ Hot Reload 제어 (비동기): `); }, }; - - console.log("🛠️ 컴포넌트 레지스트리 개발자 도구가 등록되었습니다."); - console.log(" 사용법: __COMPONENT_REGISTRY__.help()"); } } @@ -409,17 +404,7 @@ Hot Reload 제어 (비동기): * 디버그 정보 출력 */ static debug(): void { - const stats = this.getStats(); - console.group("🎨 컴포넌트 레지스트리 디버그 정보"); - console.log("📊 총 컴포넌트 수:", stats.total); - console.log("📂 카테고리별 분포:", stats.byCategory); - console.log("🏷️ 웹타입별 분포:", stats.byWebType); - console.log("👨‍💻 작성자별 분포:", stats.byAuthor); - console.log( - "🆕 최근 추가:", - stats.recentlyAdded.map((c) => `${c.id} (${c.name})`), - ); - console.groupEnd(); + // 디버그 로그 제거 (필요시 브라우저 콘솔에서 ComponentRegistry.getStats() 사용) } /** diff --git a/frontend/lib/registry/components/flow-widget/FlowWidgetRenderer.tsx b/frontend/lib/registry/components/flow-widget/FlowWidgetRenderer.tsx index d6ac4dd2..14111f58 100644 --- a/frontend/lib/registry/components/flow-widget/FlowWidgetRenderer.tsx +++ b/frontend/lib/registry/components/flow-widget/FlowWidgetRenderer.tsx @@ -21,8 +21,8 @@ export class FlowWidgetRenderer extends AutoRegisteringComponentRenderer { render(): React.ReactElement { return ( - ; + return ; } // 설정 변경 핸들러 protected handleConfigChange = (config: any) => { console.log("📥 TableListRenderer에서 설정 변경 받음:", config); - + // 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림) if (this.props.onConfigChange) { this.props.onConfigChange(config); } else { console.log("⚠️ 상위 컴포넌트에서 onConfigChange가 전달되지 않음"); } - + this.updateComponent({ config }); }; /** * 컴포넌트별 특화 메서드들 */ - + // text 타입 특화 속성 처리 protected getTableListProps() { const baseProps = this.getWebTypeProps(); - + // text 타입에 특화된 추가 속성들 return { ...baseProps, @@ -72,9 +68,7 @@ TableListRenderer.registerSelf(); if (typeof window !== "undefined") { setTimeout(() => { try { - console.log("🔄 TableList 강제 등록 시도..."); TableListRenderer.registerSelf(); - console.log("✅ TableList 강제 등록 완료"); } catch (error) { console.error("❌ TableList 강제 등록 실패:", error); } diff --git a/frontend/lib/registry/utils/createComponentDefinition.ts b/frontend/lib/registry/utils/createComponentDefinition.ts index 7d29a8b0..824846d2 100644 --- a/frontend/lib/registry/utils/createComponentDefinition.ts +++ b/frontend/lib/registry/utils/createComponentDefinition.ts @@ -96,10 +96,7 @@ export function createComponentDefinition(options: CreateComponentDefinitionOpti throw new Error(`컴포넌트 정의 검증 실패: ${validationResult.errors.join(", ")}`); } - // 경고사항 출력 (개발 모드에서만) - if (process.env.NODE_ENV === "development" && validationResult.warnings.length > 0) { - console.warn(`⚠️ 컴포넌트 정의 경고 (${id}):`, validationResult.warnings); - } + // 경고사항 출력 (개발 모드에서만) - 로그 제거 return definition; } diff --git a/frontend/lib/registry/utils/hotReload.ts b/frontend/lib/registry/utils/hotReload.ts index a2f61a29..6ac8f63e 100644 --- a/frontend/lib/registry/utils/hotReload.ts +++ b/frontend/lib/registry/utils/hotReload.ts @@ -15,9 +15,8 @@ let hotReloadListeners: Array<() => void> = []; */ export function initializeHotReload(): void { // 핫 리로드 시스템 임시 비활성화 (디버깅 목적) - console.log("🔥 컴포넌트 Hot Reload 시스템 비활성화됨 (디버깅 모드)"); return; - + if (process.env.NODE_ENV !== "development" || typeof window === "undefined") { return; } @@ -64,10 +63,18 @@ function setupDevServerEventListener(): void { const message = args.join(" "); // 핫 리로드를 트리거할 특정 메시지만 감지 (디버깅 로그는 제외) - if ((message.includes("compiled") || message.includes("Fast Refresh")) && - !message.includes("🔍") && !message.includes("🎯") && !message.includes("📤") && - !message.includes("📥") && !message.includes("⚠️") && !message.includes("🔄") && - !message.includes("✅") && !message.includes("🔧") && !message.includes("📋")) { + if ( + (message.includes("compiled") || message.includes("Fast Refresh")) && + !message.includes("🔍") && + !message.includes("🎯") && + !message.includes("📤") && + !message.includes("📥") && + !message.includes("⚠️") && + !message.includes("🔄") && + !message.includes("✅") && + !message.includes("🔧") && + !message.includes("📋") + ) { if (!reloadPending) { reloadPending = true; setTimeout(() => { diff --git a/frontend/lib/registry/utils/performanceOptimizer.ts b/frontend/lib/registry/utils/performanceOptimizer.ts index f6c81672..52eadd8f 100644 --- a/frontend/lib/registry/utils/performanceOptimizer.ts +++ b/frontend/lib/registry/utils/performanceOptimizer.ts @@ -67,8 +67,6 @@ export class PerformanceOptimizer { static initialize(options: Partial = {}): void { this.options = { ...DEFAULT_OPTIMIZATION_OPTIONS, ...options }; - console.log("⚡ 성능 최적화 시스템 초기화:", this.options); - // 메모리 사용량 모니터링 (개발 모드에서만) if (process.env.NODE_ENV === "development") { this.startMemoryMonitoring(); @@ -463,5 +461,4 @@ export class PerformanceOptimizer { // 자동 초기화 if (typeof window !== "undefined") { PerformanceOptimizer.initialize(); - console.log("⚡ 성능 최적화 시스템이 초기화되었습니다."); } diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 956cd92e..9093a480 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -48,7 +48,6 @@ export async function getComponentConfigPanel(componentId: string): Promise = tableColumns, tables, }) => { - console.log(`🔥 DynamicComponentConfigPanel 렌더링 시작: ${componentId}`); - // 모든 useState를 최상단에 선언 (Hooks 규칙) const [ConfigPanelComponent, setConfigPanelComponent] = React.useState | null>(null); const [loading, setLoading] = React.useState(true); @@ -133,12 +129,10 @@ export const DynamicComponentConfigPanel: React.FC = async function loadConfigPanel() { try { - console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 시작`); setLoading(true); setError(null); const component = await getComponentConfigPanel(componentId); - console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 결과:`, component); if (mounted) { setConfigPanelComponent(() => component); @@ -217,38 +211,20 @@ export const DynamicComponentConfigPanel: React.FC = ); } - console.log(`🔧 DynamicComponentConfigPanel 렌더링:`, { - componentId, - ConfigPanelComponent: ConfigPanelComponent?.name, - config, - configType: typeof config, - configKeys: typeof config === "object" ? Object.keys(config || {}) : "not object", - screenTableName, - tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns, - tables: Array.isArray(tables) ? tables.length : tables, - tablesType: typeof tables, - tablesDetail: tables, // 전체 테이블 목록 확인 - }); - // 테이블 변경 핸들러 - 선택된 테이블의 컬럼을 동적으로 로드 const handleTableChange = async (tableName: string) => { - console.log("🔄 테이블 변경:", tableName); try { // 먼저 tables에서 찾아보기 (이미 컬럼이 있는 경우) const existingTable = tables?.find((t) => t.tableName === tableName); if (existingTable && existingTable.columns && existingTable.columns.length > 0) { - console.log("✅ 캐시된 테이블 컬럼 사용:", existingTable.columns.length, "개"); setSelectedTableColumns(existingTable.columns); return; } // 컬럼이 없으면 tableTypeApi로 조회 (ScreenDesigner와 동일한 방식) - console.log("🔍 테이블 컬럼 API 조회:", tableName); const { tableTypeApi } = await import("@/lib/api/screen"); const columnsResponse = await tableTypeApi.getColumns(tableName); - console.log("🔍 컬럼 응답 데이터:", columnsResponse); - const columns = (columnsResponse || []).map((col: any) => ({ tableName: col.tableName || tableName, columnName: col.columnName || col.column_name, @@ -265,7 +241,6 @@ export const DynamicComponentConfigPanel: React.FC = codeValue: col.codeValue || col.code_value, })); - console.log("✅ 테이블 컬럼 로드 성공:", columns.length, "개"); setSelectedTableColumns(columns); } catch (error) { console.error("❌ 테이블 변경 오류:", error); diff --git a/frontend/lib/utils/widthToColumnSpan.ts b/frontend/lib/utils/widthToColumnSpan.ts index 5b230091..7972d8cb 100644 --- a/frontend/lib/utils/widthToColumnSpan.ts +++ b/frontend/lib/utils/widthToColumnSpan.ts @@ -165,11 +165,6 @@ export function migrateComponentsToColumnSpan( * @returns 새로운 그리드 시스템으로 변환된 레이아웃 */ export function migrateLayoutToGridSystem(layout: LayoutData, canvasWidth: number = 1920): LayoutData { - console.log("🔄 레이아웃 마이그레이션 시작:", { - screenId: layout.screenId, - componentCount: layout.components.length, - }); - // 1단계: width를 gridColumnSpan으로 변환 let migratedComponents = migrateComponentsToColumnSpan(layout.components, canvasWidth); @@ -179,11 +174,6 @@ export function migrateLayoutToGridSystem(layout: LayoutData, canvasWidth: numbe // 3단계: 같은 행 내에서 X 좌표로 gridColumnStart 계산 migratedComponents = calculateColumnStarts(migratedComponents); - console.log("✅ 마이그레이션 완료:", { - componentCount: migratedComponents.length, - sampleComponent: migratedComponents[0], - }); - return { ...layout, components: migratedComponents, @@ -233,7 +223,6 @@ export function needsMigration(layout: LayoutData): boolean { export function safeMigrateLayout(layout: LayoutData, canvasWidth: number = 1920): LayoutData { try { if (!needsMigration(layout)) { - console.log("⏭️ 마이그레이션 불필요 - 이미 최신 형식"); return layout; } From 783ce5594e7fc1efa0de907e844a0d548f3cd888 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 27 Oct 2025 11:50:25 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=EC=95=84=EC=9D=B4=EB=94=94=EA=B0=92=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/TableHistoryModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index 535061dc..631796f1 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -195,6 +195,10 @@ export function TableHistoryModal({ return null; }; + // 단일 레코드 모드에서 displayColumn 값 가져오기 + const recordDisplayValue = + recordId && displayColumn && detailRecords.length > 0 ? getDisplayValue(detailRecords[0]) : null; + return ( @@ -210,7 +214,7 @@ export function TableHistoryModal({ {recordId - ? `${recordLabel || `레코드 ID: ${recordId}`} - ${tableName} 테이블` + ? `${recordDisplayValue || recordLabel || "-"} - ${tableName} 테이블` : `${tableName} 테이블 전체 이력`} From d4579e42214593c693eb4f37a8a156f724edb6ca Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 14:38:43 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=EB=B7=B0=EC=96=B4=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EB=B0=98=EC=9D=91=ED=98=95=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9B=90=ED=98=95=20=EC=B0=A8=ED=8A=B8=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/charts/ChartRenderer.tsx | 71 ++++++-- .../admin/dashboard/charts/PieChart.tsx | 24 +-- .../components/dashboard/DashboardViewer.tsx | 155 +++++++++--------- 3 files changed, 155 insertions(+), 95 deletions(-) diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 9a5a51a6..88e90cae 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { DashboardElement, QueryResult, ChartData } from "../types"; import { Chart } from "./Chart"; import { transformQueryResultToChartData } from "../utils/chartDataTransform"; @@ -21,11 +21,39 @@ interface ChartRendererProps { * - QueryResult를 ChartData로 변환 * - D3 Chart 컴포넌트에 전달 */ -export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) { +export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(width || 250); const [chartData, setChartData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // 컨테이너 너비 측정 (width가 undefined일 때) + useEffect(() => { + if (width !== undefined) { + setContainerWidth(width); + return; + } + + const updateWidth = () => { + if (containerRef.current) { + const measuredWidth = containerRef.current.offsetWidth; + console.log("📏 컨테이너 너비 측정:", measuredWidth); + setContainerWidth(measuredWidth || 500); // 기본값 500 + } + }; + + // 약간의 지연을 두고 측정 (DOM 렌더링 완료 후) + const timer = setTimeout(updateWidth, 100); + updateWidth(); + + window.addEventListener("resize", updateWidth); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", updateWidth); + }; + }, [width]); + // 데이터 페칭 useEffect(() => { const fetchData = async () => { @@ -212,15 +240,38 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char } // D3 차트 렌더링 + const actualWidth = width !== undefined ? width : containerWidth; + + // 원형 차트는 더 큰 크기가 필요 (최소 400px) + const isCircularChart = element.subtype === "pie" || element.subtype === "donut"; + const minWidth = isCircularChart ? 400 : 200; + const finalWidth = Math.max(actualWidth - 20, minWidth); + const finalHeight = Math.max(height - 20, 300); + + console.log("🎨 ChartRenderer:", { + elementSubtype: element.subtype, + propWidth: width, + containerWidth, + actualWidth, + finalWidth, + finalHeight, + hasChartData: !!chartData, + chartDataLabels: chartData?.labels, + chartDataDatasets: chartData?.datasets?.length, + isCircularChart, + }); + return ( -
- +
+
+ +
); } diff --git a/frontend/components/admin/dashboard/charts/PieChart.tsx b/frontend/components/admin/dashboard/charts/PieChart.tsx index 8afcb4c0..affa2928 100644 --- a/frontend/components/admin/dashboard/charts/PieChart.tsx +++ b/frontend/components/admin/dashboard/charts/PieChart.tsx @@ -136,23 +136,24 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa .text(config.title); } - // 범례 (차트 오른쪽, 세로 배치) + // 범례 (차트 아래, 가로 배치, 중앙 정렬) if (config.showLegend !== false) { - const legendX = width / 2 + radius + 30; // 차트 오른쪽 - const legendY = (height - pieData.length * 25) / 2; // 세로 중앙 정렬 - - const legend = svg - .append("g") - .attr("class", "legend") - .attr("transform", `translate(${legendX}, ${legendY})`); + const itemSpacing = 140; // 각 범례 항목 사이 간격 + const totalWidth = pieData.length * itemSpacing; + const legendStartX = (width - totalWidth) / 2; // 시작 위치 + const legendY = height - 40; // 차트 아래 (여백 확보) + + const legend = svg.append("g").attr("class", "legend"); pieData.forEach((d, i) => { const legendItem = legend .append("g") - .attr("transform", `translate(0, ${i * 25})`); + .attr("transform", `translate(${legendStartX + i * itemSpacing + itemSpacing / 2}, ${legendY})`); legendItem .append("rect") + .attr("x", -7.5) // 사각형을 중앙 기준으로 + .attr("y", -7.5) .attr("width", 15) .attr("height", 15) .attr("fill", colors[i % colors.length]) @@ -160,8 +161,9 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa legendItem .append("text") - .attr("x", 20) - .attr("y", 12) + .attr("x", 0) + .attr("y", 20) + .attr("text-anchor", "middle") // 텍스트 중앙 정렬 .style("font-size", "11px") .style("fill", "#333") .text(`${d.label} (${d.value})`); diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 4bbca728..176acfb0 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -174,18 +174,6 @@ export function DashboardViewer({ }: DashboardViewerProps) { const [elementData, setElementData] = useState>({}); const [loadingElements, setLoadingElements] = useState>(new Set()); - const [isMobile, setIsMobile] = useState(false); - - // 화면 크기 감지 - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 1024); // 1024px (lg) 미만은 모바일/태블릿 - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); // 캔버스 설정 계산 const canvasConfig = useMemo(() => { @@ -287,10 +275,8 @@ export function DashboardViewer({ return () => clearInterval(interval); }, [refreshInterval, loadAllData]); - // 모바일에서 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) + // 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) - 태블릿 이하에서 세로 정렬 시 사용 const sortedElements = useMemo(() => { - if (!isMobile) return elements; - return [...elements].sort((a, b) => { // Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함) const yDiff = a.position.y - b.position.y; @@ -300,7 +286,7 @@ export function DashboardViewer({ // 같은 행이면 X 좌표로 정렬 return a.position.x - b.position.x; }); - }, [elements, isMobile]); + }, [elements]); // 요소가 없는 경우 if (elements.length === 0) { @@ -317,10 +303,18 @@ export function DashboardViewer({ return ( - {isMobile ? ( - // 모바일/태블릿: 세로 스택 레이아웃 -
-
+ {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */} +
+
+
{sortedElements.map((element) => ( loadElementData(element)} - isMobile={true} + isMobile={false} + canvasWidth={canvasConfig.width} /> ))}
- ) : ( - // 데스크톱: 기존 고정 캔버스 레이아웃 -
-
-
- {sortedElements.map((element) => ( - loadElementData(element)} - isMobile={false} - /> - ))} -
-
+
+ + {/* 태블릿 이하: 반응형 세로 정렬 */} +
+
+ {sortedElements.map((element) => ( + loadElementData(element)} + isMobile={true} + /> + ))}
- )} +
); } @@ -370,22 +355,21 @@ interface ViewerElementProps { isLoading: boolean; onRefresh: () => void; isMobile: boolean; + canvasWidth?: number; } /** * 개별 뷰어 요소 컴포넌트 + * - 데스크톱(lg 이상): absolute positioning으로 디자이너에서 설정한 위치 그대로 렌더링 (너비는 화면 비율에 따라 조정) + * - 태블릿 이하: 세로 스택 카드 레이아웃 */ -function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) { - const [isHovered, setIsHovered] = useState(false); - +function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) { if (isMobile) { - // 모바일/태블릿: 세로 스택 카드 스타일 + // 태블릿 이하: 세로 스택 카드 스타일 return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {element.showHeader !== false && (
@@ -393,14 +377,22 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
)} @@ -423,18 +415,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer ); } - // 데스크톱: 기존 absolute positioning + // 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning + // 단, 너비는 화면 크기에 따라 비율로 조정 + const widthPercentage = (element.size.width / canvasWidth) * 100; + return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {element.showHeader !== false && (
@@ -442,22 +435,36 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
)} -
+
{element.type === "chart" ? ( - + ) : ( renderWidget(element) )} From 270c322dafa8e4e6328589645f0deb4b059e7bf7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 15:19:48 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=ED=83=80=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/dashboard/[dashboardId]/page.tsx | 2 +- .../admin/dashboard/charts/ChartRenderer.tsx | 3 +- .../admin/dashboard/charts/PieChart.tsx | 29 ++++---- .../admin/dashboard/widgets/ClockWidget.tsx | 28 ++++---- .../admin/dashboard/widgets/ListWidget.tsx | 4 +- .../dashboard/widgets/CustomMetricWidget.tsx | 69 ++++++++++--------- 6 files changed, 72 insertions(+), 63 deletions(-) diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 7639abc6..54d61f77 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -105,7 +105,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { } return ( -
+
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */} {/*
diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 88e90cae..94efd190 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -246,7 +246,8 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende const isCircularChart = element.subtype === "pie" || element.subtype === "donut"; const minWidth = isCircularChart ? 400 : 200; const finalWidth = Math.max(actualWidth - 20, minWidth); - const finalHeight = Math.max(height - 20, 300); + // 원형 차트는 범례 공간을 위해 더 많은 여백 필요 + const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300); console.log("🎨 ChartRenderer:", { elementSubtype: element.subtype, diff --git a/frontend/components/admin/dashboard/charts/PieChart.tsx b/frontend/components/admin/dashboard/charts/PieChart.tsx index affa2928..ab24219f 100644 --- a/frontend/components/admin/dashboard/charts/PieChart.tsx +++ b/frontend/components/admin/dashboard/charts/PieChart.tsx @@ -24,12 +24,17 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); - const margin = { top: 40, right: 150, bottom: 40, left: 120 }; + // 범례를 위한 여백 확보 (아래 80px) + const legendHeight = config.showLegend !== false ? 80 : 0; + const margin = { top: 20, right: 20, bottom: 20 + legendHeight, left: 20 }; const chartWidth = width - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; + const chartHeight = height - margin.top - margin.bottom - legendHeight; const radius = Math.min(chartWidth, chartHeight) / 2; - const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`); + // 차트를 위쪽에 배치 (범례 공간 확보) + const centerX = width / 2; + const centerY = margin.top + radius + 20; + const g = svg.append("g").attr("transform", `translate(${centerX},${centerY})`); // 색상 팔레트 const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"]; @@ -138,10 +143,10 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa // 범례 (차트 아래, 가로 배치, 중앙 정렬) if (config.showLegend !== false) { - const itemSpacing = 140; // 각 범례 항목 사이 간격 + const itemSpacing = 100; // 각 범례 항목 사이 간격 (줄임) const totalWidth = pieData.length * itemSpacing; const legendStartX = (width - totalWidth) / 2; // 시작 위치 - const legendY = height - 40; // 차트 아래 (여백 확보) + const legendY = centerY + radius + 40; // 차트 아래 40px const legend = svg.append("g").attr("class", "legend"); @@ -152,19 +157,19 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa legendItem .append("rect") - .attr("x", -7.5) // 사각형을 중앙 기준으로 - .attr("y", -7.5) - .attr("width", 15) - .attr("height", 15) + .attr("x", -6) // 사각형을 중앙 기준으로 + .attr("y", -6) + .attr("width", 12) + .attr("height", 12) .attr("fill", colors[i % colors.length]) - .attr("rx", 3); + .attr("rx", 2); legendItem .append("text") .attr("x", 0) - .attr("y", 20) + .attr("y", 18) .attr("text-anchor", "middle") // 텍스트 중앙 정렬 - .style("font-size", "11px") + .style("font-size", "10px") .style("fill", "#333") .text(`${d.label} (${d.value})`); }); diff --git a/frontend/components/admin/dashboard/widgets/ClockWidget.tsx b/frontend/components/admin/dashboard/widgets/ClockWidget.tsx index e85623f8..fff65bc4 100644 --- a/frontend/components/admin/dashboard/widgets/ClockWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ClockWidget.tsx @@ -111,19 +111,21 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) { {/* 시계 콘텐츠 */} {renderClockContent()} - {/* 설정 버튼 - 우측 상단 */} -
- - - - - - setSettingsOpen(false)} /> - - -
+ {/* 설정 버튼 - 우측 상단 (디자이너 모드에서만 표시) */} + {onConfigUpdate && ( +
+ + + + + + setSettingsOpen(false)} /> + + +
+ )}
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 252831c5..6d3e6929 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -216,7 +216,7 @@ export function ListWidget({ element }: ListWidgetProps) { const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; return ( -
+
{/* 테이블 뷰 */} {config.viewMode === "table" && (
@@ -306,7 +306,7 @@ export function ListWidget({ element }: ListWidgetProps) { {/* 페이지네이션 */} {config.enablePagination && totalPages > 1 && ( -
+
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index 893ab6b0..0dba9473 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -374,45 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } return ( -
- {/* 스크롤 가능한 콘텐츠 영역 */} -
-
- {/* 그룹별 카드 (활성화 시) */} - {isGroupByMode && - groupedCards.map((card, index) => { - // 색상 순환 (6가지 색상) - const colorKeys = Object.keys(colorMap) as Array; - const colorKey = colorKeys[index % colorKeys.length]; - const colors = colorMap[colorKey]; - - return ( -
-
{card.label}
-
{card.value.toLocaleString()}
-
- ); - })} - - {/* 일반 지표 카드 (항상 표시) */} - {metrics.map((metric) => { - const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; - const formattedValue = metric.calculatedValue.toFixed(metric.decimals); +
+ {/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */} +
+ {/* 그룹별 카드 (활성화 시) */} + {isGroupByMode && + groupedCards.map((card, index) => { + // 색상 순환 (6가지 색상) + const colorKeys = Object.keys(colorMap) as Array; + const colorKey = colorKeys[index % colorKeys.length]; + const colors = colorMap[colorKey]; return ( -
-
{metric.label}
-
- {formattedValue} - {metric.unit} -
+
+
{card.label}
+
{card.value.toLocaleString()}
); })} -
+ + {/* 일반 지표 카드 (항상 표시) */} + {metrics.map((metric) => { + const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; + const formattedValue = metric.calculatedValue.toFixed(metric.decimals); + + return ( +
+
{metric.label}
+
+ {formattedValue} + {metric.unit} +
+
+ ); + })}
); From 8788b47663aa0774b179a2a75f8ef0cb14096b38 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 15:46:13 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/DashboardViewer.tsx | 26 ++++++++++++++----- .../dashboard/widgets/CustomMetricWidget.tsx | 18 ++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 176acfb0..4ad23fa8 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -364,6 +364,13 @@ interface ViewerElementProps { * - 태블릿 이하: 세로 스택 카드 레이아웃 */ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) { + const [isMounted, setIsMounted] = useState(false); + + // 마운트 확인 (Leaflet 지도 초기화 문제 해결) + useEffect(() => { + setIsMounted(true); + }, []); + if (isMobile) { // 태블릿 이하: 세로 스택 카드 스타일 return ( @@ -397,7 +404,11 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
)}
- {element.type === "chart" ? ( + {!isMounted ? ( +
+
+
+ ) : element.type === "chart" ? ( ) : ( renderWidget(element) @@ -454,16 +465,17 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
)} -
- {element.type === "chart" ? ( +
+ {!isMounted ? ( +
+
+
+ ) : element.type === "chart" ? ( ) : ( renderWidget(element) diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index 0dba9473..52c8411c 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -374,9 +374,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } return ( -
+
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */} -
+
{/* 그룹별 카드 (활성화 시) */} {isGroupByMode && groupedCards.map((card, index) => { @@ -388,10 +388,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) return (
-
{card.label}
-
{card.value.toLocaleString()}
+
{card.label}
+
{card.value.toLocaleString()}
); })} @@ -404,12 +404,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) return (
-
{metric.label}
-
+
{metric.label}
+
{formattedValue} - {metric.unit} + {metric.unit}
); From 640a9a741caf97f8d8dcda9cd5afe1017628cf5b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 16:06:51 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=EC=95=BC=EB=93=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=95=88=EB=90=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/yard-3d/YardEditor.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 87319916..a9fea2f3 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -65,7 +65,17 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setIsLoading(true); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { - const loadedData = response.data as YardPlacement[]; + const loadedData = (response.data as YardPlacement[]).map((p) => ({ + ...p, + // 문자열로 저장된 숫자 필드를 숫자로 변환 + position_x: Number(p.position_x), + position_y: Number(p.position_y), + position_z: Number(p.position_z), + size_x: Number(p.size_x), + size_y: Number(p.size_y), + size_z: Number(p.size_z), + quantity: p.quantity !== null && p.quantity !== undefined ? Number(p.quantity) : null, + })); setPlacements(loadedData); setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 } From 8a318ea741b1ac68530035e3b70fc674ea156210 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 16:09:06 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EC=95=BC=EB=93=9C=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index bcbded34..4dd93136 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -393,7 +393,6 @@ function Scene({ maxDistance={200} maxPolarAngle={Math.PI / 2} enabled={!isDraggingAny} - reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동) screenSpacePanning={true} // 화면 공간 패닝 panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림) rotateSpeed={0.5} // 회전 속도 From 29c49d7f077f7949b0fe19f63a3d61081cc19636 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 27 Oct 2025 16:40:59 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=EA=B0=81=20=ED=9A=8C=EC=82=AC=EB=B3=84?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/nodemon.json | 13 +- backend-node/src/app.ts | 2 + .../src/controllers/adminController.ts | 86 +- .../controllers/dataflowDiagramController.ts | 167 ++-- .../src/controllers/flowController.ts | 21 +- .../src/controllers/roleController.ts | 864 ++++++++++++++++++ .../src/middleware/permissionMiddleware.ts | 430 +++++++++ backend-node/src/routes/adminRoutes.ts | 3 +- .../src/routes/dataflow/node-flows.ts | 39 +- .../src/routes/externalDbConnectionRoutes.ts | 44 +- backend-node/src/routes/flowRoutes.ts | 7 +- backend-node/src/routes/roleRoutes.ts | 79 ++ .../src/services/RoleService_backup.ts | 554 +++++++++++ .../services/RoleService_getAllMenus_fixed.ts | 66 ++ backend-node/src/services/authService.ts | 13 +- .../src/services/flowConnectionService.ts | 1 + .../src/services/flowDefinitionService.ts | 36 +- .../src/services/flowExecutionService.ts | 1 + backend-node/src/services/flowStepService.ts | 1 + backend-node/src/services/roleService.ts | 610 +++++++++++++ backend-node/src/types/auth.ts | 17 +- backend-node/src/types/flow.ts | 2 + backend-node/src/types/oracledb.d.ts | 18 - backend-node/src/utils/permissionUtils.ts | 230 +++++ backend-node/tsconfig.json | 43 +- db/migrations/RUN_027_MIGRATION.md | 104 +++ docs/권한_그룹_시스템_설계.md | 317 +++++++ docs/권한_시스템_마이그레이션_완료.md | 307 +++++++ docs/권한_체계_가이드.md | 589 ++++++++++++ docs/리소스_기반_권한_시스템_가이드.md | 416 +++++++++ docs/메뉴_기반_권한_시스템_가이드.md | 359 ++++++++ .../app/(main)/admin/flow-management/page.tsx | 36 +- frontend/app/(main)/admin/roles/[id]/page.tsx | 30 + frontend/app/(main)/admin/roles/page.tsx | 39 + frontend/app/(main)/admin/userAuth/page.tsx | 31 + .../components/admin/MenuPermissionsTable.tsx | 347 +++++++ frontend/components/admin/RoleDeleteModal.tsx | 150 +++ .../components/admin/RoleDetailManagement.tsx | 337 +++++++ frontend/components/admin/RoleFormModal.tsx | 375 ++++++++ frontend/components/admin/RoleManagement.tsx | 335 +++++++ .../components/admin/UserAuthEditModal.tsx | 211 +++++ .../components/admin/UserAuthManagement.tsx | 157 ++++ frontend/components/admin/UserAuthTable.tsx | 254 +++++ frontend/components/admin/UserFormModal.tsx | 295 ++++-- frontend/components/admin/UserManagement.tsx | 47 +- frontend/components/admin/UserTable.tsx | 74 +- frontend/components/common/DualListBox.tsx | 379 ++++++++ frontend/components/flow/FlowStepPanel.tsx | 44 +- frontend/components/layout/AdminButton.tsx | 10 +- frontend/components/layout/AppLayout.tsx | 6 +- frontend/components/screen/ScreenDesigner.tsx | 8 +- .../config-panels/ButtonConfigPanel.tsx | 116 +-- .../FlowVisibilityConfigPanel.tsx | 44 +- .../ImprovedButtonControlConfigPanel.tsx | 99 +- frontend/lib/api/externalDbConnection.ts | 17 + frontend/lib/api/flow.ts | 56 +- frontend/lib/api/role.ts | 289 ++++++ frontend/lib/api/user.ts | 18 + frontend/lib/utils/errorUtils.ts | 40 + 59 files changed, 8698 insertions(+), 585 deletions(-) create mode 100644 backend-node/src/controllers/roleController.ts create mode 100644 backend-node/src/middleware/permissionMiddleware.ts create mode 100644 backend-node/src/routes/roleRoutes.ts create mode 100644 backend-node/src/services/RoleService_backup.ts create mode 100644 backend-node/src/services/RoleService_getAllMenus_fixed.ts create mode 100644 backend-node/src/services/roleService.ts delete mode 100644 backend-node/src/types/oracledb.d.ts create mode 100644 backend-node/src/utils/permissionUtils.ts create mode 100644 db/migrations/RUN_027_MIGRATION.md create mode 100644 docs/권한_그룹_시스템_설계.md create mode 100644 docs/권한_시스템_마이그레이션_완료.md create mode 100644 docs/권한_체계_가이드.md create mode 100644 docs/리소스_기반_권한_시스템_가이드.md create mode 100644 docs/메뉴_기반_권한_시스템_가이드.md create mode 100644 frontend/app/(main)/admin/roles/[id]/page.tsx create mode 100644 frontend/app/(main)/admin/roles/page.tsx create mode 100644 frontend/app/(main)/admin/userAuth/page.tsx create mode 100644 frontend/components/admin/MenuPermissionsTable.tsx create mode 100644 frontend/components/admin/RoleDeleteModal.tsx create mode 100644 frontend/components/admin/RoleDetailManagement.tsx create mode 100644 frontend/components/admin/RoleFormModal.tsx create mode 100644 frontend/components/admin/RoleManagement.tsx create mode 100644 frontend/components/admin/UserAuthEditModal.tsx create mode 100644 frontend/components/admin/UserAuthManagement.tsx create mode 100644 frontend/components/admin/UserAuthTable.tsx create mode 100644 frontend/components/common/DualListBox.tsx create mode 100644 frontend/lib/api/role.ts create mode 100644 frontend/lib/utils/errorUtils.ts diff --git a/backend-node/nodemon.json b/backend-node/nodemon.json index dc43f881..6923e9e9 100644 --- a/backend-node/nodemon.json +++ b/backend-node/nodemon.json @@ -1,15 +1,6 @@ { "watch": ["src"], - "ignore": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "data/**", - "uploads/**", - "logs/**", - "*.log" - ], "ext": "ts,json", - "exec": "ts-node src/app.ts", - "delay": 2000 + "ignore": ["src/**/*.spec.ts"], + "exec": "node -r ts-node/register/transpile-only src/app.ts" } - diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index bc7e5551..b75e6685 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -63,6 +63,7 @@ import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 +import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -220,6 +221,7 @@ app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 +app.use("/api/roles", roleRoutes); // 권한 그룹 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index bbef0e02..3dc65f5c 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -195,6 +195,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { search_email, deptCode, status, + companyCode, // 회사 코드 필터 추가 + size, // countPerPage 대신 사용 가능 } = req.query; // Raw Query를 사용한 사용자 목록 조회 @@ -203,6 +205,14 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { let queryParams: any[] = []; let paramIndex = 1; + // 회사 코드 필터 (권한 그룹 멤버 관리 시 사용) + if (companyCode && typeof companyCode === "string" && companyCode.trim()) { + whereConditions.push(`company_code = $${paramIndex}`); + queryParams.push(companyCode.trim()); + paramIndex++; + logger.info("회사 코드 필터 적용", { companyCode }); + } + // 검색 조건 처리 if (search && typeof search === "string" && search.trim()) { // 통합 검색 @@ -303,6 +313,16 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { } } + // 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우) + if (req.user && req.user.companyCode !== "*" && !companyCode) { + whereConditions.push(`company_code = $${paramIndex}`); + queryParams.push(req.user.companyCode); + paramIndex++; + logger.info("사용자 회사 코드 필터 적용", { + companyCode: req.user.companyCode, + }); + } + // 기존 필터들 if (deptCode) { whereConditions.push(`dept_code = $${paramIndex}`); @@ -331,7 +351,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const totalCount = parseInt(countResult[0]?.total || "0", 10); // 사용자 목록 조회 - const offset = (Number(page) - 1) * Number(countPerPage); + const limit = size ? Number(size) : Number(countPerPage); + const offset = (Number(page) - 1) * limit; const usersQuery = ` SELECT sabun, @@ -357,11 +378,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - const users = await query(usersQuery, [ - ...queryParams, - Number(countPerPage), - offset, - ]); + const users = await query(usersQuery, [...queryParams, limit, offset]); // 응답 데이터 가공 const processedUsers = users.map((user) => ({ @@ -393,8 +410,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { searchType, pagination: { page: Number(page), - limit: Number(countPerPage), - totalPages: Math.ceil(totalCount / Number(countPerPage)), + limit: limit, + totalPages: Math.ceil(totalCount / limit), }, message: "사용자 목록 조회 성공", }; @@ -404,7 +421,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { returnedCount: processedUsers.length, searchType, currentPage: Number(page), - countPerPage: Number(countPerPage), + limit: limit, + companyCode: companyCode || "all", }); res.status(200).json(response); @@ -1379,7 +1397,7 @@ export const getDepartmentList = async ( // 회사 코드 필터 if (companyCode) { - whereConditions.push(`company_name = $${paramIndex}`); + whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(companyCode); paramIndex++; } @@ -1420,6 +1438,7 @@ export const getDepartmentList = async ( data_type, status, sales_yn, + company_code, company_name FROM dept_info ${whereClause} @@ -1445,6 +1464,7 @@ export const getDepartmentList = async ( dataType: dept.data_type, status: dept.status || "active", salesYn: dept.sales_yn, + companyCode: dept.company_code, companyName: dept.company_name, children: [], }); @@ -1480,6 +1500,7 @@ export const getDepartmentList = async ( dataType: dept.data_type, status: dept.status || "active", salesYn: dept.sales_yn, + companyCode: dept.company_code, companyName: dept.company_name, })), }, @@ -1947,10 +1968,23 @@ export const changeUserStatus = async ( export const saveUser = async (req: AuthenticatedRequest, res: Response) => { try { const userData = req.body; - logger.info("사용자 저장 요청", { userData, user: req.user }); + const isUpdate = req.method === "PUT"; // PUT 요청이면 수정 + + logger.info("사용자 저장 요청", { + userData, + user: req.user, + isUpdate, + method: req.method, + }); // 필수 필드 검증 - const requiredFields = ["userId", "userName", "userPassword"]; + let requiredFields = ["userId", "userName"]; + + // 신규 등록 시에만 비밀번호 필수 + if (!isUpdate) { + requiredFields.push("userPassword"); + } + for (const field of requiredFields) { if (!userData[field] || userData[field].trim() === "") { res.status(400).json({ @@ -1965,10 +1999,15 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { } } - // 비밀번호 암호화 - const encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); + // 비밀번호 암호화 (비밀번호가 제공된 경우에만) + let encryptedPassword = null; + if (userData.userPassword) { + encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); + } // Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT) + const updatePasswordClause = encryptedPassword ? "user_password = $4," : ""; + const [savedUser] = await query( `INSERT INTO user_info ( user_id, user_name, user_name_eng, user_password, @@ -1979,7 +2018,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { ON CONFLICT (user_id) DO UPDATE SET user_name = $2, user_name_eng = $3, - user_password = $4, + ${updatePasswordClause} dept_code = $5, dept_name = $6, position_code = $7, @@ -1998,7 +2037,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { userData.userId, userData.userName, userData.userNameEng || null, - encryptedPassword, + encryptedPassword || "", // 빈 문자열로 넣되, UPDATE에서는 조건부로 제외 userData.deptCode || null, userData.deptName || null, userData.positionCode || null, @@ -2017,23 +2056,26 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { ); // 기존 사용자인지 새 사용자인지 확인 (regdate로 판단) - const isUpdate = + const isExistingUser = savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000; - logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", { - userId: userData.userId, - }); + logger.info( + isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", + { + userId: userData.userId, + } + ); const response = { success: true, result: true, - message: isUpdate + message: isExistingUser ? "사용자 정보가 수정되었습니다." : "사용자가 등록되었습니다.", data: { userId: userData.userId, - isUpdate, + isUpdate: isExistingUser, }, }; diff --git a/backend-node/src/controllers/dataflowDiagramController.ts b/backend-node/src/controllers/dataflowDiagramController.ts index ad64db21..2af23c0d 100644 --- a/backend-node/src/controllers/dataflowDiagramController.ts +++ b/backend-node/src/controllers/dataflowDiagramController.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { getDataflowDiagrams as getDataflowDiagramsService, getDataflowDiagramById as getDataflowDiagramByIdService, @@ -12,15 +13,33 @@ import { logger } from "../utils/logger"; /** * 관계도 목록 조회 (페이지네이션) */ -export const getDataflowDiagrams = async (req: Request, res: Response) => { +export const getDataflowDiagrams = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const page = parseInt(req.query.page as string) || 1; const size = parseInt(req.query.size as string) || 20; const searchTerm = req.query.searchTerm as string; - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + // 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체 + companyCode = (req.query.companyCode as string) || "*"; + } else { + // 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용 + companyCode = userCompanyCode || "*"; + } + + logger.info("관계도 목록 조회", { + userId: req.user?.userId, + userCompanyCode, + filterCompanyCode: companyCode, + page, + size, + }); const result = await getDataflowDiagramsService( companyCode, @@ -46,13 +65,21 @@ export const getDataflowDiagrams = async (req: Request, res: Response) => { /** * 특정 관계도 조회 */ -export const getDataflowDiagramById = async (req: Request, res: Response) => { +export const getDataflowDiagramById = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + companyCode = (req.query.companyCode as string) || "*"; + } else { + companyCode = userCompanyCode || "*"; + } if (isNaN(diagramId)) { return res.status(400).json({ @@ -87,7 +114,10 @@ export const getDataflowDiagramById = async (req: Request, res: Response) => { /** * 새로운 관계도 생성 */ -export const createDataflowDiagram = async (req: Request, res: Response) => { +export const createDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { diagram_name, @@ -96,27 +126,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => { category, control, plan, - company_code, - created_by, - updated_by, } = req.body; - logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code }); + const userCompanyCode = req.user?.companyCode; + const userId = req.user?.userId || "SYSTEM"; + + // 회사 코드는 로그인한 사용자의 회사 코드 사용 (슈퍼 관리자는 요청 body에서 지정 가능) + let companyCode: string; + if (userCompanyCode === "*" && req.body.company_code) { + // 슈퍼 관리자가 특정 회사로 생성하는 경우 + companyCode = req.body.company_code; + } else { + // 일반 사용자/회사 관리자는 자신의 회사로 생성 + companyCode = userCompanyCode || "*"; + } + + logger.info(`새 관계도 생성 요청:`, { + diagram_name, + companyCode, + userId, + userCompanyCode, + }); logger.info(`node_positions:`, node_positions); logger.info(`category:`, category); logger.info(`control:`, control); logger.info(`plan:`, plan); - logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2)); - const companyCode = - company_code || - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; - const userId = - created_by || - updated_by || - (req.headers["x-user-id"] as string) || - "SYSTEM"; if (!diagram_name || !relationships) { return res.status(400).json({ @@ -184,24 +218,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => { /** * 관계도 수정 */ -export const updateDataflowDiagram = async (req: Request, res: Response) => { +export const updateDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const { updated_by } = req.body; - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; - const userId = - updated_by || (req.headers["x-user-id"] as string) || "SYSTEM"; + const userCompanyCode = req.user?.companyCode; + const userId = req.user?.userId || "SYSTEM"; - logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`); + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + companyCode = (req.query.companyCode as string) || "*"; + } else { + companyCode = userCompanyCode || "*"; + } + + logger.info(`관계도 수정 요청`, { + diagramId, + companyCode, + userId, + userCompanyCode, + }); logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2)); logger.info(`node_positions:`, req.body.node_positions); - logger.info(`요청 Body 키들:`, Object.keys(req.body)); - logger.info(`요청 Body 타입:`, typeof req.body); - logger.info(`node_positions 타입:`, typeof req.body.node_positions); - logger.info(`node_positions 값:`, req.body.node_positions); if (isNaN(diagramId)) { return res.status(400).json({ @@ -265,13 +306,21 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => { /** * 관계도 삭제 */ -export const deleteDataflowDiagram = async (req: Request, res: Response) => { +export const deleteDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + companyCode = (req.query.companyCode as string) || "*"; + } else { + companyCode = userCompanyCode || "*"; + } if (isNaN(diagramId)) { return res.status(400).json({ @@ -306,21 +355,25 @@ export const deleteDataflowDiagram = async (req: Request, res: Response) => { /** * 관계도 복제 */ -export const copyDataflowDiagram = async (req: Request, res: Response) => { +export const copyDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const { - new_name, - companyCode: bodyCompanyCode, - userId: bodyUserId, - } = req.body; - const companyCode = - bodyCompanyCode || - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; - const userId = - bodyUserId || (req.headers["x-user-id"] as string) || "SYSTEM"; + const { new_name } = req.body; + const userCompanyCode = req.user?.companyCode; + const userId = req.user?.userId || "SYSTEM"; + + // 회사 코드는 로그인한 사용자의 회사 코드 사용 + let companyCode: string; + if (userCompanyCode === "*" && req.body.companyCode) { + // 슈퍼 관리자가 특정 회사로 복제하는 경우 + companyCode = req.body.companyCode; + } else { + // 일반 사용자/회사 관리자는 자신의 회사로 복제 + companyCode = userCompanyCode || "*"; + } if (isNaN(diagramId)) { return res.status(400).json({ diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index b13d6755..3ff159f1 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 관리 컨트롤러 */ @@ -34,6 +35,7 @@ export class FlowController { const { name, description, tableName, dbSourceType, dbConnectionId } = req.body; const userId = (req as any).user?.userId || "system"; + const userCompanyCode = (req as any).user?.companyCode; console.log("🔍 createFlowDefinition called with:", { name, @@ -41,6 +43,7 @@ export class FlowController { tableName, dbSourceType, dbConnectionId, + userCompanyCode, }); if (!name) { @@ -66,7 +69,8 @@ export class FlowController { const flowDef = await this.flowDefinitionService.create( { name, description, tableName, dbSourceType, dbConnectionId }, - userId + userId, + userCompanyCode ); res.json({ @@ -88,12 +92,25 @@ export class FlowController { getFlowDefinitions = async (req: Request, res: Response): Promise => { try { const { tableName, isActive } = req.query; + const user = (req as any).user; + const userCompanyCode = user?.companyCode; + + console.log("🎯 getFlowDefinitions called:", { + userId: user?.userId, + userCompanyCode: userCompanyCode, + userType: user?.userType, + tableName, + isActive, + }); const flows = await this.flowDefinitionService.findAll( tableName as string | undefined, - isActive !== undefined ? isActive === "true" : undefined + isActive !== undefined ? isActive === "true" : undefined, + userCompanyCode ); + console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`); + res.json({ success: true, data: flows, diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts new file mode 100644 index 00000000..3c6ed1e5 --- /dev/null +++ b/backend-node/src/controllers/roleController.ts @@ -0,0 +1,864 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { RoleService } from "../services/roleService"; +import { logger } from "../utils/logger"; +import { + isSuperAdmin, + isCompanyAdmin, + canAccessCompanyData, +} from "../utils/permissionUtils"; + +/** + * 권한 그룹 목록 조회 + * - 회사 관리자: 자기 회사 권한 그룹만 조회 + * - 최고 관리자: 모든 회사 권한 그룹 조회 (companyCode 미지정 시 전체 조회) + */ +export const getRoleGroups = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const search = req.query.search as string | undefined; + const companyCode = req.query.companyCode as string | undefined; + + // 최고 관리자가 아닌 경우 자기 회사만 조회 + let targetCompanyCode: string | undefined; + if (isSuperAdmin(req.user)) { + // 최고 관리자: companyCode 파라미터가 있으면 해당 회사만, 없으면 전체 조회 + targetCompanyCode = companyCode; + logger.info("권한 그룹 목록 조회 (최고 관리자)", { + userId: req.user?.userId, + targetCompanyCode: targetCompanyCode || "전체", + search, + }); + } else { + // 일반 관리자: 자기 회사만 조회 + targetCompanyCode = req.user?.companyCode; + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + message: "회사 코드가 필요합니다", + }); + return; + } + logger.info("권한 그룹 목록 조회 (회사 관리자)", { + userId: req.user?.userId, + companyCode: targetCompanyCode, + search, + }); + } + + const roleGroups = await RoleService.getRoleGroups( + targetCompanyCode, + search + ); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 목록 조회 성공", + data: roleGroups, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 목록 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 상세 조회 + */ +export const getRoleGroupById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const objid = parseInt(req.params.id, 10); + + if (isNaN(objid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + const roleGroup = await RoleService.getRoleGroupById(objid); + + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크: 슈퍼관리자 또는 해당 회사 관리자만 조회 가능 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한이 없습니다", + }); + return; + } + + const response: ApiResponse = { + success: true, + message: "권한 그룹 상세 조회 성공", + data: roleGroup, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 상세 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 상세 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 생성 + * - 회사 관리자: 자기 회사에만 권한 그룹 생성 가능 + * - 최고 관리자: 모든 회사에 권한 그룹 생성 가능 + */ +export const createRoleGroup = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { authName, authCode, companyCode } = req.body; + + if (!authName || !authCode || !companyCode) { + res.status(400).json({ + success: false, + message: "필수 정보가 누락되었습니다 (authName, authCode, companyCode)", + }); + return; + } + + // 권한 체크: 회사 관리자 이상만 생성 가능 + if (!isSuperAdmin(req.user) && !isCompanyAdmin(req.user)) { + res.status(403).json({ + success: false, + message: "권한 그룹 생성 권한이 없습니다", + }); + return; + } + + // 회사 관리자는 자기 회사에만 권한 그룹 생성 가능 + if (!isSuperAdmin(req.user) && req.user?.companyCode !== companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 권한 그룹을 생성할 수 없습니다", + }); + return; + } + + const roleGroup = await RoleService.createRoleGroup({ + authName, + authCode, + companyCode, + writer: req.user?.userId || "SYSTEM", + }); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 생성 성공", + data: roleGroup, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("권한 그룹 생성 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 생성 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 수정 + */ +export const updateRoleGroup = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const objid = parseInt(req.params.id, 10); + const { authName, authCode, status } = req.body; + + if (isNaN(objid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const existingRoleGroup = await RoleService.getRoleGroupById(objid); + if (!existingRoleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, existingRoleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 수정 권한이 없습니다", + }); + return; + } + + const roleGroup = await RoleService.updateRoleGroup(objid, { + authName, + authCode, + status, + }); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 수정 성공", + data: roleGroup, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 수정 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 수정 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 삭제 + */ +export const deleteRoleGroup = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const objid = parseInt(req.params.id, 10); + + if (isNaN(objid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const existingRoleGroup = await RoleService.getRoleGroupById(objid); + if (!existingRoleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, existingRoleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 삭제 권한이 없습니다", + }); + return; + } + + await RoleService.deleteRoleGroup(objid); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 삭제 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 삭제 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 삭제 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 목록 조회 + */ +export const getRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 조회 권한이 없습니다", + }); + return; + } + + const members = await RoleService.getRoleMembers(masterObjid); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버 조회 성공", + data: members, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 추가 + */ +export const addRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ + success: false, + message: "추가할 사용자 ID 목록이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 추가 권한이 없습니다", + }); + return; + } + + await RoleService.addRoleMembers( + masterObjid, + userIds, + req.user?.userId || "SYSTEM" + ); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버 추가 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 추가 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 추가 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 일괄 업데이트 + */ +export const updateRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(userIds)) { + res.status(400).json({ + success: false, + message: "사용자 ID 배열이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 수정 권한이 없습니다", + }); + return; + } + + // 기존 멤버 조회 + const existingMembers = await RoleService.getRoleMembers(masterObjid); + const existingUserIds = existingMembers.map((m: any) => m.userId); + + // 추가할 멤버 (새로 추가된 것들) + const toAdd = userIds.filter((id: string) => !existingUserIds.includes(id)); + + // 제거할 멤버 (기존에 있었는데 없어진 것들) + const toRemove = existingUserIds.filter( + (id: string) => !userIds.includes(id) + ); + + // 추가 + if (toAdd.length > 0) { + await RoleService.addRoleMembers( + masterObjid, + toAdd, + req.user?.userId || "SYSTEM" + ); + } + + // 제거 + if (toRemove.length > 0) { + await RoleService.removeRoleMembers( + masterObjid, + toRemove, + req.user?.userId || "SYSTEM" + ); + } + + logger.info("권한 그룹 멤버 일괄 업데이트 성공", { + masterObjid, + added: toAdd.length, + removed: toRemove.length, + }); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버가 업데이트되었습니다", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 업데이트 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 업데이트 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 제거 + */ +export const removeRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ + success: false, + message: "제거할 사용자 ID 목록이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 제거 권한이 없습니다", + }); + return; + } + + await RoleService.removeRoleMembers( + masterObjid, + userIds, + req.user?.userId || "SYSTEM" + ); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버 제거 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 제거 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 제거 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 메뉴 권한 목록 조회 + */ +export const getMenuPermissions = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const authObjid = parseInt(req.params.id, 10); + + if (isNaN(authObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(authObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "메뉴 권한 조회 권한이 없습니다", + }); + return; + } + + const permissions = await RoleService.getMenuPermissions(authObjid); + + const response: ApiResponse = { + success: true, + message: "메뉴 권한 조회 성공", + data: permissions, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 권한 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "메뉴 권한 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 메뉴 권한 설정 + */ +export const setMenuPermissions = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const authObjid = parseInt(req.params.id, 10); + const { permissions } = req.body; + + if (isNaN(authObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(permissions)) { + res.status(400).json({ + success: false, + message: "권한 목록이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(authObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "메뉴 권한 설정 권한이 없습니다", + }); + return; + } + + await RoleService.setMenuPermissions( + authObjid, + permissions, + req.user?.userId || "SYSTEM" + ); + + const response: ApiResponse = { + success: true, + message: "메뉴 권한 설정 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 권한 설정 실패", { error }); + res.status(500).json({ + success: false, + message: "메뉴 권한 설정 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 사용자가 속한 권한 그룹 목록 조회 + */ +export const getUserRoleGroups = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const userId = req.params.userId || req.user?.userId; + const companyCode = req.user?.companyCode; + + if (!userId || !companyCode) { + res.status(400).json({ + success: false, + message: "사용자 ID 또는 회사 코드가 필요합니다", + }); + return; + } + + const roleGroups = await RoleService.getUserRoleGroups(userId, companyCode); + + const response: ApiResponse = { + success: true, + message: "사용자 권한 그룹 조회 성공", + data: roleGroups, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("사용자 권한 그룹 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "사용자 권한 그룹 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 전체 메뉴 목록 조회 (권한 설정용) + */ +export const getAllMenus = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const requestedCompanyCode = req.query.companyCode as string | undefined; + + logger.info("🔍 [getAllMenus] API 호출", { + userId: req.user?.userId, + userType: req.user?.userType, + userCompanyCode: req.user?.companyCode, + requestedCompanyCode, + }); + + // 권한 체크 + if (!isSuperAdmin(req.user) && !isCompanyAdmin(req.user)) { + logger.warn("❌ [getAllMenus] 권한 없음", { + userId: req.user?.userId, + userType: req.user?.userType, + }); + res.status(403).json({ + success: false, + message: "관리자 권한이 필요합니다", + }); + return; + } + + // 회사 코드 결정: 최고 관리자는 요청한 코드 사용, 회사 관리자는 자기 회사만 + let companyCode: string | undefined; + if (isSuperAdmin(req.user)) { + // 최고 관리자: 요청한 회사 코드 사용 (없으면 전체) + companyCode = requestedCompanyCode; + logger.info("✅ [getAllMenus] 최고 관리자 - 요청된 회사 코드 사용", { + companyCode: companyCode || "전체", + }); + } else { + // 회사 관리자: 자기 회사 코드만 사용 + companyCode = req.user?.companyCode; + logger.info("✅ [getAllMenus] 회사 관리자 - 자기 회사 코드 적용", { + companyCode, + }); + } + + logger.info("✅ [getAllMenus] 관리자 권한 확인 완료", { + isSuperAdmin: isSuperAdmin(req.user), + isCompanyAdmin: isCompanyAdmin(req.user), + finalCompanyCode: companyCode || "전체", + }); + + const menus = await RoleService.getAllMenus(companyCode); + + logger.info("✅ [getAllMenus] API 응답 준비", { + menuCount: menus.length, + companyCode: companyCode || "전체", + }); + + const response: ApiResponse = { + success: true, + message: "메뉴 목록 조회 성공", + data: menus, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("❌ [getAllMenus] 메뉴 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "메뉴 목록 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/middleware/permissionMiddleware.ts b/backend-node/src/middleware/permissionMiddleware.ts new file mode 100644 index 00000000..34679bf6 --- /dev/null +++ b/backend-node/src/middleware/permissionMiddleware.ts @@ -0,0 +1,430 @@ +/** + * 권한 체크 미들웨어 + * 3단계 권한 체계 적용: SUPER_ADMIN / COMPANY_ADMIN / USER + */ + +import { Request, Response, NextFunction } from "express"; +import { PersonBean } from "../types/auth"; +import { + isSuperAdmin, + isCompanyAdmin, + isAdmin, + canExecuteDDL, + canManageUsers, + canManageCompanySettings, + canManageCompanies, + canAccessCompanyData, + PermissionLevel, + createPermissionError, +} from "../utils/permissionUtils"; +import { logger } from "../utils/logger"; + +/** + * 인증된 요청 타입 + */ +export interface AuthenticatedRequest extends Request { + user?: PersonBean; +} + +/** + * 슈퍼관리자 권한 필수 미들웨어 + */ +export const requireSuperAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + logger.warn("슈퍼관리자 권한 필요 - 인증되지 않은 사용자", { + ip: req.ip, + url: req.originalUrl, + }); + + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!isSuperAdmin(req.user)) { + logger.warn("슈퍼관리자 권한 부족", { + userId: req.user.userId, + companyCode: req.user.companyCode, + userType: req.user.userType, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json(createPermissionError(PermissionLevel.SUPER_ADMIN)); + return; + } + + logger.info("슈퍼관리자 권한 확인 완료", { + userId: req.user.userId, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("슈퍼관리자 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 관리자 권한 필수 미들웨어 (슈퍼관리자 + 회사관리자) + */ +export const requireAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!isAdmin(req.user)) { + logger.warn("관리자 권한 부족", { + userId: req.user.userId, + userType: req.user.userType, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res + .status(403) + .json(createPermissionError(PermissionLevel.COMPANY_ADMIN)); + return; + } + + logger.info("관리자 권한 확인 완료", { + userId: req.user.userId, + userType: req.user.userType, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("관리자 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 회사 데이터 접근 권한 체크 미들웨어 + * req.params.companyCode 또는 req.query.companyCode 확인 + */ +export const requireCompanyAccess = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + const targetCompanyCode = + (req.params.companyCode as string) || + (req.query.companyCode as string) || + (req.body.companyCode as string); + + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + error: { + code: "COMPANY_CODE_REQUIRED", + details: "회사 코드가 필요합니다.", + }, + }); + return; + } + + if (!canAccessCompanyData(req.user, targetCompanyCode)) { + logger.warn("회사 데이터 접근 권한 없음", { + userId: req.user.userId, + userCompanyCode: req.user.companyCode, + targetCompanyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "COMPANY_ACCESS_DENIED", + details: "해당 회사의 데이터에 접근할 권한이 없습니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("회사 데이터 접근 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 사용자 관리 권한 체크 미들웨어 + */ +export const requireUserManagement = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + const targetCompanyCode = + (req.params.companyCode as string) || + (req.query.companyCode as string) || + (req.body.companyCode as string); + + if (!canManageUsers(req.user, targetCompanyCode)) { + logger.warn("사용자 관리 권한 없음", { + userId: req.user.userId, + userCompanyCode: req.user.companyCode, + targetCompanyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "USER_MANAGEMENT_DENIED", + details: "사용자 관리 권한이 없습니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("사용자 관리 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 회사 설정 변경 권한 체크 미들웨어 + */ +export const requireCompanySettingsManagement = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + const targetCompanyCode = + (req.params.companyCode as string) || + (req.query.companyCode as string) || + (req.body.companyCode as string); + + if (!canManageCompanySettings(req.user, targetCompanyCode)) { + logger.warn("회사 설정 변경 권한 없음", { + userId: req.user.userId, + userCompanyCode: req.user.companyCode, + targetCompanyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "COMPANY_SETTINGS_DENIED", + details: "회사 설정 변경 권한이 없습니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("회사 설정 변경 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 회사 생성/삭제 권한 체크 미들웨어 (슈퍼관리자 전용) + */ +export const requireCompanyManagement = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!canManageCompanies(req.user)) { + logger.warn("회사 관리 권한 없음", { + userId: req.user.userId, + userType: req.user.userType, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "COMPANY_MANAGEMENT_DENIED", + details: "회사 생성/삭제는 최고 관리자만 가능합니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("회사 관리 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * DDL 실행 권한 체크 미들웨어 (슈퍼관리자 전용) + */ +export const requireDDLPermission = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!canExecuteDDL(req.user)) { + logger.warn("DDL 실행 권한 없음", { + userId: req.user.userId, + userType: req.user.userType, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "DDL_EXECUTION_DENIED", + details: + "DDL 실행은 최고 관리자만 가능합니다. 데이터베이스 스키마 변경은 company_code가 '*'이고 user_type이 'SUPER_ADMIN'인 사용자만 수행할 수 있습니다.", + }, + }); + return; + } + + logger.info("DDL 실행 권한 확인 완료", { + userId: req.user.userId, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("DDL 실행 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 895b96e9..c6ae0bfc 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -45,7 +45,8 @@ router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 -router.post("/users", saveUser); // 사용자 등록/수정 +router.post("/users", saveUser); // 사용자 등록/수정 (기존) +router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 0e9a2d3e..7ede970a 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -6,27 +6,39 @@ import { Router, Request, Response } from "express"; import { query, queryOne } from "../../database/db"; import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; +import { AuthenticatedRequest } from "../../types/auth"; const router = Router(); /** * 플로우 목록 조회 */ -router.get("/", async (req: Request, res: Response) => { +router.get("/", async (req: AuthenticatedRequest, res: Response) => { try { - const flows = await query( - ` + const userCompanyCode = req.user?.companyCode; + + let sqlQuery = ` SELECT flow_id as "flowId", flow_name as "flowName", flow_description as "flowDescription", + company_code as "companyCode", created_at as "createdAt", updated_at as "updatedAt" FROM node_flows - ORDER BY updated_at DESC - `, - [] - ); + `; + + const params: any[] = []; + + // 슈퍼 관리자가 아니면 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + sqlQuery += ` WHERE company_code = $1`; + params.push(userCompanyCode); + } + + sqlQuery += ` ORDER BY updated_at DESC`; + + const flows = await query(sqlQuery, params); return res.json({ success: true, @@ -86,9 +98,10 @@ router.get("/:flowId", async (req: Request, res: Response) => { /** * 플로우 저장 (신규) */ -router.post("/", async (req: Request, res: Response) => { +router.post("/", async (req: AuthenticatedRequest, res: Response) => { try { const { flowName, flowDescription, flowData } = req.body; + const userCompanyCode = req.user?.companyCode || "*"; if (!flowName || !flowData) { return res.status(400).json({ @@ -99,14 +112,16 @@ router.post("/", async (req: Request, res: Response) => { const result = await queryOne( ` - INSERT INTO node_flows (flow_name, flow_description, flow_data) - VALUES ($1, $2, $3) + INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code) + VALUES ($1, $2, $3, $4) RETURNING flow_id as "flowId" `, - [flowName, flowDescription || "", flowData] + [flowName, flowDescription || "", flowData, userCompanyCode] ); - logger.info(`플로우 저장 성공: ${result.flowId}`); + logger.info( + `플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})` + ); return res.json({ success: true, diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts index ca7d1600..5ad87dab 100644 --- a/backend-node/src/routes/externalDbConnectionRoutes.ts +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -9,6 +9,7 @@ import { } from "../types/externalDbTypes"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; +import logger from "../utils/logger"; const router = Router(); @@ -53,10 +54,22 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCodeFilter: string | undefined; + if (userCompanyCode === "*") { + // 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체 + companyCodeFilter = req.query.company_code as string; + } else { + // 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용 + companyCodeFilter = userCompanyCode; + } + const filter: ExternalDbConnectionFilter = { db_type: req.query.db_type as string, is_active: req.query.is_active as string, - company_code: req.query.company_code as string, + company_code: companyCodeFilter, search: req.query.search as string, }; @@ -67,6 +80,13 @@ router.get( } }); + logger.info("외부 DB 연결 목록 조회", { + userId: req.user?.userId, + userCompanyCode, + filterCompanyCode: companyCodeFilter, + filter, + }); + const result = await ExternalDbConnectionService.getConnections(filter); if (result.success) { @@ -470,12 +490,32 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { + // 로그인한 사용자의 회사 코드 가져오기 + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 지정한 회사 또는 전체(*) 조회 가능 + // 일반 사용자/회사 관리자는 자신의 회사만 조회 가능 + let companyCodeFilter: string; + if (userCompanyCode === "*") { + // 슈퍼 관리자 + companyCodeFilter = (req.query.company_code as string) || "*"; + } else { + // 회사 관리자 또는 일반 사용자 + companyCodeFilter = userCompanyCode || "*"; + } + // 활성 상태의 외부 커넥션 조회 const filter: ExternalDbConnectionFilter = { is_active: "Y", - company_code: (req.query.company_code as string) || "*", + company_code: companyCodeFilter, }; + logger.info("제어관리용 활성 커넥션 조회", { + userId: req.user?.userId, + userCompanyCode, + filterCompanyCode: companyCodeFilter, + }); + const externalConnections = await ExternalDbConnectionService.getConnections(filter); diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 06c6795b..08d1ac5f 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -9,6 +9,9 @@ import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); const flowController = new FlowController(); +// 모든 플로우 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + // ==================== 플로우 정의 ==================== router.post("/definitions", flowController.createFlowDefinition); router.get("/definitions", flowController.getFlowDefinitions); @@ -33,8 +36,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList); router.get("/:flowId/steps/counts", flowController.getAllStepCounts); // ==================== 데이터 이동 ==================== -router.post("/move", authenticateToken, flowController.moveData); -router.post("/move-batch", authenticateToken, flowController.moveBatchData); +router.post("/move", flowController.moveData); +router.post("/move-batch", flowController.moveBatchData); // ==================== 오딧 로그 ==================== router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts new file mode 100644 index 00000000..21c17ecb --- /dev/null +++ b/backend-node/src/routes/roleRoutes.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import { + getRoleGroups, + getRoleGroupById, + createRoleGroup, + updateRoleGroup, + deleteRoleGroup, + getRoleMembers, + addRoleMembers, + updateRoleMembers, + removeRoleMembers, + getMenuPermissions, + setMenuPermissions, + getUserRoleGroups, + getAllMenus, +} from "../controllers/roleController"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { requireAdmin } from "../middleware/permissionMiddleware"; + +const router = Router(); + +// 모든 role 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 권한 그룹 CRUD + */ +// 권한 그룹 목록 조회 (회사별) +router.get("/", requireAdmin, getRoleGroups); + +// 권한 그룹 상세 조회 +router.get("/:id", requireAdmin, getRoleGroupById); + +// 권한 그룹 생성 (회사 관리자 이상) +router.post("/", requireAdmin, createRoleGroup); + +// 권한 그룹 수정 (회사 관리자 이상) +router.put("/:id", requireAdmin, updateRoleGroup); + +// 권한 그룹 삭제 (회사 관리자 이상) +router.delete("/:id", requireAdmin, deleteRoleGroup); + +/** + * 권한 그룹 멤버 관리 + */ +// 권한 그룹 멤버 목록 조회 +router.get("/:id/members", requireAdmin, getRoleMembers); + +// 권한 그룹 멤버 일괄 업데이트 (전체 교체) +router.put("/:id/members", requireAdmin, updateRoleMembers); + +// 권한 그룹 멤버 추가 (여러 명) +router.post("/:id/members", requireAdmin, addRoleMembers); + +// 권한 그룹 멤버 제거 (여러 명) +router.delete("/:id/members", requireAdmin, removeRoleMembers); + +/** + * 메뉴 권한 관리 + */ +// 전체 메뉴 목록 조회 (권한 설정용) +router.get("/menus/all", requireAdmin, getAllMenus); + +// 메뉴 권한 목록 조회 +router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); + +// 메뉴 권한 설정 +router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); + +/** + * 사용자 권한 그룹 조회 + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + +export default router; diff --git a/backend-node/src/services/RoleService_backup.ts b/backend-node/src/services/RoleService_backup.ts new file mode 100644 index 00000000..2932a2cc --- /dev/null +++ b/backend-node/src/services/RoleService_backup.ts @@ -0,0 +1,554 @@ +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 권한 그룹 인터페이스 + */ +export interface RoleGroup { + objid: number; + authName: string; + authCode: string; + companyCode: string; + status: string; + writer: string; + regdate: Date; + memberCount?: number; + menuCount?: number; + memberNames?: string; +} + +/** + * 권한 그룹 멤버 인터페이스 + */ +export interface RoleMember { + objid: number; + masterObjid: number; + userId: string; + userName?: string; + deptName?: string; + positionName?: string; + writer: string; + regdate: Date; +} + +/** + * 메뉴 권한 인터페이스 + */ +export interface MenuPermission { + objid: number; + menuObjid: number; + authObjid: number; + menuName?: string; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + writer: string; + regdate: Date; +} + +/** + * 권한 그룹 서비스 + */ +export class RoleService { + /** + * 회사별 권한 그룹 목록 조회 + * @param companyCode - 회사 코드 (undefined 시 전체 조회) + * @param search - 검색어 + */ + static async getRoleGroups( + companyCode?: string, + search?: string + ): Promise { + try { + let sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate, + (SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount", + (SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount", + (SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name) + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = am.objid) AS "memberNames" + FROM authority_master am + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (companyCode가 undefined면 전체 조회) + if (companyCode) { + sql += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터 + if (search && search.trim()) { + sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`; + params.push(`%${search.trim()}%`); + paramIndex++; + } + + sql += ` ORDER BY regdate DESC`; + + logger.info("권한 그룹 조회 SQL", { sql, params }); + const result = await query(sql, params); + logger.info("권한 그룹 조회 결과", { count: result.length }); + return result; + } catch (error) { + logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search }); + throw error; + } + } + + /** + * 권한 그룹 상세 조회 + */ + static async getRoleGroupById(objid: number): Promise { + try { + const sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate + FROM authority_master + WHERE objid = $1 + `; + + const result = await query(sql, [objid]); + return result.length > 0 ? result[0] : null; + } catch (error) { + logger.error("권한 그룹 상세 조회 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 생성 + */ + static async createRoleGroup(data: { + authName: string; + authCode: string; + companyCode: string; + writer: string; + }): Promise { + try { + const sql = ` + INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) + VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, [ + data.authName, + data.authCode, + data.companyCode, + data.writer, + ]); + + logger.info("권한 그룹 생성 성공", { + objid: result[0].objid, + authName: data.authName, + }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 생성 실패", { error, data }); + throw error; + } + } + + /** + * 권한 그룹 수정 + */ + static async updateRoleGroup( + objid: number, + data: { + authName?: string; + authCode?: string; + status?: string; + } + ): Promise { + try { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.authName !== undefined) { + updates.push(`auth_name = $${paramIndex}`); + params.push(data.authName); + paramIndex++; + } + + if (data.authCode !== undefined) { + updates.push(`auth_code = $${paramIndex}`); + params.push(data.authCode); + paramIndex++; + } + + if (data.status !== undefined) { + updates.push(`status = $${paramIndex}`); + params.push(data.status); + paramIndex++; + } + + if (updates.length === 0) { + throw new Error("수정할 데이터가 없습니다"); + } + + params.push(objid); + + const sql = ` + UPDATE authority_master + SET ${updates.join(", ")} + WHERE objid = $${paramIndex} + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, params); + + if (result.length === 0) { + throw new Error("권한 그룹을 찾을 수 없습니다"); + } + + logger.info("권한 그룹 수정 성공", { objid, updates }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 수정 실패", { error, objid, data }); + throw error; + } + } + + /** + * 권한 그룹 삭제 + */ + static async deleteRoleGroup(objid: number): Promise { + try { + // CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth) + await query("DELETE FROM authority_master WHERE objid = $1", [objid]); + logger.info("권한 그룹 삭제 성공", { objid }); + } catch (error) { + logger.error("권한 그룹 삭제 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 목록 조회 + */ + static async getRoleMembers(masterObjid: number): Promise { + try { + const sql = ` + SELECT + asu.objid, + asu.master_objid AS "masterObjid", + asu.user_id AS "userId", + ui.user_name AS "userName", + ui.dept_name AS "deptName", + ui.position_name AS "positionName", + asu.writer, + asu.regdate + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = $1 + ORDER BY ui.user_name + `; + + const result = await query(sql, [masterObjid]); + return result; + } catch (error) { + logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 추가 (여러 명) + */ + static async addRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + // 이미 존재하는 멤버 제외 + const existingSql = ` + SELECT user_id + FROM authority_sub_user + WHERE master_objid = $1 AND user_id = ANY($2) + `; + const existing = await query<{ user_id: string }>(existingSql, [ + masterObjid, + userIds, + ]); + const existingIds = new Set( + existing.map((row: { user_id: string }) => row.user_id) + ); + + const newUserIds = userIds.filter((userId) => !existingIds.has(userId)); + + if (newUserIds.length === 0) { + logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds }); + return; + } + + // 배치 삽입 + const values = newUserIds + .map( + (_, index) => + `(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())` + ) + .join(", "); + + const sql = ` + INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate) + VALUES ${values} + `; + + await query(sql, [masterObjid, ...newUserIds, writer]); + + // 히스토리 기록 + for (const userId of newUserIds) { + await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer); + } + + logger.info("권한 그룹 멤버 추가 성공", { + masterObjid, + count: newUserIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 그룹 멤버 제거 (여러 명) + */ + static async removeRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + await query( + "DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)", + [masterObjid, userIds] + ); + + // 히스토리 기록 + for (const userId of userIds) { + await this.insertAuthorityHistory( + masterObjid, + userId, + "REMOVE", + writer + ); + } + + logger.info("권한 그룹 멤버 제거 성공", { + masterObjid, + count: userIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 히스토리 기록 + */ + private static async insertAuthorityHistory( + masterObjid: number, + userId: string, + historyType: "ADD" | "REMOVE", + writer: string + ): Promise { + try { + const sql = ` + INSERT INTO authority_master_history + (objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date) + SELECT + nextval('seq_authority_master'), + $1, + am.auth_name, + am.auth_code, + $2, + am.status, + $3, + $4, + NOW() + FROM authority_master am + WHERE am.objid = $1 + `; + + await query(sql, [masterObjid, userId, historyType, writer]); + } catch (error) { + logger.error("권한 히스토리 기록 실패", { + error, + masterObjid, + userId, + historyType, + }); + // 히스토리 기록 실패는 메인 작업을 중단하지 않음 + } + } + + /** + * 메뉴 권한 목록 조회 + */ + static async getMenuPermissions( + authObjid: number + ): Promise { + try { + const sql = ` + SELECT + rma.objid, + rma.menu_objid AS "menuObjid", + rma.auth_objid AS "authObjid", + mi.menu_name_kor AS "menuName", + mi.menu_code AS "menuCode", + mi.menu_url AS "menuUrl", + rma.create_yn AS "createYn", + rma.read_yn AS "readYn", + rma.update_yn AS "updateYn", + rma.delete_yn AS "deleteYn", + rma.execute_yn AS "executeYn", + rma.export_yn AS "exportYn", + rma.writer, + rma.regdate + FROM rel_menu_auth rma + LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid + WHERE rma.auth_objid = $1 + ORDER BY mi.menu_name_kor + `; + + const result = await query(sql, [authObjid]); + return result; + } catch (error) { + logger.error("메뉴 권한 조회 실패", { error, authObjid }); + throw error; + } + } + + /** + * 메뉴 권한 설정 (여러 메뉴) + */ + static async setMenuPermissions( + authObjid: number, + permissions: Array<{ + menuObjid: number; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + }>, + writer: string + ): Promise { + try { + // 기존 권한 삭제 + await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, + ]); + + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); + + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await query(sql, [authObjid, ...params, writer]); + } + + logger.info("메뉴 권한 설정 성공", { + authObjid, + count: permissions.length, + }); + } catch (error) { + logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions }); + throw error; + } + } + + /** + * 사용자가 속한 권한 그룹 목록 조회 + */ + static async getUserRoleGroups( + userId: string, + companyCode: string + ): Promise { + try { + const sql = ` + SELECT + am.objid, + am.auth_name AS "authName", + am.auth_code AS "authCode", + am.company_code AS "companyCode", + am.status, + am.writer, + am.regdate + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.company_code = $2 + AND am.status = 'active' + ORDER BY am.auth_name + `; + + const result = await query(sql, [userId, companyCode]); + return result; + } catch (error) { + logger.error("사용자 권한 그룹 조회 실패", { + error, + userId, + companyCode, + }); + throw error; + } + } + + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + static async getAllMenus(companyCode?: string): Promise { + try { + logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); + + let whereConditions: string[] = ["status = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (선택적) diff --git a/backend-node/src/services/RoleService_getAllMenus_fixed.ts b/backend-node/src/services/RoleService_getAllMenus_fixed.ts new file mode 100644 index 00000000..9dd1689d --- /dev/null +++ b/backend-node/src/services/RoleService_getAllMenus_fixed.ts @@ -0,0 +1,66 @@ + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + static async getAllMenus(companyCode?: string): Promise { + try { + logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); + + let whereConditions: string[] = ["status = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (선택적) + // 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회 + if (companyCode) { + whereConditions.push(`(company_code = \$${paramIndex} OR company_code = '*')`); + params.push(companyCode); + paramIndex++; + logger.info("📋 회사 코드 필터 적용", { companyCode }); + } else { + logger.info("📋 회사 코드 필터 없음 (전체 조회)"); + } + + const whereClause = whereConditions.join(" AND "); + + const sql = ` + SELECT + objid, + menu_name_kor AS "menuName", + menu_name_eng AS "menuNameEng", + menu_code AS "menuCode", + menu_url AS "menuUrl", + menu_type AS "menuType", + parent_obj_id AS "parentObjid", + seq AS "sortOrder", + company_code AS "companyCode" + FROM menu_info + WHERE ${whereClause} + ORDER BY seq, menu_name_kor + `; + + logger.info("🔍 SQL 쿼리 실행", { + whereClause, + params, + sql: sql.substring(0, 200) + "...", + }); + + const result = await query(sql, params); + + logger.info("✅ 메뉴 목록 조회 성공", { + count: result.length, + companyCode, + menus: result.map((m) => ({ + objid: m.objid, + name: m.menuName, + code: m.menuCode, + companyCode: m.companyCode, + })), + }); + + return result; + } catch (error) { + logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode }); + throw error; + } + } +} diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index fee93775..7c4f4c8d 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -185,6 +185,9 @@ export class AuthService { //}); // PersonBean 형태로 변환 (null 값을 undefined로 변환) + const companyCode = userInfo.company_code || "ILSHIN"; + const userType = userInfo.user_type || "USER"; + const personBean: PersonBean = { userId: userInfo.user_id, userName: userInfo.user_name || "", @@ -197,15 +200,21 @@ export class AuthService { email: userInfo.email || undefined, tel: userInfo.tel || undefined, cellPhone: userInfo.cell_phone || undefined, - userType: userInfo.user_type || undefined, + userType: userType, userTypeName: userInfo.user_type_name || undefined, partnerObjid: userInfo.partner_objid || undefined, authName: authNames || undefined, - companyCode: userInfo.company_code || "ILSHIN", + companyCode: companyCode, photo: userInfo.photo ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, locale: userInfo.locale || "KR", + // 권한 레벨 정보 추가 (3단계 체계) + isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN", + isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*", + isAdmin: + (companyCode === "*" && userType === "SUPER_ADMIN") || + userType === "COMPANY_ADMIN", }; //console.log("📦 AuthService - 최종 PersonBean:", { diff --git a/backend-node/src/services/flowConnectionService.ts b/backend-node/src/services/flowConnectionService.ts index 5b1f3d40..f9918cd4 100644 --- a/backend-node/src/services/flowConnectionService.ts +++ b/backend-node/src/services/flowConnectionService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 연결 서비스 */ diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index f08f934d..759178c1 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 정의 서비스 */ @@ -15,20 +16,24 @@ export class FlowDefinitionService { */ async create( request: CreateFlowDefinitionRequest, - userId: string + userId: string, + userCompanyCode?: string ): Promise { + const companyCode = request.companyCode || userCompanyCode || "*"; + console.log("🔥 flowDefinitionService.create called with:", { name: request.name, description: request.description, tableName: request.tableName, dbSourceType: request.dbSourceType, dbConnectionId: request.dbConnectionId, + companyCode, userId, }); const query = ` - INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; @@ -38,6 +43,7 @@ export class FlowDefinitionService { request.tableName || null, request.dbSourceType || "internal", request.dbConnectionId || null, + companyCode, userId, ]; @@ -53,12 +59,29 @@ export class FlowDefinitionService { */ async findAll( tableName?: string, - isActive?: boolean + isActive?: boolean, + companyCode?: string ): Promise { + console.log("🔍 flowDefinitionService.findAll called with:", { + tableName, + isActive, + companyCode, + }); + let query = "SELECT * FROM flow_definition WHERE 1=1"; const params: any[] = []; let paramIndex = 1; + // 회사 코드 필터링 + if (companyCode && companyCode !== "*") { + query += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + console.log(`✅ Company filter applied: company_code = ${companyCode}`); + } else { + console.log(`⚠️ No company filter (companyCode: ${companyCode})`); + } + if (tableName) { query += ` AND table_name = $${paramIndex}`; params.push(tableName); @@ -73,7 +96,11 @@ export class FlowDefinitionService { query += " ORDER BY created_at DESC"; + console.log("📋 Final query:", query); + console.log("📋 Query params:", params); + const result = await db.query(query, params); + console.log(`📊 Found ${result.length} flow definitions`); return result.map(this.mapToFlowDefinition); } @@ -179,6 +206,7 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, + companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, createdAt: row.created_at, diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 9d9eb9c4..966842b8 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 실행 서비스 * 단계별 데이터 카운트 및 리스트 조회 diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index ccb793eb..67d342ac 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 단계 서비스 */ diff --git a/backend-node/src/services/roleService.ts b/backend-node/src/services/roleService.ts new file mode 100644 index 00000000..403a1e46 --- /dev/null +++ b/backend-node/src/services/roleService.ts @@ -0,0 +1,610 @@ +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 권한 그룹 인터페이스 + */ +export interface RoleGroup { + objid: number; + authName: string; + authCode: string; + companyCode: string; + status: string; + writer: string; + regdate: Date; + memberCount?: number; + menuCount?: number; + memberNames?: string; +} + +/** + * 권한 그룹 멤버 인터페이스 + */ +export interface RoleMember { + objid: number; + masterObjid: number; + userId: string; + userName?: string; + deptName?: string; + positionName?: string; + writer: string; + regdate: Date; +} + +/** + * 메뉴 권한 인터페이스 + */ +export interface MenuPermission { + objid: number; + menuObjid: number; + authObjid: number; + menuName?: string; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + writer: string; + regdate: Date; +} + +/** + * 권한 그룹 서비스 + */ +export class RoleService { + /** + * 회사별 권한 그룹 목록 조회 + * @param companyCode - 회사 코드 (undefined 시 전체 조회) + * @param search - 검색어 + */ + static async getRoleGroups( + companyCode?: string, + search?: string + ): Promise { + try { + let sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate, + (SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount", + (SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount", + (SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name) + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = am.objid) AS "memberNames" + FROM authority_master am + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (companyCode가 undefined면 전체 조회) + if (companyCode) { + sql += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터 + if (search && search.trim()) { + sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`; + params.push(`%${search.trim()}%`); + paramIndex++; + } + + sql += ` ORDER BY regdate DESC`; + + logger.info("권한 그룹 조회 SQL", { sql, params }); + const result = await query(sql, params); + logger.info("권한 그룹 조회 결과", { count: result.length }); + return result; + } catch (error) { + logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search }); + throw error; + } + } + + /** + * 권한 그룹 상세 조회 + */ + static async getRoleGroupById(objid: number): Promise { + try { + const sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate + FROM authority_master + WHERE objid = $1 + `; + + const result = await query(sql, [objid]); + return result.length > 0 ? result[0] : null; + } catch (error) { + logger.error("권한 그룹 상세 조회 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 생성 + */ + static async createRoleGroup(data: { + authName: string; + authCode: string; + companyCode: string; + writer: string; + }): Promise { + try { + const sql = ` + INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) + VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, [ + data.authName, + data.authCode, + data.companyCode, + data.writer, + ]); + + logger.info("권한 그룹 생성 성공", { + objid: result[0].objid, + authName: data.authName, + }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 생성 실패", { error, data }); + throw error; + } + } + + /** + * 권한 그룹 수정 + */ + static async updateRoleGroup( + objid: number, + data: { + authName?: string; + authCode?: string; + status?: string; + } + ): Promise { + try { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.authName !== undefined) { + updates.push(`auth_name = $${paramIndex}`); + params.push(data.authName); + paramIndex++; + } + + if (data.authCode !== undefined) { + updates.push(`auth_code = $${paramIndex}`); + params.push(data.authCode); + paramIndex++; + } + + if (data.status !== undefined) { + updates.push(`status = $${paramIndex}`); + params.push(data.status); + paramIndex++; + } + + if (updates.length === 0) { + throw new Error("수정할 데이터가 없습니다"); + } + + params.push(objid); + + const sql = ` + UPDATE authority_master + SET ${updates.join(", ")} + WHERE objid = $${paramIndex} + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, params); + + if (result.length === 0) { + throw new Error("권한 그룹을 찾을 수 없습니다"); + } + + logger.info("권한 그룹 수정 성공", { objid, updates }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 수정 실패", { error, objid, data }); + throw error; + } + } + + /** + * 권한 그룹 삭제 + */ + static async deleteRoleGroup(objid: number): Promise { + try { + // CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth) + await query("DELETE FROM authority_master WHERE objid = $1", [objid]); + logger.info("권한 그룹 삭제 성공", { objid }); + } catch (error) { + logger.error("권한 그룹 삭제 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 목록 조회 + */ + static async getRoleMembers(masterObjid: number): Promise { + try { + const sql = ` + SELECT + asu.objid, + asu.master_objid AS "masterObjid", + asu.user_id AS "userId", + ui.user_name AS "userName", + ui.dept_name AS "deptName", + ui.position_name AS "positionName", + asu.writer, + asu.regdate + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = $1 + ORDER BY ui.user_name + `; + + const result = await query(sql, [masterObjid]); + return result; + } catch (error) { + logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 추가 (여러 명) + */ + static async addRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + // 이미 존재하는 멤버 제외 + const existingSql = ` + SELECT user_id + FROM authority_sub_user + WHERE master_objid = $1 AND user_id = ANY($2) + `; + const existing = await query<{ user_id: string }>(existingSql, [ + masterObjid, + userIds, + ]); + const existingIds = new Set( + existing.map((row: { user_id: string }) => row.user_id) + ); + + const newUserIds = userIds.filter((userId) => !existingIds.has(userId)); + + if (newUserIds.length === 0) { + logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds }); + return; + } + + // 배치 삽입 + const values = newUserIds + .map( + (_, index) => + `(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())` + ) + .join(", "); + + const sql = ` + INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate) + VALUES ${values} + `; + + await query(sql, [masterObjid, ...newUserIds, writer]); + + // 히스토리 기록 + for (const userId of newUserIds) { + await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer); + } + + logger.info("권한 그룹 멤버 추가 성공", { + masterObjid, + count: newUserIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 그룹 멤버 제거 (여러 명) + */ + static async removeRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + await query( + "DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)", + [masterObjid, userIds] + ); + + // 히스토리 기록 + for (const userId of userIds) { + await this.insertAuthorityHistory( + masterObjid, + userId, + "REMOVE", + writer + ); + } + + logger.info("권한 그룹 멤버 제거 성공", { + masterObjid, + count: userIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 히스토리 기록 + */ + private static async insertAuthorityHistory( + masterObjid: number, + userId: string, + historyType: "ADD" | "REMOVE", + writer: string + ): Promise { + try { + const sql = ` + INSERT INTO authority_master_history + (objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date) + SELECT + nextval('seq_authority_master'), + $1, + am.auth_name, + am.auth_code, + $2, + am.status, + $3, + $4, + NOW() + FROM authority_master am + WHERE am.objid = $1 + `; + + await query(sql, [masterObjid, userId, historyType, writer]); + } catch (error) { + logger.error("권한 히스토리 기록 실패", { + error, + masterObjid, + userId, + historyType, + }); + // 히스토리 기록 실패는 메인 작업을 중단하지 않음 + } + } + + /** + * 메뉴 권한 목록 조회 + */ + static async getMenuPermissions( + authObjid: number + ): Promise { + try { + const sql = ` + SELECT + rma.objid, + rma.menu_objid AS "menuObjid", + rma.auth_objid AS "authObjid", + mi.menu_name_kor AS "menuName", + mi.menu_code AS "menuCode", + mi.menu_url AS "menuUrl", + rma.create_yn AS "createYn", + rma.read_yn AS "readYn", + rma.update_yn AS "updateYn", + rma.delete_yn AS "deleteYn", + rma.execute_yn AS "executeYn", + rma.export_yn AS "exportYn", + rma.writer, + rma.regdate + FROM rel_menu_auth rma + LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid + WHERE rma.auth_objid = $1 + ORDER BY mi.menu_name_kor + `; + + const result = await query(sql, [authObjid]); + return result; + } catch (error) { + logger.error("메뉴 권한 조회 실패", { error, authObjid }); + throw error; + } + } + + /** + * 메뉴 권한 설정 (여러 메뉴) + */ + static async setMenuPermissions( + authObjid: number, + permissions: Array<{ + menuObjid: number; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + }>, + writer: string + ): Promise { + try { + // 기존 권한 삭제 + await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, + ]); + + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); + + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await query(sql, [authObjid, ...params, writer]); + } + + logger.info("메뉴 권한 설정 성공", { + authObjid, + count: permissions.length, + }); + } catch (error) { + logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions }); + throw error; + } + } + + /** + * 사용자가 속한 권한 그룹 목록 조회 + */ + static async getUserRoleGroups( + userId: string, + companyCode: string + ): Promise { + try { + const sql = ` + SELECT + am.objid, + am.auth_name AS "authName", + am.auth_code AS "authCode", + am.company_code AS "companyCode", + am.status, + am.writer, + am.regdate + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.company_code = $2 + AND am.status = 'active' + ORDER BY am.auth_name + `; + + const result = await query(sql, [userId, companyCode]); + return result; + } catch (error) { + logger.error("사용자 권한 그룹 조회 실패", { + error, + userId, + companyCode, + }); + throw error; + } + } + + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + static async getAllMenus(companyCode?: string): Promise { + try { + logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); + + let whereConditions: string[] = ["status = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (선택적) + // 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회 + // 회사 코드 필터 (선택적) + if (companyCode) { + // 특정 회사 메뉴만 조회 (공통 메뉴 제외) + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode }); + } else { + logger.info("📋 회사 코드 필터 없음 (전체 조회)"); + } + + const whereClause = whereConditions.join(" AND "); + + const sql = ` + SELECT + objid, + menu_name_kor AS "menuName", + menu_name_eng AS "menuNameEng", + menu_code AS "menuCode", + menu_url AS "menuUrl", + menu_type AS "menuType", + parent_obj_id AS "parentObjid", + seq AS "sortOrder", + company_code AS "companyCode" + FROM menu_info + WHERE ${whereClause} + ORDER BY seq, menu_name_kor + `; + + logger.info("🔍 SQL 쿼리 실행", { + whereClause, + params, + sql: sql.substring(0, 200) + "...", + }); + + const result = await query(sql, params); + + logger.info("✅ 메뉴 목록 조회 성공", { + count: result.length, + companyCode, + menus: result.map((m) => ({ + objid: m.objid, + name: m.menuName, + code: m.menuCode, + companyCode: m.companyCode, + })), + }); + + return result; + } catch (error) { + logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode }); + throw error; + } + } +} diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index c1384b51..35a2c0f5 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -7,6 +7,15 @@ export interface LoginRequest { password: string; } +// 사용자 권한 레벨 (3단계 체계) +export enum UserRole { + SUPER_ADMIN = "SUPER_ADMIN", // 최고 관리자 (전체 시스템) + COMPANY_ADMIN = "COMPANY_ADMIN", // 회사 관리자 (자기 회사만) + USER = "USER", // 일반 사용자 + GUEST = "GUEST", // 게스트 + PARTNER = "PARTNER", // 협력업체 +} + // 기존 ApiLoginController.UserInfo 클래스 포팅 export interface UserInfo { userId: string; @@ -18,7 +27,9 @@ export interface UserInfo { email?: string; photo?: string; locale?: string; - isAdmin?: boolean; + isAdmin?: boolean; // 하위 호환성 유지 + isSuperAdmin?: boolean; // 슈퍼관리자 여부 (company_code === '*' && userType === 'SUPER_ADMIN') + isCompanyAdmin?: boolean; // 회사 관리자 여부 (userType === 'COMPANY_ADMIN') } // 기존 ApiLoginController.ApiResponse 클래스 포팅 @@ -52,6 +63,10 @@ export interface PersonBean { companyCode?: string; photo?: string; locale?: string; + // 권한 레벨 정보 (3단계 체계) + isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN') + isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN') + isAdmin?: boolean; // 관리자 (슈퍼관리자 + 회사관리자) } // 로그인 결과 타입 (기존 LoginService.loginPwdCheck 반환값) diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 02510366..c127eccc 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -10,6 +10,7 @@ export interface FlowDefinition { tableName: string; dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; createdAt: Date; @@ -23,6 +24,7 @@ export interface CreateFlowDefinitionRequest { tableName: string; dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } // 플로우 정의 수정 요청 diff --git a/backend-node/src/types/oracledb.d.ts b/backend-node/src/types/oracledb.d.ts deleted file mode 100644 index 818b6a6f..00000000 --- a/backend-node/src/types/oracledb.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module 'oracledb' { - export interface Connection { - execute(sql: string, bindParams?: any, options?: any): Promise; - close(): Promise; - } - - export interface ConnectionConfig { - user: string; - password: string; - connectString: string; - } - - export function getConnection(config: ConnectionConfig): Promise; - export function createPool(config: any): Promise; - export function getPool(): any; - export function close(): Promise; -} - diff --git a/backend-node/src/utils/permissionUtils.ts b/backend-node/src/utils/permissionUtils.ts new file mode 100644 index 00000000..bbc85398 --- /dev/null +++ b/backend-node/src/utils/permissionUtils.ts @@ -0,0 +1,230 @@ +/** + * 권한 체크 유틸리티 + * 3단계 권한 체계: SUPER_ADMIN / COMPANY_ADMIN / USER + */ + +import { PersonBean } from "../types/auth"; + +/** + * 권한 레벨 Enum + */ +export enum PermissionLevel { + SUPER_ADMIN = "SUPER_ADMIN", // 최고 관리자 (전체 시스템) + COMPANY_ADMIN = "COMPANY_ADMIN", // 회사 관리자 (자기 회사만) + USER = "USER", // 일반 사용자 +} + +/** + * 사용자가 슈퍼관리자인지 확인 + * @param user 사용자 정보 + * @returns 슈퍼관리자 여부 + */ +export function isSuperAdmin(user?: PersonBean | null): boolean { + if (!user) return false; + return user.companyCode === "*" && user.userType === "SUPER_ADMIN"; +} + +/** + * 사용자가 회사 관리자인지 확인 (슈퍼관리자 제외) + * @param user 사용자 정보 + * @returns 회사 관리자 여부 + */ +export function isCompanyAdmin(user?: PersonBean | null): boolean { + if (!user) return false; + return user.userType === "COMPANY_ADMIN" && user.companyCode !== "*"; +} + +/** + * 사용자가 관리자인지 확인 (슈퍼관리자 + 회사관리자) + * @param user 사용자 정보 + * @returns 관리자 여부 + */ +export function isAdmin(user?: PersonBean | null): boolean { + return isSuperAdmin(user) || isCompanyAdmin(user); +} + +/** + * 사용자가 일반 사용자인지 확인 + * @param user 사용자 정보 + * @returns 일반 사용자 여부 + */ +export function isRegularUser(user?: PersonBean | null): boolean { + if (!user) return false; + return ( + user.userType === "USER" || + user.userType === "GUEST" || + user.userType === "PARTNER" + ); +} + +/** + * 사용자의 권한 레벨 반환 + * @param user 사용자 정보 + * @returns 권한 레벨 + */ +export function getUserPermissionLevel( + user?: PersonBean | null +): PermissionLevel | null { + if (!user) return null; + + if (isSuperAdmin(user)) { + return PermissionLevel.SUPER_ADMIN; + } + + if (isCompanyAdmin(user)) { + return PermissionLevel.COMPANY_ADMIN; + } + + return PermissionLevel.USER; +} + +/** + * DDL 실행 권한 확인 (슈퍼관리자만) + * @param user 사용자 정보 + * @returns DDL 실행 가능 여부 + */ +export function canExecuteDDL(user?: PersonBean | null): boolean { + return isSuperAdmin(user); +} + +/** + * 회사 데이터 접근 권한 확인 + * @param user 사용자 정보 + * @param targetCompanyCode 접근하려는 회사 코드 + * @returns 접근 가능 여부 + */ +export function canAccessCompanyData( + user?: PersonBean | null, + targetCompanyCode?: string +): boolean { + if (!user) return false; + + // 슈퍼관리자는 모든 회사 데이터 접근 가능 + if (isSuperAdmin(user)) { + return true; + } + + // 자기 회사 데이터만 접근 가능 + return user.companyCode === targetCompanyCode; +} + +/** + * 사용자 관리 권한 확인 (관리자만) + * @param user 사용자 정보 + * @param targetCompanyCode 관리하려는 회사 코드 + * @returns 사용자 관리 가능 여부 + */ +export function canManageUsers( + user?: PersonBean | null, + targetCompanyCode?: string +): boolean { + if (!user) return false; + + // 슈퍼관리자는 모든 회사 사용자 관리 가능 + if (isSuperAdmin(user)) { + return true; + } + + // 회사 관리자는 자기 회사 사용자만 관리 가능 + if (isCompanyAdmin(user)) { + return user.companyCode === targetCompanyCode; + } + + return false; +} + +/** + * 회사 설정 변경 권한 확인 (관리자만) + * @param user 사용자 정보 + * @param targetCompanyCode 설정 변경하려는 회사 코드 + * @returns 설정 변경 가능 여부 + */ +export function canManageCompanySettings( + user?: PersonBean | null, + targetCompanyCode?: string +): boolean { + return canManageUsers(user, targetCompanyCode); +} + +/** + * 회사 생성/삭제 권한 확인 (슈퍼관리자만) + * @param user 사용자 정보 + * @returns 회사 생성/삭제 가능 여부 + */ +export function canManageCompanies(user?: PersonBean | null): boolean { + return isSuperAdmin(user); +} + +/** + * 시스템 설정 변경 권한 확인 (슈퍼관리자만) + * @param user 사용자 정보 + * @returns 시스템 설정 변경 가능 여부 + */ +export function canManageSystemSettings(user?: PersonBean | null): boolean { + return isSuperAdmin(user); +} + +/** + * 권한 에러 메시지 생성 + * @param requiredLevel 필요한 권한 레벨 + * @returns 에러 메시지 + */ +export function getPermissionErrorMessage( + requiredLevel: PermissionLevel +): string { + const messages: Record = { + [PermissionLevel.SUPER_ADMIN]: + "최고 관리자 권한이 필요합니다. 전체 시스템을 관리할 수 있는 권한이 없습니다.", + [PermissionLevel.COMPANY_ADMIN]: + "관리자 권한이 필요합니다. 회사 관리자 이상의 권한이 필요합니다.", + [PermissionLevel.USER]: "인증된 사용자 권한이 필요합니다.", + }; + + return messages[requiredLevel] || "권한이 부족합니다."; +} + +/** + * 권한 부족 에러 객체 생성 + * @param requiredLevel 필요한 권한 레벨 + * @returns 에러 응답 객체 + */ +export function createPermissionError(requiredLevel: PermissionLevel) { + return { + success: false, + error: { + code: "INSUFFICIENT_PERMISSION", + details: getPermissionErrorMessage(requiredLevel), + }, + }; +} + +/** + * 사용자 권한 정보 요약 + * @param user 사용자 정보 + * @returns 권한 정보 객체 + */ +export function getUserPermissionSummary(user?: PersonBean | null) { + if (!user) { + return { + level: null, + isSuperAdmin: false, + isCompanyAdmin: false, + isAdmin: false, + canExecuteDDL: false, + canManageUsers: false, + canManageCompanies: false, + canManageSystemSettings: false, + }; + } + + return { + level: getUserPermissionLevel(user), + isSuperAdmin: isSuperAdmin(user), + isCompanyAdmin: isCompanyAdmin(user), + isAdmin: isAdmin(user), + canExecuteDDL: canExecuteDDL(user), + canManageUsers: isAdmin(user), + canManageCompanies: canManageCompanies(user), + canManageSystemSettings: canManageSystemSettings(user), + }; +} diff --git a/backend-node/tsconfig.json b/backend-node/tsconfig.json index 848784d8..1dd27608 100644 --- a/backend-node/tsconfig.json +++ b/backend-node/tsconfig.json @@ -1,38 +1,29 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2020", "module": "commonjs", - "lib": ["ES2022"], + "lib": ["ES2020"], "outDir": "./dist", "rootDir": "./src", - "strict": true, + "strict": false, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "removeComments": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, "moduleResolution": "node", - "baseUrl": "./", - "paths": { - "@/*": ["src/*"], - "@/config/*": ["src/config/*"], - "@/controllers/*": ["src/controllers/*"], - "@/services/*": ["src/services/*"], - "@/models/*": ["src/models/*"], - "@/middleware/*": ["src/middleware/*"], - "@/utils/*": ["src/utils/*"], - "@/types/*": ["src/types/*"], - "@/validators/*": ["src/validators/*"] - } + "allowSyntheticDefaultImports": true, + "noImplicitReturns": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmitOnError": false, + "noImplicitAny": false }, - "include": ["src/**/*", "src/types/**/*.d.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "ts-node": { + "transpileOnly": true, + "compilerOptions": { + "module": "commonjs" + } + } } diff --git a/db/migrations/RUN_027_MIGRATION.md b/db/migrations/RUN_027_MIGRATION.md new file mode 100644 index 00000000..8871bd8b --- /dev/null +++ b/db/migrations/RUN_027_MIGRATION.md @@ -0,0 +1,104 @@ +# 027 마이그레이션 실행 가이드 + +## 개요 + +`dept_info` 테이블에 `company_code` 컬럼을 추가하는 마이그레이션입니다. + +## 실행 방법 + +### 방법 1: Docker Compose를 통한 실행 (권장) + +```bash +# 1. 현재 사용 중인 Docker Compose 파일 확인 +cd /Users/kimjuseok/ERP-node + +# 2. DB 컨테이너 이름 확인 +docker ps | grep postgres + +# 3. 마이그레이션 실행 +docker exec -i psql -U postgres -d ilshin < db/migrations/027_add_company_code_to_dept_info.sql + +# 예시 (컨테이너 이름이 'erp-node-db-1'인 경우): +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/027_add_company_code_to_dept_info.sql +``` + +### 방법 2: pgAdmin 또는 DBeaver를 통한 실행 + +1. pgAdmin 또는 DBeaver 실행 +2. PostgreSQL 서버 연결: + - Host: `39.117.244.52` + - Port: `11132` + - Database: `plm` + - Username: `postgres` + - Password: `ph0909!!` +3. `db/migrations/027_add_company_code_to_dept_info.sql` 파일 내용을 복사 +4. SQL 쿼리 창에 붙여넣기 +5. 실행 (F5 또는 Execute 버튼) + +### 방법 3: psql CLI를 통한 직접 연결 + +```bash +# 1. psql 설치 확인 +psql --version + +# 2. 직접 연결하여 마이그레이션 실행 +psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/027_add_company_code_to_dept_info.sql +``` + +## 마이그레이션 검증 + +마이그레이션이 성공적으로 실행되었는지 확인: + +```sql +-- 1. company_code 컬럼 추가 확인 +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'dept_info' AND column_name = 'company_code'; + +-- 2. 인덱스 생성 확인 +SELECT indexname, indexdef +FROM pg_indexes +WHERE tablename = 'dept_info' AND indexname = 'idx_dept_info_company_code'; + +-- 3. 데이터 마이그레이션 확인 (company_code가 모두 채워졌는지) +SELECT company_code, COUNT(*) as dept_count +FROM dept_info +GROUP BY company_code +ORDER BY company_code; + +-- 4. NULL 값이 있는지 확인 (없어야 정상) +SELECT COUNT(*) as null_count +FROM dept_info +WHERE company_code IS NULL; +``` + +## 롤백 방법 (문제 발생 시) + +```sql +-- 1. 인덱스 제거 +DROP INDEX IF EXISTS idx_dept_info_company_code; + +-- 2. company_code 컬럼 제거 +ALTER TABLE dept_info DROP COLUMN IF EXISTS company_code; +``` + +## 주의사항 + +1. **백업 필수**: 마이그레이션 실행 전 반드시 데이터베이스 백업 +2. **운영 환경**: 운영 환경에서는 점검 시간에 실행 권장 +3. **트랜잭션**: 마이그레이션은 하나의 트랜잭션으로 실행됨 (실패 시 자동 롤백) +4. **성능**: `dept_info` 테이블 크기에 따라 실행 시간이 다를 수 있음 + +## 마이그레이션 내용 요약 + +1. `company_code` 컬럼 추가 (VARCHAR(20)) +2. `company_code` 인덱스 생성 +3. 기존 데이터 마이그레이션 (`hq_name` → `company_code`) +4. `company_code`를 NOT NULL로 변경 +5. 기본값 'ILSHIN' 설정 + +## 관련 파일 + +- 마이그레이션 파일: `db/migrations/027_add_company_code_to_dept_info.sql` +- 백엔드 API 수정: `backend-node/src/controllers/adminController.ts` +- 프론트엔드 API: `frontend/lib/api/user.ts` diff --git a/docs/권한_그룹_시스템_설계.md b/docs/권한_그룹_시스템_설계.md new file mode 100644 index 00000000..0709a872 --- /dev/null +++ b/docs/권한_그룹_시스템_설계.md @@ -0,0 +1,317 @@ +# 권한 그룹 시스템 설계 (RBAC) + +## 개요 + +회사 내에서 **역할 기반 접근 제어(RBAC - Role-Based Access Control)**를 통해 세밀한 권한 관리를 제공합니다. + +## 기존 시스템 분석 + +### 현재 테이블 구조 + +#### 1. `authority_master` - 권한 그룹 마스터 + +```sql +CREATE TABLE authority_master ( + objid NUMERIC PRIMARY KEY, + auth_name VARCHAR, -- 권한 그룹 이름 (예: "영업팀 권한", "개발팀 권한") + auth_code VARCHAR, -- 권한 코드 (예: "SALES_TEAM", "DEV_TEAM") + writer VARCHAR, + regdate TIMESTAMP, + status VARCHAR +); +``` + +#### 2. `authority_sub_user` - 권한 그룹 멤버 + +```sql +CREATE TABLE authority_sub_user ( + objid NUMERIC PRIMARY KEY, + master_objid NUMERIC, -- authority_master.objid 참조 + user_id VARCHAR, -- user_info.user_id 참조 + writer VARCHAR, + regdate TIMESTAMP +); +``` + +#### 3. `rel_menu_auth` - 메뉴 권한 매핑 + +```sql +CREATE TABLE rel_menu_auth ( + objid NUMERIC, + menu_objid NUMERIC, -- menu_info.objid 참조 + auth_objid NUMERIC, -- authority_master.objid 참조 + writer VARCHAR, + regdate TIMESTAMP, + create_yn VARCHAR, -- 생성 권한 (Y/N) + read_yn VARCHAR, -- 조회 권한 (Y/N) + update_yn VARCHAR, -- 수정 권한 (Y/N) + delete_yn VARCHAR -- 삭제 권한 (Y/N) +); +``` + +## 개선 사항 + +### 1. 회사별 권한 그룹 지원 + +**현재 문제점:** + +- `authority_master` 테이블에 `company_code` 컬럼이 없음 +- 모든 회사가 권한 그룹을 공유하게 됨 + +**해결 방안:** + +```sql +-- 마이그레이션 028 +ALTER TABLE authority_master ADD COLUMN company_code VARCHAR(20); +CREATE INDEX idx_authority_master_company ON authority_master(company_code); + +-- 기존 데이터 마이그레이션 (기본값 설정) +UPDATE authority_master SET company_code = 'ILSHIN' WHERE company_code IS NULL; +``` + +### 2. 권한 레벨과 권한 그룹의 차이 + +| 구분 | 권한 레벨 (userType) | 권한 그룹 (authority_master) | +| ---------- | -------------------------------- | ------------------------------ | +| **목적** | 시스템 레벨 권한 | 메뉴별 세부 권한 | +| **범위** | 전역 (시스템 전체) | 회사별 (회사 내부) | +| **관리자** | 최고 관리자 (SUPER_ADMIN) | 회사 관리자 (COMPANY_ADMIN) | +| **예시** | SUPER_ADMIN, COMPANY_ADMIN, USER | "영업팀", "개발팀", "관리자팀" | + +### 3. 2단계 권한 체계 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1단계: 권한 레벨 (userType) │ +│ - SUPER_ADMIN: 모든 회사 관리, DDL 실행 │ +│ - COMPANY_ADMIN: 자기 회사 관리, 권한 그룹 생성 │ +│ - USER: 자기 회사 데이터 조회/수정 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2단계: 권한 그룹 (authority_master) │ +│ - 회사 내부에서 메뉴별 세부 권한 설정 │ +│ - 생성(C), 조회(R), 수정(U), 삭제(D) 권한 제어 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 사용 시나리오 + +### 시나리오 1: 영업팀 권한 그룹 + +**요구사항:** + +- 영업팀은 고객 관리, 계약 관리 메뉴만 접근 가능 +- 고객 정보는 조회/수정 가능하지만 삭제 불가 +- 계약은 생성/조회/수정 가능 + +**구현:** + +```sql +-- 1. 권한 그룹 생성 +INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status) +VALUES (nextval('seq_authority'), '영업팀 권한', 'SALES_TEAM', 'COMPANY_1', 'active'); + +-- 2. 사용자 추가 +INSERT INTO authority_sub_user (objid, master_objid, user_id) +VALUES + (nextval('seq_auth_sub'), 1, 'user1'), + (nextval('seq_auth_sub'), 1, 'user2'); + +-- 3. 메뉴 권한 설정 +-- 고객 관리 메뉴 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn) +VALUES (100, 1, 'N', 'Y', 'Y', 'N'); + +-- 계약 관리 메뉴 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn) +VALUES (101, 1, 'Y', 'Y', 'Y', 'N'); +``` + +### 시나리오 2: 개발팀 권한 그룹 + +**요구사항:** + +- 개발팀은 모든 기술 메뉴 접근 가능 +- 프로젝트, 코드 관리 메뉴는 모든 권한 보유 +- 시스템 설정은 조회만 가능 + +**구현:** + +```sql +-- 1. 권한 그룹 생성 +INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status) +VALUES (nextval('seq_authority'), '개발팀 권한', 'DEV_TEAM', 'COMPANY_1', 'active'); + +-- 2. 메뉴 권한 설정 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn) +VALUES + (200, 2, 'Y', 'Y', 'Y', 'Y'), -- 프로젝트 관리 (모든 권한) + (201, 2, 'Y', 'Y', 'Y', 'Y'), -- 코드 관리 (모든 권한) + (202, 2, 'N', 'Y', 'N', 'N'); -- 시스템 설정 (조회만) +``` + +## 구현 단계 + +### Phase 1: 데이터베이스 마이그레이션 + +- [ ] `authority_master`에 `company_code` 추가 +- [ ] 기존 데이터 마이그레이션 +- [ ] 인덱스 생성 + +### Phase 2: 백엔드 API + +- [ ] 권한 그룹 CRUD API + - `GET /api/admin/roles` - 회사별 권한 그룹 목록 + - `POST /api/admin/roles` - 권한 그룹 생성 + - `PUT /api/admin/roles/:id` - 권한 그룹 수정 + - `DELETE /api/admin/roles/:id` - 권한 그룹 삭제 +- [ ] 권한 그룹 멤버 관리 API + - `GET /api/admin/roles/:id/members` - 멤버 목록 + - `POST /api/admin/roles/:id/members` - 멤버 추가 + - `DELETE /api/admin/roles/:id/members/:userId` - 멤버 제거 +- [ ] 메뉴 권한 매핑 API + - `GET /api/admin/roles/:id/menu-permissions` - 메뉴 권한 목록 + - `PUT /api/admin/roles/:id/menu-permissions` - 메뉴 권한 설정 + +### Phase 3: 프론트엔드 UI + +- [ ] 권한 그룹 관리 페이지 (`/admin/roles`) + - 권한 그룹 목록 (회사별 필터링) + - 권한 그룹 생성/수정/삭제 +- [ ] 권한 그룹 상세 페이지 (`/admin/roles/:id`) + - 멤버 관리 (사용자 추가/제거) + - 메뉴 권한 설정 (CRUD 권한 토글) +- [ ] 사용자 관리 페이지 연동 + - 사용자별 권한 그룹 할당 + +### Phase 4: 권한 체크 로직 + +- [ ] 미들웨어 개선 + - 권한 레벨 체크 (기존) + - 권한 그룹 체크 (신규) + - 메뉴별 CRUD 권한 체크 (신규) +- [ ] 프론트엔드 가드 + - 메뉴 표시/숨김 + - 버튼 활성화/비활성화 + +## 권한 체크 플로우 + +``` +사용자 요청 + ↓ +1. 인증 체크 (로그인 여부) + ↓ +2. 권한 레벨 체크 (userType) + - SUPER_ADMIN: 모든 접근 허용 + - COMPANY_ADMIN: 자기 회사만 + - USER: 권한 그룹 체크로 이동 + ↓ +3. 권한 그룹 체크 (authority_sub_user) + - 사용자가 속한 권한 그룹 조회 + ↓ +4. 메뉴 권한 체크 (rel_menu_auth) + - 요청한 메뉴에 대한 권한 확인 + - CRUD 권한 체크 + ↓ +5. 접근 허용/거부 +``` + +## 예상 UI 구조 + +### 권한 그룹 관리 페이지 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 권한 그룹 관리 │ +├─────────────────────────────────────────────────────────┤ +│ [회사 선택: COMPANY_1 ▼] [검색: ____] [+ 그룹 생성] │ +├─────────────────────────────────────────────────────────┤ +│ ┌───────────────┬──────────┬──────────┬────────┐ │ +│ │ 권한 그룹명 │ 코드 │ 멤버 수 │ 액션 │ │ +│ ├───────────────┼──────────┼──────────┼────────┤ │ +│ │ 영업팀 권한 │ SALES │ 5명 │ [수정] │ │ +│ │ 개발팀 권한 │ DEV │ 8명 │ [수정] │ │ +│ │ 관리자팀 │ ADMIN │ 2명 │ [수정] │ │ +│ └───────────────┴──────────┴──────────┴────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 권한 그룹 상세 페이지 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 영업팀 권한 (SALES_TEAM) │ +├─────────────────────────────────────────────────────────┤ +│ 【 멤버 관리 】 │ +│ [+ 멤버 추가] │ +│ ┌──────────┬──────────┬────────┐ │ +│ │ 사용자 ID │ 이름 │ 액션 │ │ +│ ├──────────┼──────────┼────────┤ │ +│ │ user1 │ 김철수 │ [제거] │ │ +│ │ user2 │ 이영희 │ [제거] │ │ +│ └──────────┴──────────┴────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ 【 메뉴 권한 설정 】 │ +│ ┌─────────────┬────┬────┬────┬────┐ │ +│ │ 메뉴 │ 생성│ 조회│ 수정│ 삭제│ │ +│ ├─────────────┼────┼────┼────┼────┤ │ +│ │ 고객 관리 │ □ │ ☑ │ ☑ │ □ │ │ +│ │ 계약 관리 │ ☑ │ ☑ │ ☑ │ □ │ │ +│ │ 매출 분석 │ □ │ ☑ │ □ │ □ │ │ +│ └─────────────┴────┴────┴────┴────┘ │ +│ [저장] [취소] │ +└─────────────────────────────────────────────────────────┘ +``` + +## 마이그레이션 계획 + +### 028_add_company_code_to_authority_master.sql + +```sql +-- 권한 그룹 테이블에 회사 코드 추가 +ALTER TABLE authority_master ADD COLUMN IF NOT EXISTS company_code VARCHAR(20); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_authority_master_company ON authority_master(company_code); + +-- 기존 데이터 마이그레이션 +UPDATE authority_master +SET company_code = 'ILSHIN' +WHERE company_code IS NULL; + +-- NOT NULL 제약 조건 추가 +ALTER TABLE authority_master ALTER COLUMN company_code SET NOT NULL; +ALTER TABLE authority_master ALTER COLUMN company_code SET DEFAULT 'ILSHIN'; + +-- 주석 추가 +COMMENT ON COLUMN authority_master.company_code IS '회사 코드 (회사별 권한 그룹 격리)'; +``` + +## 참고 사항 + +### 권한 우선순위 + +1. **SUPER_ADMIN**: 모든 권한 (권한 그룹 체크 생략) +2. **COMPANY_ADMIN**: 회사 내 모든 권한 (권한 그룹 체크 생략) +3. **USER**: 권한 그룹에 따른 메뉴별 권한 + +### 권한 그룹 vs 권한 레벨 + +- **권한 레벨**: 사용자 등록 시 최초 1회 설정 (최고 관리자가 변경) +- **권한 그룹**: 회사 관리자가 자유롭게 생성/관리, 사용자는 여러 그룹에 속할 수 있음 + +### 보안 고려사항 + +- 회사 관리자는 자기 회사의 권한 그룹만 관리 가능 +- 최고 관리자는 모든 회사의 권한 그룹 관리 가능 +- 권한 그룹 삭제 시 연결된 사용자/메뉴 권한도 함께 삭제 (CASCADE) + +## 다음 단계 + +1. **마이그레이션 028 실행** → `company_code` 추가 +2. **백엔드 API 개발** → 권한 그룹 CRUD +3. **프론트엔드 UI 개발** → 권한 그룹 관리 페이지 +4. **권한 체크 로직 통합** → 미들웨어 개선 + +이 설계를 구현하시겠습니까? diff --git a/docs/권한_시스템_마이그레이션_완료.md b/docs/권한_시스템_마이그레이션_완료.md new file mode 100644 index 00000000..cc1547c8 --- /dev/null +++ b/docs/권한_시스템_마이그레이션_완료.md @@ -0,0 +1,307 @@ +# 권한 시스템 마이그레이션 완료 보고서 + +## 실행 완료 ✅ + +날짜: 2025-10-27 +대상 데이터베이스: `plm` (39.117.244.52:11132) + +--- + +## 실행된 마이그레이션 + +### 1. **028_add_company_code_to_authority_master.sql** ✅ + +**목적**: 권한 그룹 시스템 개선 (회사별 격리) + +**주요 변경사항**: + +- `authority_master.company_code` 컬럼 추가 (회사별 권한 그룹 격리) +- 외래 키 제약 조건 추가 (`authority_sub_user` ↔ `authority_master`, `user_info`) +- 권한 요약 뷰 생성 (`v_authority_group_summary`) +- 유틸리티 함수 생성 (`get_user_authority_groups`) + +### 2. **031_add_menu_auth_columns.sql** ✅ + +**목적**: 메뉴 기반 권한 시스템 개선 (동적 화면 대응) + +**주요 변경사항**: + +- `menu_info.screen_code`, `menu_info.menu_code` 컬럼 추가 +- `rel_menu_auth.execute_yn`, `rel_menu_auth.export_yn` 컬럼 추가 +- 화면 생성 시 자동 메뉴 추가 트리거 (`auto_create_menu_for_screen`) +- 화면 삭제 시 자동 메뉴 비활성화 트리거 (`auto_deactivate_menu_for_screen`) +- 권한 체크 함수 (`check_menu_crud_permission`) +- 사용자 메뉴 조회 함수 (`get_user_menus_with_permissions`) +- 권한 요약 뷰 (`v_menu_permission_summary`) + +--- + +## 현재 데이터베이스 구조 + +### 1. 권한 그룹 시스템 + +#### `authority_master` (권한 그룹) + +``` +objid | NUMERIC | 권한 그룹 ID (PK) +auth_name | VARCHAR(50) | 권한 그룹 이름 +auth_code | VARCHAR(50) | 권한 그룹 코드 +company_code | VARCHAR(20) | 회사 코드 ⭐ (회사별 격리) +status | VARCHAR(20) | 활성/비활성 +``` + +#### `authority_sub_user` (권한 그룹 멤버) + +``` +master_objid | NUMERIC | 권한 그룹 ID (FK) +user_id | VARCHAR(50) | 사용자 ID (FK) +``` + +#### 현재 권한 그룹 현황 + +- COMPANY_1: 2개 그룹 +- COMPANY_2: 2개 그룹 +- COMPANY_3: 7개 그룹 +- COMPANY_4: 2개 그룹 +- ILSHIN: 3개 그룹 + +### 2. 메뉴 권한 시스템 + +#### `menu_info` (메뉴 정보) + +``` +objid | NUMERIC | 메뉴 ID (PK) +menu_name_kor | VARCHAR(64) | 메뉴 이름 (한글) +menu_name_eng | VARCHAR(64) | 메뉴 이름 (영어) +menu_code | VARCHAR(50) | 메뉴 코드 ⭐ (신규) +menu_url | VARCHAR(256) | 메뉴 URL +menu_type | NUMERIC | 메뉴 타입 (0=일반, 1=시스템, 2=동적생성 ⭐) +screen_code | VARCHAR(50) | 화면 코드 ⭐ (동적 메뉴 연동) +company_code | VARCHAR(50) | 회사 코드 +parent_obj_id | NUMERIC | 부모 메뉴 ID +seq | NUMERIC | 정렬 순서 +status | VARCHAR(32) | 상태 +``` + +#### `rel_menu_auth` (메뉴별 권한) + +``` +menu_objid | NUMERIC | 메뉴 ID (FK) +auth_objid | NUMERIC | 권한 그룹 ID (FK) +create_yn | VARCHAR(50) | 생성 권한 +read_yn | VARCHAR(50) | 읽기 권한 +update_yn | VARCHAR(50) | 수정 권한 +delete_yn | VARCHAR(50) | 삭제 권한 +execute_yn | CHAR(1) | 실행 권한 ⭐ (신규) +export_yn | CHAR(1) | 내보내기 권한 ⭐ (신규) +``` + +--- + +## 자동화 기능 + +### 1. 화면 생성 시 자동 메뉴 추가 🤖 + +```sql +-- 사용자가 화면 생성 +INSERT INTO screen_definitions (screen_name, screen_code, company_code, ...) +VALUES ('계약 관리', 'SCR_CONTRACT', 'ILSHIN', ...); + +-- ↓ 트리거 자동 실행 ↓ + +-- menu_info에 자동 추가됨! +-- menu_type = 2 (동적 생성) +-- screen_code = 'SCR_CONTRACT' +-- menu_url = '/screen/SCR_CONTRACT' +``` + +### 2. 화면 삭제 시 자동 메뉴 비활성화 🤖 + +```sql +-- 화면 삭제 +UPDATE screen_definitions +SET is_active = 'D' +WHERE screen_code = 'SCR_CONTRACT'; + +-- ↓ 트리거 자동 실행 ↓ + +-- 메뉴 비활성화됨! +UPDATE menu_info +SET status = 'inactive' +WHERE screen_code = 'SCR_CONTRACT'; +``` + +--- + +## 사용 가이드 + +### 1. 권한 그룹 생성 + +```sql +-- 예: ILSHIN 회사의 "개발팀" 권한 그룹 생성 +INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) +VALUES (nextval('seq_authority_master'), '개발팀', 'DEV_TEAM', 'ILSHIN', 'active', 'admin', NOW()); +``` + +### 2. 권한 그룹에 멤버 추가 + +```sql +-- 예: '개발팀'에 사용자 'dev1' 추가 +INSERT INTO authority_sub_user (master_objid, user_id) +VALUES ( + (SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM' AND company_code = 'ILSHIN'), + 'dev1' +); +``` + +### 3. 메뉴 권한 설정 + +```sql +-- 예: '개발팀'에게 특정 메뉴의 CRUD 권한 부여 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, execute_yn, export_yn, writer) +VALUES ( + 1005, -- 메뉴 ID + (SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM' AND company_code = 'ILSHIN'), + 'Y', 'Y', 'Y', 'Y', 'Y', 'N', -- CRUD + Execute 권한 + 'admin' +); +``` + +### 4. 사용자 권한 확인 + +```sql +-- 예: 'dev1' 사용자가 메뉴 1005를 수정할 수 있는지 확인 +SELECT check_menu_crud_permission('dev1', 1005, 'update'); +-- 결과: TRUE 또는 FALSE + +-- 예: 'dev1' 사용자가 접근 가능한 모든 메뉴 조회 +SELECT * FROM get_user_menus_with_permissions('dev1', 'ILSHIN'); +``` + +--- + +## 다음 단계 + +### 1. 백엔드 API 구현 + +**필요한 API**: + +- `GET /api/roles/:id/menu-permissions` - 권한 그룹의 메뉴 권한 조회 +- `POST /api/roles/:id/menu-permissions` - 메뉴 권한 설정 +- `GET /api/users/menus` - 현재 사용자가 접근 가능한 메뉴 목록 +- `POST /api/menu-permissions/check` - 특정 메뉴에 대한 권한 확인 + +**구현 파일**: + +- `backend-node/src/services/RoleService.ts` +- `backend-node/src/controllers/roleController.ts` +- `backend-node/src/middleware/permissionMiddleware.ts` + +### 2. 프론트엔드 UI 개발 + +**필요한 페이지/컴포넌트**: + +1. **권한 그룹 상세 페이지** (`/admin/roles/[id]`) + + - 기본 정보 (이름, 코드, 회사) + - 멤버 관리 (Dual List Box) ✅ 이미 구현됨 + - **메뉴 권한 설정** (체크박스 그리드) ⬅️ 신규 개발 필요 + +2. **메뉴 권한 설정 그리드** + + ``` + ┌─────────────────┬────────┬────────┬────────┬────────┬────────┬────────┐ + │ 메뉴 │ 생성 │ 읽기 │ 수정 │ 삭제 │ 실행 │ 내보내기│ + ├─────────────────┼────────┼────────┼────────┼────────┼────────┼────────┤ + │ 대시보드 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │ + │ 계약 관리 │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │ ☑ │ + │ 사용자 관리 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │ + └─────────────────┴────────┴────────┴────────┴────────┴────────┴────────┘ + ``` + +3. **네비게이션 메뉴** (사용자별 권한 필터링) + + - `get_user_menus_with_permissions` 함수 활용 + - 읽기 권한이 있는 메뉴만 표시 + +4. **버튼/액션 권한 제어** + - 생성 버튼: `can_create` + - 수정 버튼: `can_update` + - 삭제 버튼: `can_delete` + - 실행 버튼: `can_execute` (플로우, DDL) + - 내보내기 버튼: `can_export` + +**구현 파일**: + +- `frontend/components/admin/RoleDetailManagement.tsx` (메뉴 권한 탭 추가) +- `frontend/components/admin/MenuPermissionGrid.tsx` (신규) +- `frontend/lib/api/role.ts` (메뉴 권한 API 추가) +- `frontend/hooks/useMenuPermission.ts` (신규) + +### 3. 테스트 시나리오 + +**시나리오 1: 영업팀 권한 설정** + +1. 영업팀 권한 그룹 생성 +2. 멤버 추가 (3명) +3. 메뉴 권한 설정: + - 대시보드: 읽기만 + - 계약 관리: CRUD + 내보내기 + - 플로우 관리: 읽기 + 실행 +4. 영업팀 사용자로 로그인하여 검증 + +**시나리오 2: 동적 화면 생성 및 권한 설정** + +1. "배송 현황" 화면 생성 +2. 자동으로 메뉴 추가 확인 +3. 영업팀에게 읽기 권한 부여 +4. 영업팀 사용자 로그인하여 메뉴 표시 확인 + +--- + +## 주의사항 + +### 1. 기존 데이터 호환성 + +- 기존 `menu_info` 테이블 구조는 그대로 유지 +- 새로운 컬럼만 추가되어 기존 데이터에 영향 없음 + +### 2. 권한 타입 매핑 + +- `menu_type`이 `numeric`에서 `VARCHAR`로 변경되지 않음 (기존 구조 유지) +- `menu_type = 2`가 동적 생성 메뉴를 의미 + +### 3. 데이터 마이그레이션 불필요 + +- 기존 권한 데이터는 그대로 유지 +- 새로운 권한 그룹은 수동으로 설정 필요 + +--- + +## 검증 체크리스트 + +- [x] `authority_master.company_code` 컬럼 존재 확인 +- [x] `menu_info.screen_code`, `menu_info.menu_code` 컬럼 존재 확인 +- [x] `rel_menu_auth.execute_yn`, `rel_menu_auth.export_yn` 컬럼 존재 확인 +- [x] 트리거 함수 생성 확인 (`auto_create_menu_for_screen`, `auto_deactivate_menu_for_screen`) +- [x] 권한 체크 함수 생성 확인 (`check_menu_crud_permission`) +- [x] 사용자 메뉴 조회 함수 생성 확인 (`get_user_menus_with_permissions`) +- [x] 권한 요약 뷰 생성 확인 (`v_menu_permission_summary`) +- [ ] 백엔드 API 구현 +- [ ] 프론트엔드 UI 구현 +- [ ] 테스트 시나리오 실행 + +--- + +## 관련 문서 + +- `docs/메뉴_기반_권한_시스템_가이드.md` - 사용자 가이드 +- `docs/권한_체계_가이드.md` - 3단계 권한 체계 개요 +- `db/migrations/028_add_company_code_to_authority_master.sql` - 권한 그룹 마이그레이션 +- `db/migrations/031_add_menu_auth_columns.sql` - 메뉴 권한 마이그레이션 + +--- + +## 문의사항 + +기술적 문의사항이나 추가 기능 요청은 개발팀에 문의하세요. diff --git a/docs/권한_체계_가이드.md b/docs/권한_체계_가이드.md new file mode 100644 index 00000000..8e954523 --- /dev/null +++ b/docs/권한_체계_가이드.md @@ -0,0 +1,589 @@ +# 3단계 권한 체계 가이드 + +## 📋 목차 + +1. [권한 체계 개요](#권한-체계-개요) +2. [권한 레벨 상세](#권한-레벨-상세) +3. [데이터베이스 설정](#데이터베이스-설정) +4. [백엔드 구현](#백엔드-구현) +5. [프론트엔드 구현](#프론트엔드-구현) +6. [실무 예제](#실무-예제) +7. [FAQ](#faq) + +--- + +## 권한 체계 개요 + +### 3단계 권한 구조 + +``` +┌────────────────────┬──────────────┬─────────────────┬────────────────────────┐ +│ 권한 레벨 │ company_code │ user_type │ 접근 범위 │ +├────────────────────┼──────────────┼─────────────────┼────────────────────────┤ +│ 최고 관리자 │ * │ SUPER_ADMIN │ ✅ 전체 회사 데이터 │ +│ (Super Admin) │ │ │ ✅ DDL 실행 권한 │ +│ │ │ │ ✅ 회사 생성/삭제 │ +│ │ │ │ ✅ 시스템 설정 │ +├────────────────────┼──────────────┼─────────────────┼────────────────────────┤ +│ 회사 관리자 │ 20 │ COMPANY_ADMIN │ ✅ 자기 회사 데이터 │ +│ (Company Admin) │ │ │ ✅ 회사 사용자 관리 │ +│ │ │ │ ✅ 회사 설정 변경 │ +│ │ │ │ ❌ DDL 실행 불가 │ +│ │ │ │ ❌ 타회사 접근 불가 │ +├────────────────────┼──────────────┼─────────────────┼────────────────────────┤ +│ 일반 사용자 │ 20 │ USER │ ✅ 자기 회사 데이터 │ +│ (User) │ │ │ ❌ 사용자 관리 불가 │ +│ │ │ │ ❌ 설정 변경 불가 │ +└────────────────────┴──────────────┴─────────────────┴────────────────────────┘ +``` + +### 핵심 원칙 + +1. **company_code = "\*"** → 전체 시스템 접근 (슈퍼관리자 전용) +2. **company_code = "특정코드"** → 해당 회사만 접근 +3. **user_type** → 회사 내 권한 레벨 결정 + +--- + +## 권한 레벨 상세 + +### 1️⃣ 슈퍼관리자 (SUPER_ADMIN) + +**조건:** + +- `company_code = '*'` +- `user_type = 'SUPER_ADMIN'` + +**권한:** + +- ✅ 모든 회사 데이터 조회/수정 +- ✅ DDL 실행 (CREATE TABLE, ALTER TABLE 등) +- ✅ 회사 생성/삭제 +- ✅ 시스템 설정 변경 +- ✅ 모든 사용자 관리 +- ✅ 코드 관리, 템플릿 관리 등 전역 설정 + +**사용 사례:** + +- 시스템 전체 관리자 +- 데이터베이스 스키마 변경 +- 새로운 회사 추가 +- 전사 공통 설정 관리 + +**계정 예시:** + +```sql +INSERT INTO user_info (user_id, user_name, company_code, user_type) +VALUES ('super_admin', '시스템 관리자', '*', 'SUPER_ADMIN'); +``` + +--- + +### 2️⃣ 회사 관리자 (COMPANY_ADMIN) + +**조건:** + +- `company_code = '특정 회사 코드'` (예: '20') +- `user_type = 'COMPANY_ADMIN'` + +**권한:** + +- ✅ 자기 회사 데이터 조회/수정 +- ✅ 자기 회사 사용자 관리 (추가/수정/삭제) +- ✅ 자기 회사 설정 변경 +- ✅ 자기 회사 대시보드/화면 관리 +- ❌ DDL 실행 불가 +- ❌ 타 회사 데이터 접근 불가 +- ❌ 시스템 전역 설정 변경 불가 + +**사용 사례:** + +- 각 회사의 IT 관리자 +- 회사 내 사용자 계정 관리 +- 회사별 커스터마이징 설정 + +**계정 예시:** + +```sql +INSERT INTO user_info (user_id, user_name, company_code, user_type) +VALUES ('company_admin_20', '회사20 관리자', '20', 'COMPANY_ADMIN'); +``` + +--- + +### 3️⃣ 일반 사용자 (USER) + +**조건:** + +- `company_code = '특정 회사 코드'` (예: '20') +- `user_type = 'USER'` + +**권한:** + +- ✅ 자기 회사 데이터 조회/수정 +- ✅ 자신이 만든 화면/대시보드 관리 +- ❌ 사용자 관리 불가 +- ❌ 회사 설정 변경 불가 +- ❌ 타 회사 데이터 접근 불가 + +**사용 사례:** + +- 일반 업무 사용자 +- 데이터 입력/조회 +- 개인 대시보드 생성 + +**계정 예시:** + +```sql +INSERT INTO user_info (user_id, user_name, company_code, user_type) +VALUES ('user_kim', '김철수', '20', 'USER'); +``` + +--- + +## 데이터베이스 설정 + +### 마이그레이션 실행 + +```bash +# 권한 체계 마이그레이션 실행 +psql -U postgres -d your_database -f db/migrations/026_add_user_type_hierarchy.sql +``` + +### 주요 변경사항 + +1. **코드 테이블 업데이트:** + + - `ADMIN` → `COMPANY_ADMIN` 으로 변경 + - `SUPER_ADMIN` 신규 추가 + +2. **PostgreSQL 함수 추가:** + + - `is_super_admin(user_id)` - 슈퍼관리자 확인 + - `is_company_admin(user_id, company_code)` - 회사 관리자 확인 + - `can_access_company_data(user_id, company_code)` - 데이터 접근 권한 + +3. **권한 뷰 생성:** + - `v_user_permissions` - 사용자별 권한 요약 + +--- + +## 백엔드 구현 + +### 1. 권한 체크 유틸리티 사용 + +```typescript +import { + isSuperAdmin, + isCompanyAdmin, + isAdmin, + canExecuteDDL, + canAccessCompanyData, + canManageUsers, +} from "../utils/permissionUtils"; + +// 슈퍼관리자 확인 +if (isSuperAdmin(req.user)) { + // 전체 데이터 조회 +} + +// 회사 데이터 접근 권한 확인 +if (canAccessCompanyData(req.user, targetCompanyCode)) { + // 해당 회사 데이터 조회 +} + +// 사용자 관리 권한 확인 +if (canManageUsers(req.user, targetCompanyCode)) { + // 사용자 추가/수정/삭제 +} +``` + +### 2. 미들웨어 사용 + +```typescript +import { + requireSuperAdmin, + requireAdmin, + requireCompanyAccess, + requireUserManagement, + requireDDLPermission, +} from "../middleware/permissionMiddleware"; + +// 슈퍼관리자 전용 엔드포인트 +router.post( + "/api/admin/ddl/execute", + authenticate, + requireDDLPermission, + ddlController.execute +); + +// 관리자 전용 엔드포인트 (슈퍼관리자 + 회사관리자) +router.get( + "/api/admin/users", + authenticate, + requireAdmin, + userController.getUserList +); + +// 회사 데이터 접근 체크 +router.get( + "/api/data/:companyCode/orders", + authenticate, + requireCompanyAccess, + orderController.getOrders +); + +// 사용자 관리 권한 체크 +router.post( + "/api/admin/users/:companyCode", + authenticate, + requireUserManagement, + userController.createUser +); +``` + +### 3. 서비스 레이어 구현 + +```typescript +// ❌ 잘못된 방법 - 하드코딩된 회사 코드 +async getOrders(companyCode: string) { + return query("SELECT * FROM orders WHERE company_code = $1", [companyCode]); +} + +// ✅ 올바른 방법 - 권한 체크 포함 +async getOrders(user: PersonBean, companyCode: string) { + // 권한 확인 + if (!canAccessCompanyData(user, companyCode)) { + throw new Error("해당 회사 데이터에 접근할 권한이 없습니다."); + } + + // 슈퍼관리자는 모든 데이터 조회 가능 + if (isSuperAdmin(user)) { + if (companyCode === "*") { + return query("SELECT * FROM orders"); // 전체 조회 + } + } + + // 일반 사용자/회사 관리자는 자기 회사만 + return query("SELECT * FROM orders WHERE company_code = $1", [companyCode]); +} +``` + +--- + +## 프론트엔드 구현 + +### 1. 사용자 타입 정의 + +```typescript +// frontend/types/user.ts +export interface UserInfo { + userId: string; + userName: string; + companyCode: string; + userType: string; // 'SUPER_ADMIN' | 'COMPANY_ADMIN' | 'USER' + isSuperAdmin?: boolean; + isCompanyAdmin?: boolean; + isAdmin?: boolean; +} +``` + +### 2. 권한 기반 UI 렌더링 + +```tsx +import { useAuth } from "@/hooks/useAuth"; + +function AdminPanel() { + const { user } = useAuth(); + + return ( +
+ {/* 슈퍼관리자만 표시 */} + {user?.isSuperAdmin && ( + + )} + + {/* 관리자만 표시 (슈퍼관리자 + 회사관리자) */} + {user?.isAdmin && ( + + )} + + {/* 모든 사용자 표시 */} + +
+ ); +} +``` + +### 3. 권한 체크 Hook + +```typescript +// frontend/hooks/usePermissions.ts +export function usePermissions() { + const { user } = useAuth(); + + return { + isSuperAdmin: user?.isSuperAdmin ?? false, + isCompanyAdmin: user?.isCompanyAdmin ?? false, + isAdmin: user?.isAdmin ?? false, + canExecuteDDL: user?.isSuperAdmin ?? false, + canManageUsers: user?.isAdmin ?? false, + canAccessCompany: (companyCode: string) => { + if (user?.isSuperAdmin) return true; + return user?.companyCode === companyCode; + }, + }; +} + +// 사용 예시 +function DataTable({ companyCode }: { companyCode: string }) { + const { canAccessCompany } = usePermissions(); + + if (!canAccessCompany(companyCode)) { + return
접근 권한이 없습니다.
; + } + + return ; +} +``` + +--- + +## 실무 예제 + +### 예제 1: 주문 데이터 조회 + +**시나리오:** + +- 슈퍼관리자: 모든 회사의 주문 조회 +- 회사20 관리자: 회사20의 주문만 조회 +- 회사20 사용자: 회사20의 주문만 조회 + +**백엔드 구현:** + +```typescript +// orders.service.ts +export class OrderService { + async getOrders(user: PersonBean, companyCode?: string) { + let sql = "SELECT * FROM orders WHERE 1=1"; + const params: any[] = []; + + // 슈퍼관리자가 아닌 경우 회사 필터 적용 + if (!isSuperAdmin(user)) { + sql += " AND company_code = $1"; + params.push(user.companyCode); + } else if (companyCode && companyCode !== "*") { + // 슈퍼관리자가 특정 회사를 지정한 경우 + sql += " AND company_code = $1"; + params.push(companyCode); + } + + return query(sql, params); + } +} +``` + +**프론트엔드 구현:** + +```tsx +function OrderList() { + const { user } = useAuth(); + const [selectedCompany, setSelectedCompany] = useState(user?.companyCode); + + // 슈퍼관리자는 회사 선택 가능 + const showCompanySelector = user?.isSuperAdmin; + + return ( +
+ {showCompanySelector && ( + + )} + + +
+ ); +} +``` + +--- + +### 예제 2: 사용자 관리 + +**시나리오:** + +- 슈퍼관리자: 모든 회사의 사용자 관리 +- 회사20 관리자: 회사20 사용자만 관리 +- 회사20 사용자: 사용자 관리 불가 + +**백엔드 구현:** + +```typescript +// users.controller.ts +router.post("/api/admin/users", authenticate, async (req, res) => { + const { companyCode, userId, userName } = req.body; + + // 권한 확인 + if (!canManageUsers(req.user, companyCode)) { + return res.status(403).json({ + success: false, + error: "사용자 관리 권한이 없습니다.", + }); + } + + // 슈퍼관리자가 아닌 경우, 자기 회사만 가능 + if (!isSuperAdmin(req.user) && companyCode !== req.user.companyCode) { + return res.status(403).json({ + success: false, + error: "다른 회사의 사용자를 생성할 수 없습니다.", + }); + } + + // 사용자 생성 + await UserService.createUser({ companyCode, userId, userName }); + + res.json({ success: true }); +}); +``` + +--- + +### 예제 3: DDL 실행 (테이블 생성) + +**시나리오:** + +- 슈퍼관리자만 DDL 실행 가능 +- 다른 모든 사용자는 차단 + +**백엔드 구현:** + +```typescript +// ddl.controller.ts +router.post( + "/api/admin/ddl/execute", + authenticate, + requireDDLPermission, // 슈퍼관리자 체크 미들웨어 + async (req, res) => { + const { sql } = req.body; + + // 추가 보안 검증 + if (!canExecuteDDL(req.user)) { + return res.status(403).json({ + success: false, + error: "DDL 실행 권한이 없습니다.", + }); + } + + // DDL 실행 + await query(sql); + + // 감사 로그 기록 + await AuditService.logDDL({ + userId: req.user.userId, + sql, + timestamp: new Date(), + }); + + res.json({ success: true }); + } +); +``` + +**프론트엔드 구현:** + +```tsx +function DDLExecutor() { + const { user } = useAuth(); + + // 슈퍼관리자가 아니면 컴포넌트 자체를 숨김 + if (!user?.isSuperAdmin) { + return null; + } + + return ( +
+

DDL 실행 (슈퍼관리자 전용)

+