diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 57edad10..8e3780d6 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -108,6 +108,46 @@ export async function getUserMenus( } } +/** + * POP 메뉴 목록 조회 + * [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + */ +export async function getPopMenus( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; + + const result = await AdminService.getPopMenuList({ + userCompanyCode, + userType, + }); + + const response: ApiResponse = { + success: true, + message: "POP 메뉴 목록 조회 성공", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("POP 메뉴 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.", + error: { + code: "POP_MENU_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ebf3e8f5..21673369 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -50,29 +50,24 @@ export class AuthController { logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); + // 메뉴 조회를 위한 공통 파라미터 + const { AdminService } = await import("../services/adminService"); + const paramMap = { + userId: loginResult.userInfo.userId, + userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", + userType: loginResult.userInfo.userType, + userLang: "ko", + }; + // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; try { - const { AdminService } = await import("../services/adminService"); - const paramMap = { - userId: loginResult.userInfo.userId, - userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", - userType: loginResult.userInfo.userType, - userLang: "ko", - }; - const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); - // 접근 가능한 첫 번째 메뉴 찾기 - // 조건: - // 1. LEV (레벨)이 2 이상 (최상위 폴더 제외) - // 2. MENU_URL이 있고 비어있지 않음 - // 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴 const firstMenu = menuList.find((menu: any) => { const level = menu.lev || menu.level; const url = menu.menu_url || menu.url; - return level >= 2 && url && url.trim() !== "" && url !== "#"; }); @@ -86,13 +81,30 @@ export class AuthController { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); } + // POP 랜딩 경로 조회 + let popLandingPath: string | null = null; + try { + const popResult = await AdminService.getPopMenuList(paramMap); + if (popResult.landingMenu?.menu_url) { + popLandingPath = popResult.landingMenu.menu_url; + } else if (popResult.childMenus.length === 1) { + popLandingPath = popResult.childMenus[0].menu_url; + } else if (popResult.childMenus.length > 1) { + popLandingPath = "/pop"; + } + logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + } catch (popError) { + logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); + } + res.status(200).json({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, - firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가 + firstMenuPath, + popLandingPath, }, }); } else { diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index b9964962..a0779d50 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { getAdminMenus, getUserMenus, + getPopMenus, getMenuInfo, saveMenu, // 메뉴 추가 updateMenu, // 메뉴 수정 @@ -40,6 +41,7 @@ router.use(authenticateToken); // 메뉴 관련 API router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); +router.get("/pop-menus", getPopMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 730572d8..b36bc39e 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -17,6 +17,7 @@ interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; + shareAcrossItems?: boolean; } interface HiddenMappingInfo { @@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -225,23 +251,25 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } @@ -448,6 +476,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -467,7 +520,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } - // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) const allHidden = [ ...(fieldMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []), @@ -494,34 +546,28 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, - companyCode, - { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - logger.info("[pop/execute-action] 채번 완료", { - ruleId: ag.numberingRuleId, - targetColumn: ag.targetColumn, - generatedCode, - }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { - ruleId: ag.numberingRuleId, - error: err.message, - }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index e5d0c1a0..a27fcc77 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -621,6 +621,74 @@ export class AdminService { } } + /** + * POP 메뉴 목록 조회 + * menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + * [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환 + */ + static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> { + try { + const { userCompanyCode, userType } = paramMap; + logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType }); + + let queryParams: any[] = []; + let paramIndex = 1; + + let companyFilter = ""; + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + companyFilter = `AND COMPANY_CODE = '*'`; + } else { + companyFilter = `AND COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + } + + // POP L1 메뉴 조회 + const parentMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = 0 + AND MENU_TYPE = 1 + AND ( + MENU_DESC LIKE '%[POP]%' + OR UPPER(MENU_NAME_KOR) LIKE '%POP%' + ) + ${companyFilter} + ORDER BY SEQ + LIMIT 1`, + queryParams + ); + + if (parentMenus.length === 0) { + logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)"); + return { parentMenu: null, childMenus: [], landingMenu: null }; + } + + const parentMenu = parentMenus[0]; + + // 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링) + const childMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = $1 + AND STATUS = 'active' + AND COMPANY_CODE = $2 + ORDER BY SEQ`, + [parentMenu.objid, parentMenu.company_code] + ); + + // [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정 + const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null; + + logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`); + + return { parentMenu, childMenus, landingMenu }; + } catch (error) { + logger.error("AdminService.getPopMenuList 오류:", error); + throw error; + } + } + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9dea4037..f46cfe2b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -4468,26 +4468,30 @@ export class TableManagementService { const rawColumns = await query( `SELECT - column_name as "columnName", - column_name as "displayName", - data_type as "dataType", - udt_name as "dbType", - is_nullable as "isNullable", - column_default as "defaultValue", - character_maximum_length as "maxLength", - numeric_precision as "numericPrecision", - numeric_scale as "numericScale", + c.column_name as "columnName", + c.column_name as "displayName", + c.data_type as "dataType", + c.udt_name as "dbType", + c.is_nullable as "isNullable", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", CASE - WHEN column_name IN ( - SELECT column_name FROM information_schema.key_column_usage - WHERE table_name = $1 AND constraint_name LIKE '%_pkey' + WHEN c.column_name IN ( + SELECT kcu.column_name FROM information_schema.key_column_usage kcu + WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey' ) THEN true ELSE false - END as "isPrimaryKey" - FROM information_schema.columns - WHERE table_name = $1 - AND table_schema = 'public' - ORDER BY ordinal_position`, + END as "isPrimaryKey", + col_description( + (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')), + c.ordinal_position + ) as "columnComment" + FROM information_schema.columns c + WHERE c.table_name = $1 + AND c.table_schema = 'public' + ORDER BY c.ordinal_position`, [tableName] ); @@ -4497,10 +4501,10 @@ export class TableManagementService { displayName: col.displayName, dataType: col.dataType, dbType: col.dbType, - webType: "text", // 기본값 + webType: "text", inputType: "direct", detailSettings: "{}", - description: "", // 필수 필드 추가 + description: col.columnComment || "", isNullable: col.isNullable, isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, @@ -4511,6 +4515,7 @@ export class TableManagementService { numericScale: col.numericScale ? Number(col.numericScale) : undefined, displayOrder: 0, isVisible: true, + columnComment: col.columnComment || "", })); logger.info( diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index fe697cee..d105df77 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -10,8 +10,17 @@ import { LoginFooter } from "@/components/auth/LoginFooter"; * 비즈니스 로직은 useLogin 훅에서 처리하고, UI 컴포넌트들을 조합하여 구성 */ export default function LoginPage() { - const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } = - useLogin(); + const { + formData, + isLoading, + error, + showPassword, + isPopMode, + handleInputChange, + handleLogin, + togglePasswordVisibility, + togglePopMode, + } = useLogin(); return (
@@ -23,9 +32,11 @@ export default function LoginPage() { isLoading={isLoading} error={error} showPassword={showPassword} + isPopMode={isPopMode} onInputChange={handleInputChange} onSubmit={handleLogin} onTogglePassword={togglePasswordVisibility} + onTogglePop={togglePopMode} /> diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index bbc2182e..d014d7f7 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; @@ -285,14 +285,23 @@ function PopScreenViewPage() {
)} + {/* 일반 모드 네비게이션 바 */} + {!isPreviewMode && ( +
+ + {screen.screenName} + +
+ )} + {/* POP 화면 컨텐츠 */}
- {/* 현재 모드 표시 (일반 모드) */} - {!isPreviewMode && ( -
- {currentModeKey.replace("_", " ")} -
- )}
= ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen"); const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // POP 화면 할당 관련 상태 + const [selectedPopScreen, setSelectedPopScreen] = useState(null); + const [popScreenSearchText, setPopScreenSearchText] = useState(""); + const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false); + const [isPopLanding, setIsPopLanding] = useState(false); + const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false); + // 대시보드 할당 관련 상태 const [selectedDashboard, setSelectedDashboard] = useState(null); const [dashboards, setDashboards] = useState([]); @@ -196,8 +203,27 @@ export const MenuFormModal: React.FC = ({ toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); }; + // POP 화면 선택 시 URL 자동 설정 + const handlePopScreenSelect = (screen: ScreenDefinition) => { + const actualScreenId = screen.screenId || screen.id; + if (!actualScreenId) { + toast.error("화면 ID를 찾을 수 없습니다."); + return; + } + + setSelectedPopScreen(screen); + setIsPopScreenDropdownOpen(false); + + const popUrl = `/pop/screens/${actualScreenId}`; + + setFormData((prev) => ({ + ...prev, + menuUrl: popUrl, + })); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => { // console.log("🔄 URL 타입 변경:", { // from: urlType, // to: type, @@ -208,36 +234,53 @@ export const MenuFormModal: React.FC = ({ setUrlType(type); if (type === "direct") { - // 직접 입력 모드로 변경 시 선택된 화면 초기화 setSelectedScreen(null); - // URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록) + setSelectedPopScreen(null); setFormData((prev) => ({ ...prev, menuUrl: "", - screenCode: undefined, // 화면 코드도 함께 초기화 + screenCode: undefined, })); - } else { - // 화면 할당 모드로 변경 시 - // 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 + } else if (type === "pop") { + setSelectedScreen(null); + if (selectedPopScreen) { + const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id; + setFormData((prev) => ({ + ...prev, + menuUrl: `/pop/screens/${actualScreenId}`, + })); + } else { + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } + } else if (type === "screen") { + setSelectedPopScreen(null); if (selectedScreen) { - console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName); - // 현재 선택된 화면으로 URL 재생성 const actualScreenId = selectedScreen.screenId || selectedScreen.id; let screenUrl = `/screens/${actualScreenId}`; - - // 관리자 메뉴인 경우 mode=admin 파라미터 추가 const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; if (isAdminMenu) { screenUrl += "?mode=admin"; } - setFormData((prev) => ({ ...prev, menuUrl: screenUrl, - screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지 + screenCode: selectedScreen.screenCode, })); } else { - // 선택된 화면이 없으면 URL과 screenCode 초기화 + setFormData((prev) => ({ + ...prev, + menuUrl: "", + screenCode: undefined, + })); + } + } else { + // dashboard + setSelectedScreen(null); + setSelectedPopScreen(null); + if (!selectedDashboard) { setFormData((prev) => ({ ...prev, menuUrl: "", @@ -297,8 +340,8 @@ export const MenuFormModal: React.FC = ({ const menuUrl = menu.menu_url || menu.MENU_URL || ""; - // URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정) - const isScreenUrl = menuUrl.startsWith("/screens/"); + const isPopScreenUrl = menuUrl.startsWith("/pop/screens/"); + const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/"); setFormData({ objid: menu.objid || menu.OBJID, @@ -360,10 +403,31 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (isPopScreenUrl) { + setUrlType("pop"); + setSelectedScreen(null); + + // [POP_LANDING] 태그 감지 + const menuDesc = menu.menu_desc || menu.MENU_DESC || ""; + setIsPopLanding(menuDesc.includes("[POP_LANDING]")); + + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId) { + const setPopScreenFromId = () => { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + }; + if (screens.length > 0) { + setPopScreenFromId(); + } else { + setTimeout(setPopScreenFromId, 500); + } + } } else if (menuUrl.startsWith("/dashboard/")) { setUrlType("dashboard"); setSelectedScreen(null); - // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); @@ -408,6 +472,7 @@ export const MenuFormModal: React.FC = ({ } else { console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); setIsEdit(false); + setIsPopLanding(false); // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) let defaultMenuType = "1"; // 기본값은 사용자 @@ -470,6 +535,31 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); + // POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인 + useEffect(() => { + if (!isOpen) return; + + const checkOtherPopLanding = async () => { + try { + const res = await menuApi.getPopMenus(); + if (res.success && res.data?.landingMenu) { + const landingObjId = res.data.landingMenu.objid?.toString(); + const currentObjId = formData.objid?.toString(); + // 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복 + setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId); + } else { + setHasOtherPopLanding(false); + } + } catch { + setHasOtherPopLanding(false); + } + }; + + if (urlType === "pop") { + checkOtherPopLanding(); + } + }, [isOpen, urlType, formData.objid]); + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { @@ -517,6 +607,22 @@ export const MenuFormModal: React.FC = ({ } }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // POP 화면 목록 로드 완료 후 기존 할당 설정 + useEffect(() => { + if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/pop/screens/")) { + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId && !selectedPopScreen) { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + } + } + } + }, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -533,16 +639,20 @@ export const MenuFormModal: React.FC = ({ setIsDashboardDropdownOpen(false); setDashboardSearchText(""); } + if (!target.closest(".pop-screen-dropdown")) { + setIsPopScreenDropdownOpen(false); + setPopScreenSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isLangKeyDropdownOpen, isScreenDropdownOpen]); + }, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]); const loadCompanies = async () => { try { @@ -590,10 +700,17 @@ export const MenuFormModal: React.FC = ({ try { setLoading(true); + // POP 기본 화면 태그 처리 + let finalMenuDesc = formData.menuDesc; + if (urlType === "pop") { + const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim(); + finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag; + } + // 백엔드에 전송할 데이터 변환 const submitData = { ...formData, - // 상태를 소문자로 변환 (백엔드에서 소문자 기대) + menuDesc: finalMenuDesc, status: formData.status.toLowerCase(), }; @@ -853,7 +970,7 @@ export const MenuFormModal: React.FC = ({ {/* URL 타입 선택 */} - +
+
+ + +
)} + {/* POP 화면 할당 */} + {urlType === "pop" && ( +
+
+ + + {isPopScreenDropdownOpen && ( +
+
+
+ + setPopScreenSearchText(e.target.value)} + className="pl-8" + /> +
+
+ +
+ {screens + .filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ) + .map((screen, index) => ( +
handlePopScreenSelect(screen)} + className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100" + > +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+
ID: {screen.screenId || screen.id || "N/A"}
+
+
+ ))} + {screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ).length === 0 &&
검색 결과가 없습니다.
} +
+
+ )} +
+ + {selectedPopScreen && ( +
+
{selectedPopScreen.screenName}
+
코드: {selectedPopScreen.screenCode}
+
생성된 URL: {formData.menuUrl}
+
+ )} + + {/* POP 기본 화면 설정 */} +
+ setIsPopLanding(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50" + /> + + {!isPopLanding && hasOtherPopLanding && ( + + (이미 다른 메뉴가 기본 화면으로 설정되어 있습니다) + + )} +
+ {isPopLanding && ( +

+ 프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다. +

+ )} +
+ )} + {/* URL 직접 입력 */} {urlType === "direct" && ( ) => void; onSubmit: (e: React.FormEvent) => void; onTogglePassword: () => void; + onTogglePop: () => void; } /** @@ -24,9 +27,11 @@ export function LoginForm({ isLoading, error, showPassword, + isPopMode, onInputChange, onSubmit, onTogglePassword, + onTogglePop, }: LoginFormProps) { return ( @@ -82,6 +87,19 @@ export function LoginForm({
+ {/* POP 모드 토글 */} +
+
+ + POP 모드 +
+ +
+ {/* 로그인 버튼 */} )} + {scannedCode && ( + + )} + {scannedCode && !autoSubmit && ( + )} + {/* 사용자 배지 */} - - )} - {!onCancel && ( -

새 연결 추가

- )} - -
- 보내는 값 - -
- -
- 받는 컴포넌트 - -
- - {targetMeta && ( -
- 받는 방식 - -
- )} - - {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( -
-

필터할 컬럼

- - {dbColumnsLoading ? ( -
- - 컬럼 조회 중... -
- ) : hasAnyColumns ? ( -
- {displayColumns.length > 0 && ( -
-

화면 표시 컬럼

- {displayColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} - - {dataOnlyColumns.length > 0 && ( -
- {displayColumns.length > 0 && ( -
- )} -

데이터 전용 컬럼

- {dataOnlyColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} -
- ) : ( - setFilterColumns(e.target.value ? [e.target.value] : [])} - placeholder="컬럼명 입력" - className="h-7 text-xs" - /> - )} - - {filterColumns.length > 0 && ( -

- {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 -

- )} - -
-

필터 방식

- -
-
- )} - - -
- ); -} - // ======================================== // 받기 섹션 (읽기 전용: 연결된 소스만 표시) // ======================================== @@ -722,32 +323,3 @@ function ReceiveSection({ ); } -// ======================================== -// 유틸 -// ======================================== - -function isEventTypeConnection( - sourceMeta: ComponentConnectionMeta | undefined, - outputKey: string, - targetMeta: ComponentConnectionMeta | null | undefined, - inputKey: string, -): boolean { - const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); - const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); - return sourceItem?.type === "event" || targetItem?.type === "event"; -} - -function buildConnectionLabel( - source: PopComponentDefinitionV5, - _outputKey: string, - target: PopComponentDefinitionV5 | undefined, - _inputKey: string, - columns?: string[] -): string { - const srcLabel = source.label || source.id; - const tgtLabel = target?.label || target?.id || "?"; - const colInfo = columns && columns.length > 0 - ? ` [${columns.join(", ")}]` - : ""; - return `${srcLabel} → ${tgtLabel}${colInfo}`; -} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 32ac610b..0fb99fc5 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -76,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-string-list": "리스트 목록", "pop-search": "검색", "pop-field": "입력", + "pop-profile": "프로필", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index a02bf02b..f88b6d59 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile"; /** * 데이터 흐름 정의 @@ -360,8 +360,10 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record; } +interface AutoMatchPair { + sourceKey: string; + targetKey: string; + isFilter: boolean; +} + /** - * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다. - * 규칙: category="event"이고 key가 동일한 쌍 + * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다. + * 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭) + * 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭) */ function getAutoMatchPairs( sourceType: string, targetType: string -): { sourceKey: string; targetKey: string }[] { +): AutoMatchPair[] { const sourceDef = PopComponentRegistry.getComponent(sourceType); const targetDef = PopComponentRegistry.getComponent(targetType); @@ -44,14 +50,15 @@ function getAutoMatchPairs( return []; } - const pairs: { sourceKey: string; targetKey: string }[] = []; + const pairs: AutoMatchPair[] = []; for (const s of sourceDef.connectionMeta.sendable) { - if (s.category !== "event") continue; for (const r of targetDef.connectionMeta.receivable) { - if (r.category !== "event") continue; - if (s.key === r.key) { - pairs.push({ sourceKey: s.key, targetKey: r.key }); + if (s.category === "event" && r.category === "event" && s.key === r.key) { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); + } + if (s.type === "filter_value" && r.type === "filter_value") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true }); } } } @@ -93,10 +100,24 @@ export function useConnectionResolver({ const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; const unsub = subscribe(sourceEvent, (payload: unknown) => { - publish(targetEvent, { - value: payload, - _connectionId: conn.id, - }); + if (pair.isFilter) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + const filterMode = (data?.filterMode as string) || "contains"; + publish(targetEvent, { + value: payload, + filterConfig: fieldName + ? { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode } + : conn.filterConfig, + _connectionId: conn.id, + }); + } else { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + } }); unsubscribers.push(unsub); } @@ -121,13 +142,22 @@ export function useConnectionResolver({ const unsub = subscribe(sourceEvent, (payload: unknown) => { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - const enrichedPayload = { - value: payload, - filterConfig: conn.filterConfig, - _connectionId: conn.id, - }; + let resolvedFilterConfig = conn.filterConfig; + if (!resolvedFilterConfig) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + if (fieldName) { + const filterMode = (data?.filterMode as string) || "contains"; + resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" }; + } + } - publish(targetEvent, enrichedPayload); + publish(targetEvent, { + value: payload, + filterConfig: resolvedFilterConfig, + _connectionId: conn.id, + }); }); unsubscribers.push(unsub); } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 09c32d5f..bd0cf9a2 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -20,6 +20,21 @@ export const useLogin = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [isPopMode, setIsPopMode] = useState(false); + + // localStorage에서 POP 모드 상태 복원 + useEffect(() => { + const saved = localStorage.getItem("popLoginMode"); + if (saved === "true") setIsPopMode(true); + }, []); + + const togglePopMode = useCallback(() => { + setIsPopMode((prev) => { + const next = !prev; + localStorage.setItem("popLoginMode", String(next)); + return next; + }); + }, []); /** * 폼 입력값 변경 처리 @@ -141,17 +156,22 @@ export const useLogin = () => { // 쿠키에도 저장 (미들웨어에서 사용) document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`; - // 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트 - const firstMenuPath = result.data?.firstMenuPath; - - if (firstMenuPath) { - // 접근 가능한 메뉴가 있으면 해당 메뉴로 이동 - console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath); - router.push(firstMenuPath); + if (isPopMode) { + const popPath = result.data?.popLandingPath; + if (popPath) { + router.push(popPath); + } else { + setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요."); + setIsLoading(false); + return; + } } else { - // 접근 가능한 메뉴가 없으면 메인 페이지로 이동 - console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동"); - router.push(AUTH_CONFIG.ROUTES.MAIN); + const firstMenuPath = result.data?.firstMenuPath; + if (firstMenuPath) { + router.push(firstMenuPath); + } else { + router.push(AUTH_CONFIG.ROUTES.MAIN); + } } } else { // 로그인 실패 @@ -165,7 +185,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router], + [formData, validateForm, apiCall, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 @@ -179,10 +199,12 @@ export const useLogin = () => { isLoading, error, showPassword, + isPopMode, // 액션 handleInputChange, handleLogin, togglePasswordVisibility, + togglePopMode, }; }; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 8611aeda..fe5d8725 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -79,6 +79,23 @@ export interface ApiResponse { errorCode?: string; } +export interface PopMenuItem { + objid: string; + menu_name_kor: string; + menu_url: string; + menu_desc: string; + seq: number; + company_code: string; + status: string; + screenId?: number; +} + +export interface PopMenuResponse { + parentMenu: PopMenuItem | null; + childMenus: PopMenuItem[]; + landingMenu: PopMenuItem | null; +} + export const menuApi = { // 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) getAdminMenus: async (): Promise> => { @@ -94,6 +111,12 @@ export const menuApi = { return response.data; }, + // POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴) + getPopMenus: async (): Promise> => { + const response = await apiClient.get("/admin/pop-menus"); + return response.data; + }, + // 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시) getAdminMenusForManagement: async (): Promise> => { const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } }); diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 3793bc2d..2fe44592 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -35,6 +35,7 @@ export interface PopComponentDefinition { preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; connectionMeta?: ComponentConnectionMeta; + getDynamicConnectionMeta?: (config: Record) => ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 85d07e83..d593f779 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -22,6 +22,5 @@ import "./pop-string-list"; import "./pop-search"; import "./pop-field"; - -// 향후 추가될 컴포넌트들: -// import "./pop-list"; +import "./pop-scanner"; +import "./pop-profile"; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index ae6d05d9..67aaabad 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -23,6 +23,19 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { usePopAction } from "@/hooks/pop/usePopAction"; import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction"; @@ -51,6 +64,7 @@ import { PackageCheck, ChevronRight, GripVertical, + ChevronsUpDown, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -227,9 +241,11 @@ export interface ButtonTask { apiEndpoint?: string; apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; - // custom-event + // custom-event (제어 실행) eventName?: string; eventPayload?: Record; + flowId?: number; + flowName?: string; } /** 빠른 시작 템플릿 */ @@ -345,7 +361,7 @@ export const TASK_TYPE_LABELS: Record = { "close-modal": "모달 닫기", "refresh": "새로고침", "api-call": "API 호출", - "custom-event": "커스텀 이벤트", + "custom-event": "제어 실행", }; /** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */ @@ -1267,11 +1283,97 @@ interface PopButtonConfigPanelProps { componentId?: string; } +/** 연결된 컴포넌트에서 사용 가능한 필드 목록 추출 (연결 기반) */ +function extractConnectedFields( + componentId?: string, + connections?: PopButtonConfigPanelProps["connections"], + allComponents?: PopButtonConfigPanelProps["allComponents"], +): { value: string; label: string; source: string }[] { + if (!componentId || !connections || !allComponents) return []; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId || c.targetComponent === componentId) + .map((c) => (c.sourceComponent === componentId ? c.targetComponent : c.sourceComponent)); + const uniqueIds = [...new Set(targetIds)]; + if (uniqueIds.length === 0) return []; + + const fields: { value: string; label: string; source: string }[] = []; + + for (const tid of uniqueIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const cfg = comp.config as Record; + const compLabel = (comp as Record).label as string || comp.type || tid; + + if (comp.type === "pop-card-list") { + const tpl = cfg.cardTemplate as + | { header?: Record; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } } + | undefined; + if (tpl) { + if (tpl.header?.codeField) { + fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: compLabel }); + } + if (tpl.header?.titleField) { + fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: compLabel }); + } + for (const f of tpl.body?.fields ?? []) { + if (f.valueType === "column" && f.columnName) { + fields.push({ value: f.columnName, label: f.label || f.columnName, source: compLabel }); + } else if (f.valueType === "formula" && f.label) { + fields.push({ value: `__formula_${f.id || f.label}`, label: f.label, source: `${compLabel} (수식)` }); + } + } + } + fields.push({ value: "__cart_quantity", label: "사용자 입력 수량", source: `${compLabel} (장바구니)` }); + fields.push({ value: "__cart_row_key", label: "선택한 카드의 원본 키", source: `${compLabel} (장바구니)` }); + fields.push({ value: "__cart_id", label: "장바구니 항목 ID", source: `${compLabel} (장바구니)` }); + } + + if (comp.type === "pop-field") { + const sections = cfg.sections as Array<{ + fields?: Array<{ id: string; fieldName?: string; labelText?: string }>; + }> | undefined; + if (Array.isArray(sections)) { + for (const section of sections) { + for (const f of section.fields ?? []) { + if (f.fieldName) { + fields.push({ value: f.fieldName, label: f.labelText || f.fieldName, source: compLabel }); + } + } + } + } + } + + if (comp.type === "pop-search") { + const filterCols = cfg.filterColumns as string[] | undefined; + const modalCfg = cfg.modalConfig as { valueField?: string } | undefined; + if (Array.isArray(filterCols) && filterCols.length > 0) { + for (const col of filterCols) { + fields.push({ value: col, label: col, source: compLabel }); + } + } else if (modalCfg?.valueField) { + fields.push({ value: modalCfg.valueField, label: modalCfg.valueField, source: compLabel }); + } else if (cfg.fieldName && typeof cfg.fieldName === "string") { + fields.push({ value: cfg.fieldName, label: (cfg.placeholder as string) || cfg.fieldName, source: compLabel }); + } + } + } + + return fields; +} + export function PopButtonConfigPanel({ config, onUpdate, + allComponents, + connections, + componentId, }: PopButtonConfigPanelProps) { const v2 = useMemo(() => migrateButtonConfig(config), [config]); + const cardFields = useMemo( + () => extractConnectedFields(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); const updateV2 = useCallback( (partial: Partial) => { @@ -1449,9 +1551,9 @@ export function PopButtonConfigPanel({ {/* 작업 목록 */} -
+
{v2.tasks.length === 0 && ( -

+

작업이 없습니다. 빠른 시작 또는 아래 버튼으로 추가하세요.

)} @@ -1465,6 +1567,7 @@ export function PopButtonConfigPanel({ onUpdate={(partial) => updateTask(task.id, partial)} onRemove={() => removeTask(task.id)} onMove={(dir) => moveTask(task.id, dir)} + cardFields={cardFields} /> ))} @@ -1490,6 +1593,41 @@ export function PopButtonConfigPanel({ // 작업 항목 에디터 (접힘/펼침) // ======================================== +/** 작업 항목의 요약 텍스트 생성 */ +function buildTaskSummary(task: ButtonTask): string { + switch (task.type) { + case "data-update": { + if (!task.targetTable) return ""; + const col = task.targetColumn ? `.${task.targetColumn}` : ""; + const opLabels: Record = { + assign: "값 지정", + add: "더하기", + subtract: "빼기", + multiply: "곱하기", + divide: "나누기", + conditional: "조건 분기", + "db-conditional": "조건 비교", + }; + const op = opLabels[task.operationType || "assign"] || ""; + return `${task.targetTable}${col} ${op}`; + } + case "data-delete": + return task.targetTable || ""; + case "navigate": + return task.targetScreenId ? `화면 ${task.targetScreenId}` : ""; + case "modal-open": + return task.modalTitle || task.modalScreenId || ""; + case "cart-save": + return task.cartScreenId ? `화면 ${task.cartScreenId}` : ""; + case "api-call": + return task.apiEndpoint || ""; + case "custom-event": + return task.flowName || task.eventName || ""; + default: + return ""; + } +} + function TaskItemEditor({ task, index, @@ -1497,6 +1635,7 @@ function TaskItemEditor({ onUpdate, onRemove, onMove, + cardFields, }: { task: ButtonTask; index: number; @@ -1504,55 +1643,61 @@ function TaskItemEditor({ onUpdate: (partial: Partial) => void; onRemove: () => void; onMove: (direction: "up" | "down") => void; + cardFields: { value: string; label: string; source: string }[]; }) { const [expanded, setExpanded] = useState(false); const designerCtx = usePopDesignerContext(); + const summary = buildTaskSummary(task); return ( -
- {/* 헤더: 타입 + 순서 + 삭제 */} +
setExpanded(!expanded)} > - - - {index + 1}. {TASK_TYPE_LABELS[task.type]} - - {task.label && ( - - ({task.label}) - - )} -
+
+
+ + {index + 1}. {TASK_TYPE_LABELS[task.type]} + + {summary && ( + + - {summary} + + )} +
+
+
{index > 0 && ( - )} {index < totalCount - 1 && ( - )}
- {/* 펼침: 타입별 설정 폼 */} {expanded && ( -
- +
+
)}
@@ -1567,10 +1712,12 @@ function TaskDetailForm({ task, onUpdate, designerCtx, + cardFields, }: { task: ButtonTask; onUpdate: (partial: Partial) => void; designerCtx: ReturnType; + cardFields: { value: string; label: string; source: string }[]; }) { // 테이블/컬럼 조회 (data-update, data-delete용) const [tables, setTables] = useState([]); @@ -1592,7 +1739,7 @@ function TaskDetailForm({ switch (task.type) { case "data-save": return ( -

+

연결된 입력 컴포넌트의 저장 매핑을 사용합니다. 별도 설정 불필요.

); @@ -1604,13 +1751,14 @@ function TaskDetailForm({ onUpdate={onUpdate} tables={tables} columns={columns} + cardFields={cardFields} /> ); case "data-delete": return ( -
- +
+ - +
+ onUpdate({ cartScreenId: e.target.value })} placeholder="비워두면 이동 없이 저장만" - className="h-7 text-xs" + className="h-8 text-xs" />
); case "modal-open": return ( -
-
- +
+
+
{task.modalMode === "screen-ref" && ( -
- +
+ onUpdate({ modalScreenId: e.target.value })} placeholder="화면 ID" - className="h-7 text-xs" + className="h-8 text-xs" />
)} -
- +
+ onUpdate({ modalTitle: e.target.value })} placeholder="모달 제목 (선택)" - className="h-7 text-xs" + className="h-8 text-xs" />
{task.modalMode === "fullscreen" && designerCtx && (
{task.modalScreenId ? ( - ) : ( -
-
- 이면 -> - updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" /> -
+ ) : ( + onUpdate({ sourceField: e.target.value })} + className="h-8 text-xs" + placeholder="필드명 직접 입력 (예: qty)" + /> + )} +

+ {cardFields.length > 0 + ? "연결된 컴포넌트의 데이터 중 하나를 선택합니다" + : "연결된 컴포넌트가 없습니다. 연결 탭에서 먼저 연결해주세요"} +

- ))} - -
- 그 외 -> + )} +
+ )} + + {/* 5. 조건 비교 (db-conditional) - 세로 스택 */} + {task.operationType === "db-conditional" && ( +
+

+ DB 컬럼 값을 비교해서 결과를 정합니다 +

+ +
+ + onUpdate({ compareColumn: v })} + placeholder="비교할 컬럼 선택" + /> +
+ +
+ + +
+ +
+ + onUpdate({ compareWith: v })} + placeholder="비교 대상 컬럼 선택" + /> +
+ +
+ onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} - className="h-7 text-[10px]" - placeholder="기본값" + value={task.dbThenValue ?? ""} + onChange={(e) => onUpdate({ dbThenValue: e.target.value })} + className="h-8 text-xs" + placeholder="예: 입고완료" + /> +
+ +
+ + onUpdate({ dbElseValue: e.target.value })} + className="h-8 text-xs" + placeholder="예: 부분입고" />
)} - {/* 조회 키 */} -
-
- - + {/* 6. 조건 분기 (conditional) */} + {task.operationType === "conditional" && ( +
+

+ 입력된 값에 따라 다른 결과를 지정합니다 +

+ + {conditions.map((cond, cIdx) => ( +
+
+ 조건 {cIdx + 1} + +
+
+ + updateCondition(cIdx, { whenColumn: v })} + placeholder="컬럼 선택" + /> +
+
+ +
+ + updateCondition(cIdx, { whenValue: e.target.value })} + className="h-8 flex-1 text-xs" + placeholder="비교할 값" + /> +
+
+
+ + updateCondition(cIdx, { thenValue: e.target.value })} + className="h-8 text-xs" + placeholder="변경할 값" + /> +
+
+ ))} + + + +
+ + onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} + className="h-8 text-xs" + placeholder="기본값 입력" + /> +
- {task.lookupMode === "manual" && ( -
- - -> - onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" /> + )} + + {/* 7. 고급 설정 (조회 키) */} +
+ + {showAdvanced && ( +
+
+ + +

+ {task.lookupMode === "manual" + ? "카드 항목의 필드를 직접 지정하여 대상 행을 찾습니다" + : "카드 항목과 테이블 PK를 자동으로 매칭합니다"} +

+
+ {task.lookupMode === "manual" && ( +
+
+ + +
+
+ + onUpdate({ manualPkColumn: v })} + placeholder="PK 컬럼 선택" + /> +
+
+ )}
)}
+ + {/* 8. 설정 요약 */} + {summaryText && ( +
+

설정 요약

+

{summaryText}

+
+ )} )}
@@ -2326,10 +2662,10 @@ function PopButtonPreviewComponent({ // ======================================== const KNOWN_ITEM_FIELDS = [ - { value: "__cart_id", label: "__cart_id (카드 항목 ID)" }, - { value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" }, - { value: "id", label: "id" }, - { value: "row_key", label: "row_key" }, + { value: "__cart_row_key", label: "카드 항목의 원본 키", desc: "DB에서 가져온 데이터의 PK (가장 일반적)" }, + { value: "__cart_id", label: "카드 항목 ID", desc: "장바구니 내부 고유 ID" }, + { value: "id", label: "id", desc: "데이터의 id 컬럼" }, + { value: "row_key", label: "row_key", desc: "데이터의 row_key 컬럼" }, ]; function StatusChangeRuleEditor({ @@ -2645,6 +2981,107 @@ function SingleRuleEditor({ ); } +// ======================================== +// 제어 실행 작업 폼 (custom-event -> 제어 플로우) +// ======================================== + +function ControlFlowTaskForm({ + task, + onUpdate, +}: { + task: ButtonTask; + onUpdate: (partial: Partial) => void; +}) { + const [flows, setFlows] = useState<{ flowId: number; flowName: string; flowDescription?: string }[]>([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + setLoading(true); + apiClient + .get("/dataflow/node-flows") + .then((res) => { + const data = res.data?.data ?? res.data ?? []; + if (Array.isArray(data)) { + setFlows(data); + } + }) + .catch(() => setFlows([])) + .finally(() => setLoading(false)); + }, []); + + const selectedFlow = flows.find((f) => f.flowId === task.flowId); + + return ( +
+ + {loading ? ( +

목록 불러오는 중...

+ ) : ( + + + + + + + + + + 검색 결과 없음 + + + {flows.map((f) => ( + { + onUpdate({ + flowId: f.flowId, + flowName: f.flowName, + eventName: `__node_flow_${f.flowId}`, + }); + setOpen(false); + }} + className="text-xs" + > + +
+ {f.flowName} + {f.flowDescription && ( + + {f.flowDescription} + + )} +
+
+ ))} +
+
+
+
+
+ )} +
+ ); +} + // 레지스트리 등록 PopComponentRegistry.registerComponent({ id: "pop-button", diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 6383974b..1c351cf2 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -2039,16 +2039,29 @@ function FilterSettingsSection({ {filters.map((filter, index) => (
+
+ + 조건 {index + 1} + + +
- - - - - updateFilter(index, { ...filter, value: e.target.value }) - } - placeholder="값" - className="h-7 flex-1 text-xs" - /> - - +
+ + + updateFilter(index, { ...filter, value: e.target.value }) + } + placeholder="값 입력" + className="h-8 flex-1 text-xs" + /> +
))}
@@ -2663,46 +2667,51 @@ function FilterCriteriaSection({ ) : (
{filters.map((filter, index) => ( -
-
- updateFilter(index, { ...filter, column: val || "" })} - placeholder="컬럼 선택" +
+
+ + 조건 {index + 1} + + +
+ updateFilter(index, { ...filter, column: val || "" })} + placeholder="컬럼 선택" + /> +
+ + updateFilter(index, { ...filter, value: e.target.value })} + placeholder="값 입력" + className="h-8 flex-1 text-xs" />
- - updateFilter(index, { ...filter, value: e.target.value })} - placeholder="값" - className="h-7 flex-1 text-xs" - /> -
))}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 0f6adda6..b05846ef 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -34,6 +34,7 @@ export interface ColumnInfo { type: string; udtName: string; isPrimaryKey?: boolean; + comment?: string; } // ===== SQL 값 이스케이프 ===== @@ -330,6 +331,7 @@ export async function fetchTableColumns( type: col.dataType || col.data_type || col.type || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown", isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true", + comment: col.columnComment || col.description || "", })); } } diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index dace22f6..0438df90 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -203,6 +203,32 @@ export function PopFieldComponent({ return unsub; }, [componentId, subscribe, cfg.readSource, fetchReadSourceData]); + useEffect(() => { + const unsub = subscribe("scan_auto_fill", (payload: unknown) => { + const data = payload as Record | null; + if (!data || typeof data !== "object") return; + + const fieldNames = new Set(); + for (const section of cfg.sections) { + for (const f of section.fields ?? []) { + if (f.fieldName) fieldNames.add(f.fieldName); + } + } + + const matched: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (fieldNames.has(key)) { + matched[key] = value; + } + } + + if (Object.keys(matched).length > 0) { + setAllValues((prev) => ({ ...prev, ...matched })); + } + }); + return unsub; + }, [subscribe, cfg.sections]); + // 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답 useEffect(() => { if (!componentId) return; @@ -220,7 +246,7 @@ export function PopFieldComponent({ ? { targetTable: cfg.saveConfig.tableName, columnMapping: Object.fromEntries( - (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) + (cfg.saveConfig.fieldMappings || []).map((m) => [fieldIdToName[m.fieldId] || m.fieldId, m.targetColumn]) ), autoGenMappings: (cfg.saveConfig.autoGenMappings || []) .filter((m) => m.numberingRuleId) @@ -228,6 +254,7 @@ export function PopFieldComponent({ numberingRuleId: m.numberingRuleId!, targetColumn: m.targetColumn, showResultModal: m.showResultModal, + shareAcrossItems: m.shareAcrossItems ?? false, })), hiddenMappings: (cfg.saveConfig.hiddenMappings || []) .filter((m) => m.targetColumn) @@ -247,7 +274,7 @@ export function PopFieldComponent({ } ); return unsub; - }, [componentId, subscribe, publish, allValues, cfg.saveConfig]); + }, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]); // 필드 값 변경 핸들러 const handleFieldChange = useCallback( diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx index 8b5beb84..b3d6d076 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -398,8 +398,19 @@ function SaveTabContent({ syncAndUpdateSaveMappings((prev) => prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)) ); + + if (partial.targetColumn !== undefined) { + const newFieldName = partial.targetColumn || ""; + const sections = cfg.sections.map((s) => ({ + ...s, + fields: (s.fields ?? []).map((f) => + f.id === fieldId ? { ...f, fieldName: newFieldName } : f + ), + })); + onUpdateConfig({ sections }); + } }, - [syncAndUpdateSaveMappings] + [syncAndUpdateSaveMappings, cfg, onUpdateConfig] ); // --- 숨은 필드 매핑 로직 --- @@ -1337,7 +1348,19 @@ function SaveTabContent({ />
+
+ updateAutoGenMapping(m.id, { shareAcrossItems: v })} + /> + +
+ {m.shareAcrossItems && ( +

+ 저장되는 모든 행에 동일한 번호를 부여합니다 +

+ )}
); })} @@ -1414,7 +1437,7 @@ function SectionEditor({ const newField: PopFieldItem = { id: fieldId, inputType: "text", - fieldName: fieldId, + fieldName: "", labelText: "", readOnly: false, }; diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts index 7118d0a6..f0813e6c 100644 --- a/frontend/lib/registry/pop-components/pop-field/types.ts +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping { numberingRuleId?: string; showInForm: boolean; showResultModal: boolean; + shareAcrossItems?: boolean; } export interface PopFieldSaveConfig { diff --git a/frontend/lib/registry/pop-components/pop-profile.tsx b/frontend/lib/registry/pop-components/pop-profile.tsx new file mode 100644 index 00000000..49aaa10c --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-profile.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { useAuth } from "@/hooks/useAuth"; + +// ======================================== +// 타입 정의 +// ======================================== + +type AvatarSize = "sm" | "md" | "lg"; + +export interface PopProfileConfig { + avatarSize?: AvatarSize; + showDashboardLink?: boolean; + showPcMode?: boolean; + showLogout?: boolean; +} + +const DEFAULT_CONFIG: PopProfileConfig = { + avatarSize: "md", + showDashboardLink: true, + showPcMode: true, + showLogout: true, +}; + +const AVATAR_SIZE_MAP: Record = { + sm: { container: "h-8 w-8", text: "text-sm", px: 32 }, + md: { container: "h-10 w-10", text: "text-base", px: 40 }, + lg: { container: "h-12 w-12", text: "text-lg", px: 48 }, +}; + +const AVATAR_SIZE_LABELS: Record = { + sm: "작은 (32px)", + md: "보통 (40px)", + lg: "큰 (48px)", +}; + +// ======================================== +// 뷰어 컴포넌트 +// ======================================== + +interface PopProfileComponentProps { + config?: PopProfileConfig; + componentId?: string; + screenId?: string; +} + +function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) { + const router = useRouter(); + const { user, isLoggedIn, logout } = useAuth(); + const [open, setOpen] = useState(false); + + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...rawConfig, + }), [rawConfig]); + + const sizeInfo = AVATAR_SIZE_MAP[config.avatarSize || "md"]; + const initial = user?.userName?.substring(0, 1)?.toUpperCase() || "?"; + + const handlePcMode = () => { + setOpen(false); + router.push("/"); + }; + + const handleDashboard = () => { + setOpen(false); + router.push("/pop"); + }; + + const handleLogout = async () => { + setOpen(false); + await logout(); + }; + + const handleLogin = () => { + setOpen(false); + router.push("/login"); + }; + + return ( +
+ + + + + + {isLoggedIn && user ? ( + <> + {/* 사용자 정보 */} +
+
+ {user.photo && user.photo.trim() !== "" && user.photo !== "null" ? ( + {user.userName + ) : ( + initial + )} +
+
+ + {user.userName || "사용자"} ({user.userId || ""}) + + + {user.deptName || "부서 정보 없음"} + +
+
+ + {/* 메뉴 항목 */} +
+ {config.showDashboardLink && ( + + )} + {config.showPcMode && ( + + )} + {config.showLogout && ( + <> +
+ + + )} +
+ + ) : ( +
+

+ 로그인이 필요합니다 +

+ +
+ )} + + +
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== + +interface PopProfileConfigPanelProps { + config: PopProfileConfig; + onUpdate: (config: PopProfileConfig) => void; +} + +function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) { + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...rawConfig, + }), [rawConfig]); + + const updateConfig = (partial: Partial) => { + onUpdate({ ...config, ...partial }); + }; + + return ( +
+ {/* 아바타 크기 */} +
+ + +
+ + {/* 메뉴 항목 토글 */} +
+ + +
+ + updateConfig({ showDashboardLink: v })} + /> +
+ +
+ + updateConfig({ showPcMode: v })} + /> +
+ +
+ + updateConfig({ showLogout: v })} + /> +
+
+
+ ); +} + +// ======================================== +// 디자이너 미리보기 +// ======================================== + +function PopProfilePreview({ config }: { config?: PopProfileConfig }) { + const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"]; + return ( +
+
+ +
+ 프로필 +
+ ); +} + +// ======================================== +// 레지스트리 등록 +// ======================================== + +PopComponentRegistry.registerComponent({ + id: "pop-profile", + name: "프로필", + description: "사용자 프로필 / PC 전환 / 로그아웃", + category: "action", + icon: "UserCircle", + component: PopProfileComponent, + configPanel: PopProfileConfigPanel, + preview: PopProfilePreview, + defaultProps: { + avatarSize: "md", + showDashboardLink: true, + showPcMode: true, + showLogout: true, + }, + connectionMeta: { + sendable: [], + receivable: [], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-scanner.tsx b/frontend/lib/registry/pop-components/pop-scanner.tsx new file mode 100644 index 00000000..e2230170 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-scanner.tsx @@ -0,0 +1,694 @@ +"use client"; + +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScanLine } from "lucide-react"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { BarcodeScanModal } from "@/components/common/BarcodeScanModal"; +import type { + PopDataConnection, + PopComponentDefinitionV5, +} from "@/components/pop/designer/types/pop-layout"; + +// ======================================== +// 타입 정의 +// ======================================== + +export interface ScanFieldMapping { + sourceKey: string; + outputIndex: number; + label: string; + targetComponentId: string; + targetFieldName: string; + enabled: boolean; +} + +export interface PopScannerConfig { + barcodeFormat: "all" | "1d" | "2d"; + autoSubmit: boolean; + showLastScan: boolean; + buttonLabel: string; + buttonVariant: "default" | "outline" | "secondary"; + parseMode: "none" | "auto" | "json"; + fieldMappings: ScanFieldMapping[]; +} + +// 연결된 컴포넌트의 필드 정보 +interface ConnectedFieldInfo { + componentId: string; + componentName: string; + componentType: string; + fieldName: string; + fieldLabel: string; +} + +const DEFAULT_SCANNER_CONFIG: PopScannerConfig = { + barcodeFormat: "all", + autoSubmit: true, + showLastScan: false, + buttonLabel: "스캔", + buttonVariant: "default", + parseMode: "none", + fieldMappings: [], +}; + +// ======================================== +// 파싱 유틸리티 +// ======================================== + +function tryParseJson(raw: string): Record | null { + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const result: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + result[k] = String(v); + } + return result; + } + } catch { + // JSON이 아닌 경우 + } + return null; +} + +function parseScanResult( + raw: string, + mode: PopScannerConfig["parseMode"] +): Record | null { + if (mode === "none") return null; + return tryParseJson(raw); +} + +// ======================================== +// 연결된 컴포넌트 필드 추출 +// ======================================== + +function getConnectedFields( + componentId?: string, + connections?: PopDataConnection[], + allComponents?: PopComponentDefinitionV5[], +): ConnectedFieldInfo[] { + if (!componentId || !connections || !allComponents) return []; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + + const uniqueTargetIds = [...new Set(targetIds)]; + const fields: ConnectedFieldInfo[] = []; + + for (const tid of uniqueTargetIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + const compType = comp.type || ""; + const compName = (comp as Record).label as string || comp.type || tid; + + // pop-search: filterColumns (복수) 또는 modalConfig.valueField 또는 fieldName (단일) + const filterCols = compCfg.filterColumns as string[] | undefined; + const modalCfg = compCfg.modalConfig as { valueField?: string; displayField?: string } | undefined; + + if (Array.isArray(filterCols) && filterCols.length > 0) { + for (const col of filterCols) { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: col, + fieldLabel: col, + }); + } + } else if (modalCfg?.valueField) { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: modalCfg.valueField, + fieldLabel: (compCfg.placeholder as string) || modalCfg.valueField, + }); + } else if (compCfg.fieldName && typeof compCfg.fieldName === "string") { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: compCfg.fieldName, + fieldLabel: (compCfg.placeholder as string) || compCfg.fieldName as string, + }); + } + + // pop-field: sections 내 fields + const sections = compCfg.sections as Array<{ + fields?: Array<{ id: string; fieldName?: string; labelText?: string }>; + }> | undefined; + if (Array.isArray(sections)) { + for (const section of sections) { + for (const f of section.fields ?? []) { + if (f.fieldName) { + fields.push({ + componentId: tid, + componentName: compName, + componentType: compType, + fieldName: f.fieldName, + fieldLabel: f.labelText || f.fieldName, + }); + } + } + } + } + } + + return fields; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +interface PopScannerComponentProps { + config?: PopScannerConfig; + label?: string; + isDesignMode?: boolean; + screenId?: string; + componentId?: string; +} + +function PopScannerComponent({ + config, + isDesignMode, + screenId, + componentId, +}: PopScannerComponentProps) { + const cfg = { ...DEFAULT_SCANNER_CONFIG, ...(config || {}) }; + const { publish } = usePopEvent(screenId || ""); + const [lastScan, setLastScan] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + + const handleScanSuccess = useCallback( + (barcode: string) => { + setLastScan(barcode); + setModalOpen(false); + + if (!componentId) return; + + if (cfg.parseMode === "none") { + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + const parsed = parseScanResult(barcode, cfg.parseMode); + + if (!parsed) { + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + if (cfg.parseMode === "auto") { + publish("scan_auto_fill", parsed); + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + if (cfg.fieldMappings.length === 0) { + publish(`__comp_output__${componentId}__scan_value`, barcode); + return; + } + + for (const mapping of cfg.fieldMappings) { + if (!mapping.enabled) continue; + const value = parsed[mapping.sourceKey]; + if (value === undefined) continue; + + publish( + `__comp_output__${componentId}__scan_field_${mapping.outputIndex}`, + value + ); + + if (mapping.targetComponentId && mapping.targetFieldName) { + publish( + `__comp_input__${mapping.targetComponentId}__set_value`, + { fieldName: mapping.targetFieldName, value } + ); + } + } + }, + [componentId, publish, cfg.parseMode, cfg.fieldMappings], + ); + + const handleClick = useCallback(() => { + if (isDesignMode) return; + setModalOpen(true); + }, [isDesignMode]); + + return ( +
+ + + {cfg.showLastScan && lastScan && ( +
+ {lastScan} +
+ )} + + {!isDesignMode && ( + + )} +
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== + +const FORMAT_LABELS: Record = { + all: "모든 형식", + "1d": "1D 바코드", + "2d": "2D 바코드 (QR)", +}; + +const VARIANT_LABELS: Record = { + default: "기본 (Primary)", + outline: "외곽선 (Outline)", + secondary: "보조 (Secondary)", +}; + +const PARSE_MODE_LABELS: Record = { + none: "없음 (단일 값)", + auto: "자동 (검색 필드명과 매칭)", + json: "JSON (수동 매핑)", +}; + +interface PopScannerConfigPanelProps { + config: PopScannerConfig; + onUpdate: (config: PopScannerConfig) => void; + allComponents?: PopComponentDefinitionV5[]; + connections?: PopDataConnection[]; + componentId?: string; +} + +function PopScannerConfigPanel({ + config, + onUpdate, + allComponents, + connections, + componentId, +}: PopScannerConfigPanelProps) { + const cfg = { ...DEFAULT_SCANNER_CONFIG, ...config }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const connectedFields = useMemo( + () => getConnectedFields(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); + + const buildMappingsFromFields = useCallback( + (fields: ConnectedFieldInfo[], existing: ScanFieldMapping[]): ScanFieldMapping[] => { + return fields.map((f, i) => { + const prev = existing.find( + (m) => m.targetComponentId === f.componentId && m.targetFieldName === f.fieldName + ); + return { + sourceKey: prev?.sourceKey ?? f.fieldName, + outputIndex: i, + label: f.fieldLabel, + targetComponentId: f.componentId, + targetFieldName: f.fieldName, + enabled: prev?.enabled ?? true, + }; + }); + }, + [], + ); + + const toggleMapping = (fieldName: string, componentId: string) => { + const updated = cfg.fieldMappings.map((m) => + m.targetFieldName === fieldName && m.targetComponentId === componentId + ? { ...m, enabled: !m.enabled } + : m + ); + update({ fieldMappings: updated }); + }; + + const updateMappingSourceKey = (fieldName: string, componentId: string, sourceKey: string) => { + const updated = cfg.fieldMappings.map((m) => + m.targetFieldName === fieldName && m.targetComponentId === componentId + ? { ...m, sourceKey } + : m + ); + update({ fieldMappings: updated }); + }; + + useEffect(() => { + if (cfg.parseMode !== "json" || connectedFields.length === 0) return; + const synced = buildMappingsFromFields(connectedFields, cfg.fieldMappings); + const isSame = + synced.length === cfg.fieldMappings.length && + synced.every( + (s, i) => + s.targetComponentId === cfg.fieldMappings[i]?.targetComponentId && + s.targetFieldName === cfg.fieldMappings[i]?.targetFieldName, + ); + if (!isSame) { + onUpdate({ ...cfg, fieldMappings: synced }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectedFields, cfg.parseMode]); + + return ( +
+
+ + +

인식할 바코드 종류를 선택합니다

+
+ +
+ + update({ buttonLabel: e.target.value })} + placeholder="스캔" + className="h-8 text-xs" + /> +
+ +
+ + +
+ +
+
+ +

+ {cfg.autoSubmit + ? "바코드 인식 즉시 값 전달 (확인 버튼 생략)" + : "인식 후 확인 버튼을 눌러야 값 전달"} +

+
+ update({ autoSubmit: v })} + /> +
+ +
+
+ +

버튼 아래에 마지막 스캔값 표시

+
+ update({ showLastScan: v })} + /> +
+ + {/* 파싱 설정 섹션 */} +
+ +

+ 바코드/QR에 여러 정보가 담긴 경우, 파싱하여 각각 다른 컴포넌트에 전달 +

+ +
+ + +
+ + {cfg.parseMode === "auto" && ( +
+

자동 매칭 방식

+

+ QR/바코드의 JSON 키가 연결된 컴포넌트의 필드명과 같으면 자동 입력됩니다. +

+ {connectedFields.length > 0 && ( +
+

연결된 필드 목록:

+ {connectedFields.map((f, i) => ( +
+ {f.fieldName} + - {f.fieldLabel} + ({f.componentName}) +
+ ))} +

+ QR에 위 필드명이 JSON 키로 포함되면 자동 매칭됩니다. +

+
+ )} + {connectedFields.length === 0 && ( +

+ 연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결하세요. + 연결 없이도 같은 화면의 모든 컴포넌트에 전역으로 전달됩니다. +

+ )} +
+ )} + + {cfg.parseMode === "json" && ( +
+

+ 연결된 컴포넌트의 필드를 선택하고, 매핑할 JSON 키를 지정합니다. + 필드명과 같은 JSON 키가 있으면 자동 매칭됩니다. +

+ + {connectedFields.length === 0 ? ( +
+

+ 연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결해주세요. + 연결된 컴포넌트의 필드 목록이 여기에 표시됩니다. +

+
+ ) : ( +
+ +
+ {cfg.fieldMappings.map((mapping) => ( +
+ + toggleMapping(mapping.targetFieldName, mapping.targetComponentId) + } + className="mt-0.5" + /> +
+ + {mapping.enabled && ( +
+ + JSON 키: + + + updateMappingSourceKey( + mapping.targetFieldName, + mapping.targetComponentId, + e.target.value, + ) + } + placeholder={mapping.targetFieldName} + className="h-6 text-[10px]" + /> +
+ )} +
+
+ ))} +
+ + {cfg.fieldMappings.some((m) => m.enabled) && ( +
+

활성 매핑:

+
    + {cfg.fieldMappings + .filter((m) => m.enabled) + .map((m, i) => ( +
  • + {m.sourceKey || "?"} + {" -> "} + {m.targetFieldName} + {m.label && ({m.label})} +
  • + ))} +
+
+ )} +
+ )} +
+ )} +
+
+ ); +} + +// ======================================== +// 미리보기 +// ======================================== + +function PopScannerPreview({ config }: { config?: PopScannerConfig }) { + const cfg = config || DEFAULT_SCANNER_CONFIG; + + return ( +
+ +
+ ); +} + +// ======================================== +// 동적 sendable 생성 +// ======================================== + +function buildSendableMeta(config?: PopScannerConfig) { + const base = [ + { + key: "scan_value", + label: "스캔 값 (원본)", + type: "filter_value" as const, + category: "filter" as const, + description: "파싱 전 원본 스캔 결과 (단일 값 모드이거나 파싱 실패 시)", + }, + ]; + + if (config?.fieldMappings && config.fieldMappings.length > 0) { + for (const mapping of config.fieldMappings) { + base.push({ + key: `scan_field_${mapping.outputIndex}`, + label: mapping.label || `스캔 필드 ${mapping.outputIndex}`, + type: "filter_value" as const, + category: "filter" as const, + description: `파싱된 필드: JSON 키 "${mapping.sourceKey}"`, + }); + } + } + + return base; +} + +// ======================================== +// 레지스트리 등록 +// ======================================== + +PopComponentRegistry.registerComponent({ + id: "pop-scanner", + name: "스캐너", + description: "바코드/QR 카메라 스캔", + category: "input", + icon: "ScanLine", + component: PopScannerComponent, + configPanel: PopScannerConfigPanel, + preview: PopScannerPreview, + defaultProps: DEFAULT_SCANNER_CONFIG, + connectionMeta: { + sendable: buildSendableMeta(), + receivable: [], + }, + getDynamicConnectionMeta: (config: Record) => ({ + sendable: buildSendableMeta(config as unknown as PopScannerConfig), + receivable: [], + }), + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 380cc103..7c5f426d 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -18,12 +18,22 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; -import { Search, ChevronRight, Loader2, X } from "lucide-react"; +import { Search, ChevronRight, Loader2, X, CalendarDays } from "lucide-react"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns"; +import { ko } from "date-fns/locale"; import { usePopEvent } from "@/hooks/pop"; import { dataApi } from "@/lib/api/data"; import type { PopSearchConfig, DatePresetOption, + DateSelectionMode, + CalendarDisplayMode, ModalSelectConfig, ModalSearchMode, ModalFilterTab, @@ -62,9 +72,20 @@ export function PopSearchComponent({ const [modalDisplayText, setModalDisplayText] = useState(""); const [simpleModalOpen, setSimpleModalOpen] = useState(false); - const fieldKey = config.fieldName || componentId || "search"; const normalizedType = normalizeInputType(config.inputType as string); const isModalType = normalizedType === "modal"; + const fieldKey = isModalType + ? (config.modalConfig?.valueField || config.fieldName || componentId || "search") + : (config.fieldName || componentId || "search"); + + const resolveFilterMode = useCallback(() => { + if (config.filterMode) return config.filterMode; + if (normalizedType === "date") { + const mode: DateSelectionMode = config.dateSelectionMode || "single"; + return mode === "range" ? "range" : "equals"; + } + return "contains"; + }, [config.filterMode, config.dateSelectionMode, normalizedType]); const emitFilterChanged = useCallback( (newValue: unknown) => { @@ -72,15 +93,18 @@ export function PopSearchComponent({ setSharedData(`search_${fieldKey}`, newValue); if (componentId) { + const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, + filterColumns, value: newValue, + filterMode: resolveFilterMode(), }); } publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { @@ -88,15 +112,40 @@ export function PopSearchComponent({ const unsub = subscribe( `__comp_input__${componentId}__set_value`, (payload: unknown) => { - const data = payload as { value?: unknown } | unknown; + const data = payload as { value?: unknown; displayText?: string } | unknown; const incoming = typeof data === "object" && data && "value" in data ? (data as { value: unknown }).value : data; + if (isModalType && incoming != null) { + setModalDisplayText(String(incoming)); + } emitFilterChanged(incoming); } ); return unsub; - }, [componentId, subscribe, emitFilterChanged]); + }, [componentId, subscribe, emitFilterChanged, isModalType]); + + useEffect(() => { + const unsub = subscribe("scan_auto_fill", (payload: unknown) => { + const data = payload as Record | null; + if (!data || typeof data !== "object") return; + const myKey = config.fieldName; + if (!myKey) return; + const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey]; + for (const key of targetKeys) { + if (key in data) { + if (isModalType) setModalDisplayText(String(data[key])); + emitFilterChanged(data[key]); + return; + } + } + if (myKey in data) { + if (isModalType) setModalDisplayText(String(data[myKey])); + emitFilterChanged(data[myKey]); + } + }); + return unsub; + }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); const handleModalOpen = useCallback(() => { if (!config.modalConfig) return; @@ -116,29 +165,30 @@ export function PopSearchComponent({ [config.modalConfig, emitFilterChanged] ); + const handleModalClear = useCallback(() => { + setModalDisplayText(""); + emitFilterChanged(""); + }, [emitFilterChanged]); + const showLabel = config.labelVisible !== false && !!config.labelText; return (
{showLabel && ( - + {config.labelText} )} -
+
@@ -165,9 +215,10 @@ interface InputRendererProps { onChange: (v: unknown) => void; modalDisplayText?: string; onModalOpen?: () => void; + onModalClear?: () => void; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -175,12 +226,18 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa return ; case "select": return ; + case "date": { + const dateMode: DateSelectionMode = config.dateSelectionMode || "single"; + return dateMode === "range" + ? + : ; + } case "date-preset": return ; case "toggle": return ; case "modal": - return ; + return ; default: return ; } @@ -215,7 +272,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; const isNumber = config.inputType === "number"; return ( -
+
); } +// ======================================== +// date 서브타입 - 단일 날짜 +// ======================================== + +function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { + const [open, setOpen] = useState(false); + const useModal = config.calendarDisplay === "modal"; + const selected = value ? new Date(value + "T00:00:00") : undefined; + + const handleSelect = useCallback( + (day: Date | undefined) => { + if (!day) return; + onChange(format(day, "yyyy-MM-dd")); + setOpen(false); + }, + [onChange] + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(""); + }, + [onChange] + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 날짜 선택 + +
+ +
+
+
+ + ); + } + + return ( + + + {triggerButton} + + + + + + ); +} + +// ======================================== +// date 서브타입 - 기간 선택 (프리셋 + Calendar Range) +// ======================================== + +interface DateRangeValue { from?: string; to?: string } + +const RANGE_PRESETS = [ + { key: "today", label: "오늘" }, + { key: "this-week", label: "이번주" }, + { key: "this-month", label: "이번달" }, +] as const; + +function computeRangePreset(key: string): DateRangeValue { + const now = new Date(); + const fmt = (d: Date) => format(d, "yyyy-MM-dd"); + switch (key) { + case "today": + return { from: fmt(now), to: fmt(now) }; + case "this-week": + return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) }; + case "this-month": + return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) }; + default: + return {}; + } +} + +function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) { + const [open, setOpen] = useState(false); + const useModal = config.calendarDisplay === "modal"; + + const rangeVal: DateRangeValue = (typeof value === "object" && value !== null) + ? value as DateRangeValue + : (typeof value === "string" && value ? { from: value, to: value } : {}); + + const calendarRange = useMemo(() => { + if (!rangeVal.from) return undefined; + return { + from: new Date(rangeVal.from + "T00:00:00"), + to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined, + }; + }, [rangeVal.from, rangeVal.to]); + + const activePreset = RANGE_PRESETS.find((p) => { + const preset = computeRangePreset(p.key); + return preset.from === rangeVal.from && preset.to === rangeVal.to; + })?.key ?? null; + + const handlePreset = useCallback( + (key: string) => { + const preset = computeRangePreset(key); + onChange(preset); + }, + [onChange] + ); + + const handleRangeSelect = useCallback( + (range: { from?: Date; to?: Date } | undefined) => { + if (!range?.from) return; + const from = format(range.from, "yyyy-MM-dd"); + const to = range.to ? format(range.to, "yyyy-MM-dd") : from; + onChange({ from, to }); + if (range.to) setOpen(false); + }, + [onChange] + ); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange({}); + }, + [onChange] + ); + + const displayText = rangeVal.from + ? rangeVal.from === rangeVal.to + ? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko }) + : `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}` + : ""; + + const presetBar = ( +
+ {RANGE_PRESETS.map((p) => ( + + ))} +
+ ); + + const calendarEl = ( + + ); + + const triggerButton = ( + + ); + + if (useModal) { + return ( + <> + {triggerButton} + + + + 기간 선택 + +
+ {presetBar} +
+ {calendarEl} +
+
+
+
+ + ); + } + + return ( + + + {triggerButton} + + +
+ {presetBar} + {calendarEl} +
+
+
+ ); +} + // ======================================== // select 서브타입 // ======================================== @@ -237,7 +565,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { return ( update({ labelText: e.target.value })} - placeholder="예: 거래처명" - className="h-8 text-xs" - /> -
-
- - -
- +
+ + update({ labelText: e.target.value })} + placeholder="예: 거래처명" + className="h-8 text-xs" + /> +
)} +
); } @@ -224,16 +217,18 @@ function StepBasicSettings({ cfg, update }: StepProps) { // STEP 2: 타입별 상세 설정 // ======================================== -function StepDetailSettings({ cfg, update }: StepProps) { +function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const normalized = normalizeInputType(cfg.inputType as string); switch (normalized) { case "text": case "number": - return ; + return ; case "select": - return ; + return ; + case "date": + return ; case "date-preset": - return ; + return ; case "modal": return ; case "toggle": @@ -255,11 +250,278 @@ function StepDetailSettings({ cfg, update }: StepProps) { } } +// ======================================== +// 공통: 필터 연결 설정 섹션 +// ======================================== + +interface FilterConnectionSectionProps { + cfg: PopSearchConfig; + update: (partial: Partial) => void; + showFieldName: boolean; + fixedFilterMode?: SearchFilterMode; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; +} + +interface ConnectedComponentInfo { + tableNames: string[]; + displayedColumns: Set; +} + +/** + * 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다. + */ +function getConnectedComponentInfo( + componentId?: string, + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], +): ConnectedComponentInfo { + const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; + if (!componentId || !connections || !allComponents) return empty; + + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + + const tableNames = new Set(); + const displayedColumns = new Set(); + + for (const tid of targetIds) { + const comp = allComponents.find((c) => c.id === tid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + + const tn = compCfg.dataSource?.tableName; + if (tn) tableNames.add(tn); + + // pop-card-list: cardTemplate에서 사용 중인 컬럼 수집 + const tpl = compCfg.cardTemplate; + if (tpl) { + if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField); + if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField); + if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn); + if (Array.isArray(tpl.body?.fields)) { + for (const f of tpl.body.fields) { + if (f.columnName) displayedColumns.add(f.columnName); + } + } + } + + // pop-string-list: selectedColumns / listColumns + if (Array.isArray(compCfg.selectedColumns)) { + for (const col of compCfg.selectedColumns) displayedColumns.add(col); + } + if (Array.isArray(compCfg.listColumns)) { + for (const lc of compCfg.listColumns) { + if (lc.columnName) displayedColumns.add(lc.columnName); + } + } + } + + return { tableNames: Array.from(tableNames), displayedColumns }; +} + +function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) { + const connInfo = useMemo( + () => getConnectedComponentInfo(componentId, connections, allComponents), + [componentId, connections, allComponents], + ); + + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + const connectedTablesKey = connInfo.tableNames.join(","); + useEffect(() => { + if (connInfo.tableNames.length === 0) { + setTargetColumns([]); + return; + } + let cancelled = false; + setColumnsLoading(true); + + Promise.all(connInfo.tableNames.map((t) => getTableColumns(t))) + .then((results) => { + if (cancelled) return; + const allCols: ColumnTypeInfo[] = []; + const seen = new Set(); + for (const res of results) { + if (res.success && res.data?.columns) { + for (const col of res.data.columns) { + if (!seen.has(col.columnName)) { + seen.add(col.columnName); + allCols.push(col); + } + } + } + } + setTargetColumns(allCols); + }) + .finally(() => { if (!cancelled) setColumnsLoading(false); }); + + return () => { cancelled = true; }; + }, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const hasConnection = connInfo.tableNames.length > 0; + + const { displayedCols, otherCols } = useMemo(() => { + if (connInfo.displayedColumns.size === 0) { + return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns }; + } + const displayed: ColumnTypeInfo[] = []; + const others: ColumnTypeInfo[] = []; + for (const col of targetColumns) { + if (connInfo.displayedColumns.has(col.columnName)) { + displayed.push(col); + } else { + others.push(col); + } + } + return { displayedCols: displayed, otherCols: others }; + }, [targetColumns, connInfo.displayedColumns]); + + const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []); + + const toggleFilterColumn = (colName: string) => { + const current = new Set(selectedFilterCols); + if (current.has(colName)) { + current.delete(colName); + } else { + current.add(colName); + } + const next = Array.from(current); + update({ + filterColumns: next, + fieldName: next[0] || "", + }); + }; + + const renderColumnCheckbox = (col: ColumnTypeInfo) => ( +
+ toggleFilterColumn(col.columnName)} + /> + +
+ ); + + return ( +
+
+ 필터 연결 설정 +
+ + {!hasConnection && ( +
+ +

+ 연결 탭에서 대상 컴포넌트를 먼저 연결해주세요. + 연결된 리스트의 컬럼 목록이 여기에 표시됩니다. +

+
+ )} + + {hasConnection && showFieldName && ( +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( +
+ {displayedCols.length > 0 && ( +
+

카드에서 표시 중

+ {displayedCols.map(renderColumnCheckbox)} +
+ )} + {displayedCols.length > 0 && otherCols.length > 0 && ( +
+ )} + {otherCols.length > 0 && ( +
+

기타 컬럼

+ {otherCols.map(renderColumnCheckbox)} +
+ )} +
+ ) : ( +

+ 연결된 테이블에서 컬럼을 찾을 수 없습니다 +

+ )} + {selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && ( +
+ +

+ 필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다 +

+
+ )} + {selectedFilterCols.length > 0 && ( +

+ {selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다 +

+ )} + {selectedFilterCols.length === 0 && ( +

+ 연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능) +

+ )} +
+ )} + + {fixedFilterMode ? ( +
+ +
+ {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} +
+

+ 이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다 +

+
+ ) : ( +
+ + +

+ 연결된 리스트에 값을 보낼 때 적용되는 매칭 방식 +

+
+ )} +
+ ); +} + // ======================================== // text/number 상세 설정 // ======================================== -function TextDetailSettings({ cfg, update }: StepProps) { +function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { return (
@@ -285,6 +547,8 @@ function TextDetailSettings({ cfg, update }: StepProps) { />
+ +
); } @@ -293,7 +557,7 @@ function TextDetailSettings({ cfg, update }: StepProps) { // select 상세 설정 // ======================================== -function SelectDetailSettings({ cfg, update }: StepProps) { +function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const options = cfg.options || []; const addOption = () => { @@ -329,6 +593,90 @@ function SelectDetailSettings({ cfg, update }: StepProps) { 옵션 추가 + + +
+ ); +} + +// ======================================== +// date 상세 설정 +// ======================================== + +const DATE_SELECTION_MODE_LABELS: Record = { + single: "단일 날짜", + range: "기간 선택", +}; + +const CALENDAR_DISPLAY_LABELS: Record = { + popover: "팝오버 (PC용)", + modal: "모달 (터치/POP용)", +}; + +function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { + const mode: DateSelectionMode = cfg.dateSelectionMode || "single"; + const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal"; + const autoFilterMode = mode === "range" ? "range" : "equals"; + + return ( +
+
+ + +

+ {mode === "single" + ? "캘린더에서 날짜 하나를 선택합니다" + : "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"} +

+
+ +
+ + +

+ {calDisplay === "modal" + ? "터치 친화적인 큰 모달로 캘린더가 열립니다" + : "입력란 아래에 작은 팝오버로 열립니다"} +

+
+ +
); } @@ -337,7 +685,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) { // date-preset 상세 설정 // ======================================== -function DatePresetDetailSettings({ cfg, update }: StepProps) { +function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"]; const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; @@ -366,6 +714,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) { "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)

)} + +
); } @@ -647,6 +997,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {

+ {/* 중복 제거 (Distinct) */} +
+
+ updateModal({ distinct: !!checked })} + /> + +
+

+ 표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시 +

+
+ {/* 검색창에 보일 값 */}
@@ -694,6 +1059,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) { 연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)

+ + )}
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 6c49b1c5..6da0ae32 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -22,6 +22,12 @@ export function normalizeInputType(t: string): SearchInputType { return t as SearchInputType; } +/** 날짜 선택 모드 */ +export type DateSelectionMode = "single" | "range"; + +/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */ +export type CalendarDisplayMode = "popover" | "modal"; + /** 날짜 프리셋 옵션 */ export type DatePresetOption = "today" | "this-week" | "this-month" | "custom"; @@ -46,6 +52,9 @@ export type ModalDisplayStyle = "table" | "icon"; /** 모달 검색 방식 */ export type ModalSearchMode = "contains" | "starts-with" | "equals"; +/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */ +export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range"; + /** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */ export type ModalFilterTab = "korean" | "alphabet"; @@ -64,6 +73,9 @@ export interface ModalSelectConfig { displayField: string; valueField: string; + + /** displayField 기준 중복 제거 */ + distinct?: boolean; } /** pop-search 전체 설정 */ @@ -81,6 +93,10 @@ export interface PopSearchConfig { options?: SelectOption[]; optionsDataSource?: SelectDataSource; + // date 전용 + dateSelectionMode?: DateSelectionMode; + calendarDisplay?: CalendarDisplayMode; + // date-preset 전용 datePresets?: DatePresetOption[]; @@ -91,8 +107,11 @@ export interface PopSearchConfig { labelText?: string; labelVisible?: boolean; - // 스타일 - labelPosition?: "top" | "left"; + // 연결된 리스트에 필터를 보낼 때의 매칭 방식 + filterMode?: SearchFilterMode; + + // 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상) + filterColumns?: string[]; } /** 기본 설정값 (레지스트리 + 컴포넌트 공유) */ @@ -102,7 +121,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = { placeholder: "검색어 입력", debounceMs: 500, triggerOnEnter: true, - labelPosition: "top", labelText: "", labelVisible: true, }; @@ -147,6 +165,14 @@ export const MODAL_FILTER_TAB_LABELS: Record = { alphabet: "ABC", }; +/** 검색 필터 방식 라벨 (설정 패널용) */ +export const SEARCH_FILTER_MODE_LABELS: Record = { + contains: "포함", + equals: "일치", + starts_with: "시작", + range: "범위", +}; + /** 한글 초성 추출 */ const KOREAN_CONSONANTS = [ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", diff --git a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx index 62d63f02..99444d95 100644 --- a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx +++ b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx @@ -38,9 +38,23 @@ export function ColumnCombobox({ const filtered = useMemo(() => { if (!search) return columns; const q = search.toLowerCase(); - return columns.filter((c) => c.name.toLowerCase().includes(q)); + return columns.filter( + (c) => + c.name.toLowerCase().includes(q) || + (c.comment && c.comment.toLowerCase().includes(q)) + ); }, [columns, search]); + const selectedCol = useMemo( + () => columns.find((c) => c.name === value), + [columns, value], + ); + const displayValue = selectedCol + ? selectedCol.comment + ? `${selectedCol.name} (${selectedCol.comment})` + : selectedCol.name + : ""; + return ( @@ -50,7 +64,7 @@ export function ColumnCombobox({ aria-expanded={open} className="mt-1 h-8 w-full justify-between text-xs" > - {value || placeholder} + {displayValue || placeholder} @@ -61,7 +75,7 @@ export function ColumnCombobox({ > -
- {col.name} +
+
+ {col.name} + {col.comment && ( + + ({col.comment}) + + )} +
{col.type} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index ea7f5d58..d6595f36 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -193,10 +193,9 @@ export function PopStringListComponent({ row: RowData, filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } ): boolean => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; + const mode = fc?.filterMode || "contains"; + const columns: string[] = fc?.targetColumns?.length ? fc.targetColumns @@ -208,17 +207,46 @@ export function PopStringListComponent({ if (columns.length === 0) return true; - const mode = fc?.filterMode || "contains"; + // range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원 + if (mode === "range") { + const val = filter.value as { from?: string; to?: string } | string; + let from = ""; + let to = ""; + if (typeof val === "object" && val !== null) { + from = val.from || ""; + to = val.to || ""; + } else { + from = String(val || ""); + to = from; + } + if (!from && !to) return true; + + return columns.some((col) => { + const cellDate = String(row[col] ?? "").slice(0, 10); + if (!cellDate) return false; + if (from && cellDate < from) return false; + if (to && cellDate > to) return false; + return true; + }); + } + + // 문자열 기반 필터 (contains, equals, starts_with) + const searchValue = String(filter.value ?? "").toLowerCase(); + if (!searchValue) return true; + + // 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출 + const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue); const matchCell = (cellValue: string) => { + const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue; switch (mode) { case "equals": - return cellValue === searchValue; + return target === searchValue; case "starts_with": - return cellValue.startsWith(searchValue); + return target.startsWith(searchValue); case "contains": default: - return cellValue.includes(searchValue); + return target.includes(searchValue); } }; diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index cd8e65b6..574eff59 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -14,6 +14,7 @@ export interface LoginResponse { token?: string; userInfo?: any; firstMenuPath?: string | null; + popLandingPath?: string | null; }; errorCode?: string; }