diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 6cc62cc6..57edad10 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3690,6 +3690,8 @@ export async function copyMenu( ? { removeText: req.body.screenNameConfig.removeText, addPrefix: req.body.screenNameConfig.addPrefix, + replaceFrom: req.body.screenNameConfig.replaceFrom, + replaceTo: req.body.screenNameConfig.replaceTo, } : undefined; diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 7ed87a06..688b6cd7 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -2,7 +2,6 @@ // Phase 2-1B: 핵심 인증 API 구현 import { Router } from "express"; -import { checkAuthStatus } from "../middleware/authMiddleware"; import { AuthController } from "../controllers/authController"; const router = Router(); @@ -12,7 +11,7 @@ const router = Router(); * 인증 상태 확인 API * 기존 Java ApiLoginController.checkAuthStatus() 포팅 */ -router.get("/status", checkAuthStatus); +router.get("/status", AuthController.checkAuthStatus); /** * POST /api/auth/login diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index aee32eeb..f67e09a3 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -373,7 +373,8 @@ export class MenuCopyService { private async collectScreens( menuObjids: number[], sourceCompanyCode: string, - client: PoolClient + client: PoolClient, + menus?: Menu[] ): Promise> { logger.info( `📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}` @@ -394,9 +395,25 @@ export class MenuCopyService { screenIds.add(assignment.screen_id); } - logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); + // 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴) + if (menus) { + const screenIdPattern = /\/screens\/(\d+)/; + for (const menu of menus) { + if (menu.menu_url) { + const match = menu.menu_url.match(screenIdPattern); + if (match) { + const urlScreenId = parseInt(match[1], 10); + if (!isNaN(urlScreenId) && urlScreenId > 0) { + screenIds.add(urlScreenId); + } + } + } + } + } - // 2) 화면 내부에서 참조되는 화면 (재귀) + logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}개`); + + // 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색 const queue = Array.from(screenIds); while (queue.length > 0) { @@ -405,17 +422,29 @@ export class MenuCopyService { if (visited.has(screenId)) continue; visited.add(screenId); - // 화면 레이아웃 조회 + const referencedScreens: number[] = []; + + // V1 레이아웃에서 참조 화면 추출 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1`, [screenId] ); - - // 참조 화면 추출 - const referencedScreens = this.extractReferencedScreens( - layoutsResult.rows + referencedScreens.push( + ...this.extractReferencedScreens(layoutsResult.rows) ); + // V2 레이아웃에서 참조 화면 추출 + const layoutsV2Result = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, sourceCompanyCode] + ); + for (const row of layoutsV2Result.rows) { + if (row.layout_data) { + this.extractScreenIdsFromObject(row.layout_data, referencedScreens); + } + } + if (referencedScreens.length > 0) { logger.info( ` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}` @@ -897,6 +926,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; + replaceFrom?: string; + replaceTo?: string; }, additionalCopyOptions?: AdditionalCopyOptions ): Promise { @@ -939,7 +970,8 @@ export class MenuCopyService { const screenIds = await this.collectScreens( menus.map((m) => m.objid), sourceCompanyCode, - client + client, + menus ); const flowIds = await this.collectFlows(screenIds, client); @@ -1095,6 +1127,16 @@ export class MenuCopyService { logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑"); await this.updateMenuUrls(menuIdMap, screenIdMap, client); + // === 6.7단계: screen_group_screens 복제 === + logger.info("\n🏷️ [6.7단계] screen_group_screens 복제"); + await this.copyScreenGroupScreens( + screenIds, + screenIdMap, + sourceCompanyCode, + targetCompanyCode, + client + ); + // === 7단계: 테이블 타입 설정 복사 === if (additionalCopyOptions?.copyTableTypeColumns) { logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); @@ -1419,6 +1461,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; + replaceFrom?: string; + replaceTo?: string; }, numberingRuleIdMap?: Map, menuIdMap?: Map @@ -1518,6 +1562,13 @@ export class MenuCopyService { // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { + if (screenNameConfig.replaceFrom?.trim()) { + transformedScreenName = transformedScreenName.replace( + new RegExp(screenNameConfig.replaceFrom.trim(), "g"), + screenNameConfig.replaceTo?.trim() || "" + ); + transformedScreenName = transformedScreenName.trim(); + } if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), @@ -2067,6 +2118,26 @@ export class MenuCopyService { logger.info(`📂 메뉴 복사 중: ${menus.length}개`); + // screen_group_id 재매핑 맵 생성 (source company → target company) + const screenGroupIdMap = new Map(); + const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[]; + if (sourceGroupIds.length > 0) { + const sourceGroups = await client.query<{ id: number; group_name: string }>( + `SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`, + [sourceGroupIds] + ); + for (const sg of sourceGroups.rows) { + const targetGroup = await client.query<{ id: number }>( + `SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`, + [sg.group_name, targetCompanyCode] + ); + if (targetGroup.rows.length > 0) { + screenGroupIdMap.set(sg.id, targetGroup.rows[0].id); + } + } + logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`); + } + // 위상 정렬 (부모 먼저 삽입) const sortedMenus = this.topologicalSortMenus(menus); @@ -2202,7 +2273,7 @@ export class MenuCopyService { menu.menu_url, menu.menu_desc, userId, - 'active', + menu.status || 'active', menu.system_name, targetCompanyCode, menu.lang_key, @@ -2211,7 +2282,7 @@ export class MenuCopyService { menu.menu_code, sourceMenuObjid, menu.menu_icon, - menu.screen_group_id, + menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null, ] ); @@ -2332,8 +2403,9 @@ export class MenuCopyService { } /** - * 메뉴 URL 업데이트 (화면 ID 재매핑) + * 메뉴 URL + screen_code 업데이트 (화면 ID 재매핑) * menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체 + * menu_info.screen_code도 복제된 screen_definitions.screen_code로 교체 */ private async updateMenuUrls( menuIdMap: Map, @@ -2341,56 +2413,197 @@ export class MenuCopyService { client: PoolClient ): Promise { if (menuIdMap.size === 0 || screenIdMap.size === 0) { - logger.info("📭 메뉴 URL 업데이트 대상 없음"); + logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음"); return; } const newMenuObjids = Array.from(menuIdMap.values()); - // 복제된 메뉴 중 menu_url이 있는 것 조회 - const menusWithUrl = await client.query<{ + // 복제된 메뉴 조회 + const menusToUpdate = await client.query<{ objid: number; - menu_url: string; + menu_url: string | null; + screen_code: string | null; }>( - `SELECT objid, menu_url FROM menu_info - WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`, + `SELECT objid, menu_url, screen_code FROM menu_info + WHERE objid = ANY($1)`, [newMenuObjids] ); - if (menusWithUrl.rows.length === 0) { - logger.info("📭 menu_url 업데이트 대상 없음"); + if (menusToUpdate.rows.length === 0) { + logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음"); return; } - let updatedCount = 0; - const screenIdPattern = /\/screens\/(\d+)/; - - for (const menu of menusWithUrl.rows) { - const match = menu.menu_url.match(screenIdPattern); - if (!match) continue; - - const originalScreenId = parseInt(match[1], 10); - const newScreenId = screenIdMap.get(originalScreenId); - - if (newScreenId && newScreenId !== originalScreenId) { - const newMenuUrl = menu.menu_url.replace( - `/screens/${originalScreenId}`, - `/screens/${newScreenId}` - ); - - await client.query( - `UPDATE menu_info SET menu_url = $1 WHERE objid = $2`, - [newMenuUrl, menu.objid] - ); - - logger.info( - ` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}` - ); - updatedCount++; + // screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회 + const newScreenIds = Array.from(screenIdMap.values()); + const screenCodeMap = new Map(); + if (newScreenIds.length > 0) { + const screenCodesResult = await client.query<{ + screen_id: number; + screen_code: string; + source_screen_id: number; + }>( + `SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id + FROM screen_definitions sd_new + WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`, + [newScreenIds] + ); + for (const row of screenCodesResult.rows) { + if (row.source_screen_id) { + // 원본의 screen_code 조회 + const origResult = await client.query<{ screen_code: string }>( + `SELECT screen_code FROM screen_definitions WHERE screen_id = $1`, + [row.source_screen_id] + ); + if (origResult.rows[0]?.screen_code) { + screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code); + } + } } } - logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`); + let updatedUrlCount = 0; + let updatedCodeCount = 0; + const screenIdPattern = /\/screens\/(\d+)/; + + for (const menu of menusToUpdate.rows) { + let newMenuUrl = menu.menu_url; + let newScreenCode = menu.screen_code; + let changed = false; + + // menu_url 재매핑 + if (menu.menu_url) { + const match = menu.menu_url.match(screenIdPattern); + if (match) { + const originalScreenId = parseInt(match[1], 10); + const newScreenId = screenIdMap.get(originalScreenId); + if (newScreenId && newScreenId !== originalScreenId) { + newMenuUrl = menu.menu_url.replace( + `/screens/${originalScreenId}`, + `/screens/${newScreenId}` + ); + changed = true; + updatedUrlCount++; + logger.info( + ` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}` + ); + } + } + // /screen/{screen_code} 형식도 처리 + const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/); + if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) { + const origCode = screenCodeUrlMatch[1]; + const newCode = screenCodeMap.get(origCode); + if (newCode && newCode !== origCode) { + newMenuUrl = `/screen/${newCode}`; + changed = true; + updatedUrlCount++; + logger.info( + ` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url} → ${newMenuUrl}` + ); + } + } + } + + // screen_code 재매핑 + if (menu.screen_code) { + const mappedCode = screenCodeMap.get(menu.screen_code); + if (mappedCode && mappedCode !== menu.screen_code) { + newScreenCode = mappedCode; + changed = true; + updatedCodeCount++; + logger.info( + ` 🏷️ screen_code 업데이트: ${menu.screen_code} → ${newScreenCode}` + ); + } + } + + if (changed) { + await client.query( + `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`, + [newMenuUrl, newScreenCode, menu.objid] + ); + } + } + + logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`); + } + + /** + * screen_group_screens 복제 (화면-스크린그룹 매핑) + */ + private async copyScreenGroupScreens( + screenIds: Set, + screenIdMap: Map, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.size === 0 || screenIdMap.size === 0) { + logger.info("📭 screen_group_screens 복제 대상 없음"); + return; + } + + // 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리) + await client.query( + `DELETE FROM screen_group_screens WHERE company_code = $1`, + [targetCompanyCode] + ); + + // 소스 회사의 screen_group_screens 조회 + const sourceScreenIds = Array.from(screenIds); + const sourceResult = await client.query<{ + group_id: number; + screen_id: number; + screen_role: string; + display_order: number; + is_default: string; + }>( + `SELECT group_id, screen_id, screen_role, display_order, is_default + FROM screen_group_screens + WHERE company_code = $1 AND screen_id = ANY($2)`, + [sourceCompanyCode, sourceScreenIds] + ); + + if (sourceResult.rows.length === 0) { + logger.info("📭 소스에 screen_group_screens 없음"); + return; + } + + // screen_group ID 매핑 (source group_name → target group_id) + const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))]; + const sourceGroups = await client.query<{ id: number; group_name: string }>( + `SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`, + [sourceGroupIds] + ); + const groupIdMap = new Map(); + for (const sg of sourceGroups.rows) { + const targetGroup = await client.query<{ id: number }>( + `SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`, + [sg.group_name, targetCompanyCode] + ); + if (targetGroup.rows.length > 0) { + groupIdMap.set(sg.id, targetGroup.rows[0].id); + } + } + + let insertedCount = 0; + for (const row of sourceResult.rows) { + const newGroupId = groupIdMap.get(row.group_id); + const newScreenId = screenIdMap.get(row.screen_id); + if (!newGroupId || !newScreenId) continue; + + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, 'system') + ON CONFLICT DO NOTHING`, + [newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode] + ); + insertedCount++; + } + + logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`); } /** diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a75fc431..4c5bdc57 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -3482,8 +3482,74 @@ export class ScreenManagementService { } console.log( - `✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, + `✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, ); + + // V2 레이아웃(screen_layouts_v2)도 동일하게 처리 + const v2LayoutsResult = await client.query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id IN (${placeholders}) + AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`, + targetScreenIds, + ); + + console.log( + `🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`, + ); + + let v2Updated = 0; + for (const v2Layout of v2LayoutsResult.rows) { + let layoutData = v2Layout.layout_data; + if (!layoutData) continue; + + let v2HasChanges = false; + + const updateV2References = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) updateV2References(item); + return; + } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "screenId" || key === "targetScreenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && numVal > 0) { + const newId = screenMap.get(numVal); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); + v2HasChanges = true; + console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`); + } + } + } + if (typeof value === "object" && value !== null) { + updateV2References(value); + } + } + }; + + updateV2References(layoutData); + + if (v2HasChanges) { + await client.query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2Updated++; + } + } + + console.log( + `✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`, + ); + result.updated += v2Updated; }); return result; @@ -4610,9 +4676,60 @@ export class ScreenManagementService { } console.log( - `✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, + `✅ V1: ${updateCount}개 레이아웃 업데이트 완료`, ); - return updateCount; + + // V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑 + const v2Layouts = await query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id = $1 + AND layout_data IS NOT NULL`, + [screenId], + ); + + let v2UpdateCount = 0; + for (const v2Layout of v2Layouts) { + const layoutData = v2Layout.layout_data; + if (!layoutData?.components) continue; + + let v2Changed = false; + const updateV2Refs = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "targetScreenId" || key === "screenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && screenIdMapping.has(numVal)) { + obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString(); + v2Changed = true; + } + } + if (typeof value === "object" && value !== null) updateV2Refs(value); + } + }; + updateV2Refs(layoutData); + + if (v2Changed) { + await query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2UpdateCount++; + } + } + + const total = updateCount + v2UpdateCount; + console.log( + `✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`, + ); + return total; } /** diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 4d862d9e..ed4602dd 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,7 +12,7 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + - DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h - CORS_ORIGIN=http://localhost:9771 diff --git a/frontend/components/screen/config-panels/button/ActionTab.tsx b/frontend/components/screen/config-panels/button/ActionTab.tsx new file mode 100644 index 00000000..f6c872e6 --- /dev/null +++ b/frontend/components/screen/config-panels/button/ActionTab.tsx @@ -0,0 +1,17 @@ +"use client"; + +import React from "react"; + +export interface ActionTabProps { + config: any; + onChange: (key: string, value: any) => void; + children: React.ReactNode; +} + +/** + * 동작 탭: 클릭 이벤트, 네비게이션, 모달 열기, 확인 다이얼로그 등 동작 설정 + * 실제 UI는 메인 ButtonConfigPanel에서 렌더링 후 children으로 전달 + */ +export const ActionTab: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/frontend/components/screen/config-panels/button/BasicTab.tsx b/frontend/components/screen/config-panels/button/BasicTab.tsx new file mode 100644 index 00000000..1eb7d2f7 --- /dev/null +++ b/frontend/components/screen/config-panels/button/BasicTab.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +export interface BasicTabProps { + config: any; + onChange: (key: string, value: any) => void; + localText?: string; + onTextChange?: (value: string) => void; +} + +export const BasicTab: React.FC = ({ + config, + onChange, + localText, + onTextChange, +}) => { + const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼"); + + const handleChange = (newValue: string) => { + onTextChange?.(newValue); + onChange("componentConfig.text", newValue); + }; + + return ( +
+
+ + handleChange(e.target.value)} + placeholder="버튼 텍스트를 입력하세요" + /> +
+
+ ); +}; diff --git a/frontend/components/screen/config-panels/button/DataTab.tsx b/frontend/components/screen/config-panels/button/DataTab.tsx new file mode 100644 index 00000000..29b35c78 --- /dev/null +++ b/frontend/components/screen/config-panels/button/DataTab.tsx @@ -0,0 +1,872 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +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, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; +import { ComponentData } from "@/types/screen"; + +export interface DataTabProps { + config: any; + onChange: (key: string, value: any) => void; + component: ComponentData; + allComponents: ComponentData[]; + currentTableName?: string; + availableTables: Array<{ name: string; label: string }>; + mappingTargetColumns: Array<{ name: string; label: string }>; + mappingSourceColumnsMap: Record>; + currentTableColumns: Array<{ name: string; label: string }>; + mappingSourcePopoverOpen: Record; + setMappingSourcePopoverOpen: React.Dispatch>>; + mappingTargetPopoverOpen: Record; + setMappingTargetPopoverOpen: React.Dispatch>>; + activeMappingGroupIndex: number; + setActiveMappingGroupIndex: React.Dispatch>; + loadMappingColumns: (tableName: string) => Promise>; + setMappingSourceColumnsMap: React.Dispatch< + React.SetStateAction>> + >; +} + +export const DataTab: React.FC = ({ + config, + onChange, + component, + allComponents, + currentTableName, + availableTables, + mappingTargetColumns, + mappingSourceColumnsMap, + currentTableColumns, + mappingSourcePopoverOpen, + setMappingSourcePopoverOpen, + mappingTargetPopoverOpen, + setMappingTargetPopoverOpen, + activeMappingGroupIndex, + setActiveMappingGroupIndex, + loadMappingColumns, + setMappingSourceColumnsMap, +}) => { + const actionType = config.action?.type; + const onUpdateProperty = (path: string, value: any) => onChange(path, value); + + if (actionType === "quickInsert") { + return ( +
+ +
+ ); + } + + if (actionType !== "transferData") { + return ( +
+ 데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다. +
+ ); + } + + return ( +
+
+

데이터 전달 설정

+ +
+ + +

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

+
+ +
+ + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ )} +
+ + {config.action?.dataTransfer?.targetType === "component" && ( +
+ + +

테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트

+
+ )} + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +
+ + + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value) + } + placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" + className="h-8 text-xs" + /> +

+ 반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다. +

+
+ )} + +
+ + +

기존 데이터를 어떻게 처리할지 선택

+
+ +
+
+ +

데이터 전달 후 소스의 선택을 해제합니다

+
+ + onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked) + } + /> +
+ +
+
+ +

데이터 전달 전 확인 다이얼로그를 표시합니다

+
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked) + } + /> +
+ + {config.action?.dataTransfer?.confirmBeforeTransfer && ( +
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)} + className="h-8 text-xs" + /> +
+ )} + +
+ +
+
+ + + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.minSelection", + parseInt(e.target.value) || 0, + ) + } + className="h-8 w-20 text-xs" + /> +
+
+ + + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.maxSelection", + parseInt(e.target.value) || undefined, + ) + } + className="h-8 w-20 text-xs" + /> +
+
+
+ +
+ +

+ 조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다 +

+
+
+ + +

+ 조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용) +

+
+
+ + + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: "" }); + } else { + newSources[0] = { ...newSources[0], fieldName: "" }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: col.name }); + } else { + newSources[0] = { ...newSources[0], fieldName: col.name }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

+
+
+
+ +
+ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+
+ + +
+

+ 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다. +

+ + {!config.action?.dataTransfer?.targetTable ? ( +
+

먼저 타겟 테이블을 선택하세요.

+
+ ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? ( +
+

매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

+
+ ) : ( +
+
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ + +
+ ))} +
+ + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ + ...prev, + [popoverKeyS]: false, + })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ + ...prev, + [popoverKeyT]: false, + })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()} +
+ )} +
+
+ +
+

+ 사용 방법: +
+ 1. 소스 컴포넌트에서 데이터를 선택합니다 +
+ 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다 +
+ 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다 +

+
+
+
+ ); +}; diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index d03aab29..9aab8e13 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -161,13 +161,14 @@ export const useAuth = () => { setLoading(true); const token = TokenManager.getToken(); - if (!token || TokenManager.isTokenExpired(token)) { - AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`); + if (!token) { + AuthLogger.log("AUTH_CHECK_FAIL", "refreshUserData: 토큰 없음"); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); return; } + // 만료된 토큰이라도 apiClient 요청 인터셉터가 자동 갱신하므로 여기서 차단하지 않음 AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작"); @@ -177,6 +178,10 @@ export const useAuth = () => { }); try { + // /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조 + // 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라 + // /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는 + // 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정 const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]); if (userInfo) { @@ -184,19 +189,12 @@ export const useAuth = () => { const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN"; const finalAuthStatus = { - isLoggedIn: authStatusData.isLoggedIn, + isLoggedIn: true, isAdmin: authStatusData.isAdmin || isAdminFromUser, }; setAuthStatus(finalAuthStatus); AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`); - - if (!finalAuthStatus.isLoggedIn) { - AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거"); - TokenManager.removeToken(); - setUser(null); - setAuthStatus({ isLoggedIn: false, isAdmin: false }); - } } else { AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도"); try { @@ -412,18 +410,19 @@ export const useAuth = () => { const token = TokenManager.getToken(); - if (token && !TokenManager.isTokenExpired(token)) { - AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`); + if (token) { + // 유효/만료 모두 refreshUserData로 처리 + // apiClient 요청 인터셉터가 만료 토큰을 자동 갱신하므로 여기서 삭제하지 않음 + const isExpired = TokenManager.isTokenExpired(token); + AuthLogger.log( + "AUTH_CHECK_START", + `초기 인증 확인: 토큰 ${isExpired ? "만료됨 → 갱신 시도" : "유효"} (경로: ${window.location.pathname})`, + ); setAuthStatus({ isLoggedIn: true, isAdmin: false, }); refreshUserData(); - } else if (token && TokenManager.isTokenExpired(token)) { - AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`); - TokenManager.removeToken(); - setAuthStatus({ isLoggedIn: false, isAdmin: false }); - setLoading(false); } else { AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: false, isAdmin: false }); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 2338ad63..bd935b63 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -329,6 +329,11 @@ apiClient.interceptors.request.use( const newToken = await refreshToken(); if (newToken) { config.headers.Authorization = `Bearer ${newToken}`; + } else { + // 갱신 실패 시 인증 없는 요청을 보내면 TOKEN_MISSING 401 → 즉시 redirectToLogin 연쇄 장애 + // 요청 자체를 차단하여 호출부의 try/catch에서 처리하도록 함 + authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 요청 차단 (${config.url})`); + return Promise.reject(new Error("TOKEN_REFRESH_FAILED")); } } } diff --git a/frontend/lib/registry/components/common/ConfigField.tsx b/frontend/lib/registry/components/common/ConfigField.tsx new file mode 100644 index 00000000..0b11780d --- /dev/null +++ b/frontend/lib/registry/components/common/ConfigField.tsx @@ -0,0 +1,264 @@ +"use client"; + +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Plus, X } from "lucide-react"; +import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes"; + +interface ConfigFieldProps { + field: ConfigFieldDefinition; + value: any; + onChange: (key: string, value: any) => void; + tableColumns?: ConfigOption[]; +} + +export function ConfigField({ + field, + value, + onChange, + tableColumns, +}: ConfigFieldProps) { + const handleChange = (newValue: any) => { + onChange(field.key, newValue); + }; + + const renderField = () => { + switch (field.type) { + case "text": + return ( + handleChange(e.target.value)} + placeholder={field.placeholder} + className="h-8 text-xs" + /> + ); + + case "number": + return ( + + handleChange( + e.target.value === "" ? undefined : Number(e.target.value), + ) + } + placeholder={field.placeholder} + min={field.min} + max={field.max} + step={field.step} + className="h-8 text-xs" + /> + ); + + case "switch": + return ( + + ); + + case "select": + return ( + + ); + + case "textarea": + return ( +