Merge branch 'ksh-function-stabilization' into ksh-v2-work
ksh-function-stabilization의 9개 커밋을 ksh-v2-work에 병합한다. [병합 내용] - BLOCK O: pop-search 연결 탭 일관성 통합 - BLOCK P: 날짜 입력 타입 구현 + 셀 반응형 레이아웃 - pop-button 설정 패널 UX/UI 전면 개선 - 일괄 채번 + 모달 distinct + 선택 해제 - pop-scanner 바코드/QR 스캐너 컴포넌트 - pop-button 제어 실행 + 연결 데이터 UX - BLOCK R: PC <-> POP 네비게이션 + Landing - pop-profile 컴포넌트 (10번째 POP 컴포넌트) - BLOCK S: 로그인 POP 모드 토글 [충돌 해결 3건 - 모두 양쪽 통합] - UserDropdown.tsx: HEAD 결재함 + source POP 모드 메뉴 통합 - AppLayout.tsx: HEAD 결재함 + source POP 모드 메뉴 (모바일+사이드바 2곳) - MenuFormModal.tsx: HEAD menuIcon 필드 + source 주석 제거 통합
This commit is contained in:
commit
712f81f6cb
|
|
@ -108,6 +108,46 @@ export async function getUserMenus(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 메뉴 목록 조회
|
||||
* [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||
*/
|
||||
export async function getPopMenus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userType = req.user?.userType;
|
||||
|
||||
const result = await AdminService.getPopMenuList({
|
||||
userCompanyCode,
|
||||
userType,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "POP 메뉴 목록 조회 성공",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "POP_MENU_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<any>(
|
||||
`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<any>(
|
||||
`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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4468,26 +4468,30 @@ export class TableManagementService {
|
|||
|
||||
const rawColumns = await query<any>(
|
||||
`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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||
|
|
@ -23,9 +32,11 @@ export default function LoginPage() {
|
|||
isLoading={isLoading}
|
||||
error={error}
|
||||
showPassword={showPassword}
|
||||
isPopMode={isPopMode}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleLogin}
|
||||
onTogglePassword={togglePasswordVisibility}
|
||||
onTogglePop={togglePopMode}
|
||||
/>
|
||||
|
||||
<LoginFooter />
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 일반 모드 네비게이션 바 */}
|
||||
{!isPreviewMode && (
|
||||
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
POP 대시보드
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">{screen.screenName}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
PC 모드
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
||||
{/* 현재 모드 표시 (일반 모드) */}
|
||||
{!isPreviewMode && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{currentModeKey.replace("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
||||
|
|
|
|||
|
|
@ -82,12 +82,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
});
|
||||
|
||||
// 화면 할당 관련 상태
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [screenSearchText, setScreenSearchText] = useState("");
|
||||
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
||||
|
||||
// POP 화면 할당 관련 상태
|
||||
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [popScreenSearchText, setPopScreenSearchText] = useState("");
|
||||
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
|
||||
const [isPopLanding, setIsPopLanding] = useState(false);
|
||||
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
|
||||
|
||||
// 대시보드 할당 관련 상태
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
|
||||
const [dashboards, setDashboards] = useState<any[]>([]);
|
||||
|
|
@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
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<MenuFormModalProps> = ({
|
|||
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<MenuFormModalProps> = ({
|
|||
|
||||
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<MenuFormModalProps> = ({
|
|||
}, 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<MenuFormModalProps> = ({
|
|||
} 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<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [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<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [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<MenuFormModalProps> = ({
|
|||
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<MenuFormModalProps> = ({
|
|||
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<MenuFormModalProps> = ({
|
|||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||
|
||||
{/* URL 타입 선택 */}
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="screen" id="screen" />
|
||||
<Label htmlFor="screen" className="cursor-pointer">
|
||||
|
|
@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
대시보드 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pop" id="pop" />
|
||||
<Label htmlFor="pop" className="cursor-pointer">
|
||||
POP 화면 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="direct" id="direct" />
|
||||
<Label htmlFor="direct" className="cursor-pointer">
|
||||
|
|
@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 할당 */}
|
||||
{urlType === "pop" && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="text-left">
|
||||
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isPopScreenDropdownOpen && (
|
||||
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
|
||||
<div className="sticky top-0 border-b bg-white p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="POP 화면 검색..."
|
||||
value={popScreenSearchText}
|
||||
onChange={(e) => setPopScreenSearchText(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{screens
|
||||
.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||
)
|
||||
.map((screen, index) => (
|
||||
<div
|
||||
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||
onClick={() => handlePopScreenSelect(screen)}
|
||||
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{screens.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPopScreen && (
|
||||
<div className="bg-accent rounded-md border p-3">
|
||||
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
|
||||
<div className="text-primary text-xs">코드: {selectedPopScreen.screenCode}</div>
|
||||
<div className="text-primary text-xs">생성된 URL: {formData.menuUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 기본 화면 설정 */}
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="popLanding"
|
||||
checked={isPopLanding}
|
||||
disabled={!isPopLanding && hasOtherPopLanding}
|
||||
onChange={(e) => setIsPopLanding(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<label
|
||||
htmlFor="popLanding"
|
||||
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
POP 기본 화면으로 설정
|
||||
</label>
|
||||
{!isPopLanding && hasOtherPopLanding && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(이미 다른 메뉴가 기본 화면으로 설정되어 있습니다)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isPopLanding && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL 직접 입력 */}
|
||||
{urlType === "direct" && (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
|
||||
import { LoginFormData } from "@/types/auth";
|
||||
import { ErrorMessage } from "./ErrorMessage";
|
||||
|
||||
|
|
@ -11,9 +12,11 @@ interface LoginFormProps {
|
|||
isLoading: boolean;
|
||||
error: string;
|
||||
showPassword: boolean;
|
||||
isPopMode: boolean;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => 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 (
|
||||
<Card className="border-0 shadow-xl">
|
||||
|
|
@ -82,6 +87,19 @@ export function LoginForm({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* POP 모드 토글 */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-600">POP 모드</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPopMode}
|
||||
onCheckedChange={onTogglePop}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -42,11 +42,13 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 바코드 리더 초기화
|
||||
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScannedCode("");
|
||||
setError("");
|
||||
setIsScanning(false);
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -277,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
{/* 스캔 가이드 오버레이 */}
|
||||
{isScanning && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
|
||||
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
||||
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||
|
|
@ -356,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{scannedCode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setScannedCode("");
|
||||
startScanning();
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
다시 스캔
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{scannedCode && !autoSubmit && (
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ import {
|
|||
User,
|
||||
Building2,
|
||||
FileCheck,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { MenuItem } from "@/lib/api/menu";
|
||||
import { MenuItem, menuApi } from "@/lib/api/menu";
|
||||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -400,6 +401,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
}
|
||||
};
|
||||
|
||||
// POP 모드 진입 핸들러
|
||||
const handlePopModeClick = async () => {
|
||||
try {
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data) {
|
||||
const { childMenus, landingMenu } = response.data;
|
||||
|
||||
if (landingMenu?.menu_url) {
|
||||
router.push(landingMenu.menu_url);
|
||||
} else if (childMenus.length === 0) {
|
||||
toast.info("설정된 POP 화면이 없습니다");
|
||||
} else if (childMenus.length === 1) {
|
||||
router.push(childMenus[0].menu_url);
|
||||
} else {
|
||||
router.push("/pop");
|
||||
}
|
||||
} else {
|
||||
toast.info("설정된 POP 화면이 없습니다");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
||||
const renderMenu = (menu: any, level: number = 0) => {
|
||||
const isExpanded = expandedMenus.has(menu.id);
|
||||
|
|
@ -529,6 +554,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
|
@ -702,6 +731,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ interface MainHeaderProps {
|
|||
user: any;
|
||||
onSidebarToggle: () => void;
|
||||
onProfileClick: () => void;
|
||||
onPopModeClick?: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 헤더 컴포넌트
|
||||
*/
|
||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
|
||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
|
||||
return (
|
||||
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
|
||||
<div className="flex h-full w-full items-center justify-between px-6">
|
||||
|
|
@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
|
|||
|
||||
{/* Right side - Admin Button + User Menu */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
|
||||
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -8,19 +8,20 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, User, FileCheck } from "lucide-react";
|
||||
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface UserDropdownProps {
|
||||
user: any;
|
||||
onProfileClick: () => void;
|
||||
onPopModeClick?: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 드롭다운 메뉴 컴포넌트
|
||||
*/
|
||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
||||
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!user) return null;
|
||||
|
|
@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
|||
? `${user.deptName}, ${user.positionName}`
|
||||
: user.deptName || user.positionName || "부서 정보 없음"}
|
||||
</p>
|
||||
{/* 사진 상태 표시 */}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
|
@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
|||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
{onPopModeClick && (
|
||||
<DropdownMenuItem onClick={onPopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
|
|
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
|
|||
company: CompanyInfo;
|
||||
onThemeToggle: () => void;
|
||||
onUserClick: () => void;
|
||||
onPcModeClick?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
|
|
@ -20,6 +21,7 @@ export function DashboardHeader({
|
|||
company,
|
||||
onThemeToggle,
|
||||
onUserClick,
|
||||
onPcModeClick,
|
||||
}: DashboardHeaderProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
|
@ -81,6 +83,17 @@ export function DashboardHeader({
|
|||
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* PC 모드 복귀 */}
|
||||
{onPcModeClick && (
|
||||
<button
|
||||
className="pop-dashboard-theme-toggle"
|
||||
onClick={onPcModeClick}
|
||||
title="PC 모드로 돌아가기"
|
||||
>
|
||||
<Monitor size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 사용자 배지 */}
|
||||
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
||||
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
import { NoticeBanner } from "./NoticeBanner";
|
||||
import { KpiBar } from "./KpiBar";
|
||||
|
|
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
|
|||
import { ActivityList } from "./ActivityList";
|
||||
import { NoticeList } from "./NoticeList";
|
||||
import { DashboardFooter } from "./DashboardFooter";
|
||||
import { MenuItem as DashboardMenuItem } from "./types";
|
||||
import { menuApi, PopMenuItem } from "@/lib/api/menu";
|
||||
import {
|
||||
KPI_ITEMS,
|
||||
MENU_ITEMS,
|
||||
|
|
@ -17,10 +20,31 @@ import {
|
|||
} from "./data";
|
||||
import "./dashboard.css";
|
||||
|
||||
export function PopDashboard() {
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
|
||||
"production",
|
||||
"material",
|
||||
"quality",
|
||||
"equipment",
|
||||
"safety",
|
||||
];
|
||||
|
||||
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
|
||||
return {
|
||||
id: item.objid,
|
||||
title: item.menu_name_kor,
|
||||
count: 0,
|
||||
description: item.menu_desc?.replace("[POP]", "").trim() || "",
|
||||
status: "",
|
||||
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
|
||||
href: item.menu_url || "#",
|
||||
};
|
||||
}
|
||||
|
||||
export function PopDashboard() {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
|
||||
|
||||
// 로컬 스토리지에서 테마 로드
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||
if (savedTheme) {
|
||||
|
|
@ -28,6 +52,22 @@ export function PopDashboard() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// API에서 POP 메뉴 로드
|
||||
useEffect(() => {
|
||||
const loadPopMenus = async () => {
|
||||
try {
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data && response.data.childMenus.length > 0) {
|
||||
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
|
||||
setMenuItems(converted);
|
||||
}
|
||||
} catch {
|
||||
// API 실패 시 기존 하드코딩 데이터 유지
|
||||
}
|
||||
};
|
||||
loadPopMenus();
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const newTheme = theme === "dark" ? "light" : "dark";
|
||||
setTheme(newTheme);
|
||||
|
|
@ -40,6 +80,10 @@ export function PopDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
const handlePcModeClick = () => {
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleActivityMore = () => {
|
||||
alert("전체 활동 내역 화면으로 이동합니다.");
|
||||
};
|
||||
|
|
@ -58,13 +102,14 @@ export function PopDashboard() {
|
|||
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
||||
onThemeToggle={handleThemeToggle}
|
||||
onUserClick={handleUserClick}
|
||||
onPcModeClick={handlePcModeClick}
|
||||
/>
|
||||
|
||||
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
||||
|
||||
<KpiBar items={KPI_ITEMS} />
|
||||
|
||||
<MenuGrid items={MENU_ITEMS} />
|
||||
<MenuGrid items={menuItems} />
|
||||
|
||||
<div className="pop-dashboard-bottom-section">
|
||||
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -69,6 +69,18 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: TextCursorInput,
|
||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||
},
|
||||
{
|
||||
type: "pop-scanner",
|
||||
label: "스캐너",
|
||||
icon: ScanLine,
|
||||
description: "바코드/QR 카메라 스캔",
|
||||
},
|
||||
{
|
||||
type: "pop-profile",
|
||||
label: "프로필",
|
||||
icon: UserCircle,
|
||||
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -19,9 +17,7 @@ import {
|
|||
} from "../types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ComponentConnectionMeta,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -36,15 +32,6 @@ interface ConnectionEditorProps {
|
|||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
|
||||
// ========================================
|
||||
|
||||
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
|
||||
if (!meta?.sendable) return false;
|
||||
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ConnectionEditor
|
||||
// ========================================
|
||||
|
|
@ -84,17 +71,13 @@ export default function ConnectionEditor({
|
|||
);
|
||||
}
|
||||
|
||||
const isFilterSource = hasFilterSendable(meta);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
<SendSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
isFilterSource={isFilterSource}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
|
|
@ -112,47 +95,14 @@ export default function ConnectionEditor({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||
if (!comp?.config) return [];
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
const cols: string[] = [];
|
||||
|
||||
if (Array.isArray(cfg.listColumns)) {
|
||||
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
|
||||
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(cfg.selectedColumns)) {
|
||||
(cfg.selectedColumns as string[]).forEach((c) => {
|
||||
if (!cols.includes(c)) cols.push(c);
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
const ds = cfg.dataSource as { tableName?: string } | undefined;
|
||||
return ds?.tableName || "";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 보내기 섹션
|
||||
// ========================================
|
||||
|
||||
interface SendSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
outgoing: PopDataConnection[];
|
||||
isFilterSource: boolean;
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
|
|
@ -160,10 +110,8 @@ interface SendSectionProps {
|
|||
|
||||
function SendSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
isFilterSource,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
|
|
@ -180,32 +128,17 @@ function SendSection({
|
|||
{outgoing.map((conn) => (
|
||||
<div key={conn.id}>
|
||||
{editingId === conn.id ? (
|
||||
isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
)
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||
<span className="flex-1 truncate text-xs">
|
||||
|
|
@ -230,22 +163,12 @@ function SendSection({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
)}
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -350,328 +273,6 @@ function SimpleConnectionForm({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function FilterConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: FilterConnectionFormProps) {
|
||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||
);
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
initial?.targetComponent || ""
|
||||
);
|
||||
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
|
||||
initial?.targetInput || ""
|
||||
);
|
||||
const [filterColumns, setFilterColumns] = React.useState<string[]>(
|
||||
initial?.filterConfig?.targetColumns ||
|
||||
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
|
||||
);
|
||||
const [filterMode, setFilterMode] = React.useState<
|
||||
"equals" | "contains" | "starts_with" | "range"
|
||||
>(initial?.filterConfig?.filterMode || "contains");
|
||||
|
||||
const targetCandidates = allComponents.filter((c) => {
|
||||
if (c.id === component.id) return false;
|
||||
const reg = PopComponentRegistry.getComponent(c.type);
|
||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||
});
|
||||
|
||||
const targetComp = selectedTargetId
|
||||
? allComponents.find((c) => c.id === selectedTargetId)
|
||||
: null;
|
||||
|
||||
const targetMeta = targetComp
|
||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||
if (selectedTargetInput) return;
|
||||
|
||||
const receivables = targetMeta.receivable;
|
||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||
if (exactMatch) {
|
||||
setSelectedTargetInput(exactMatch.key);
|
||||
return;
|
||||
}
|
||||
if (receivables.length === 1) {
|
||||
setSelectedTargetInput(receivables[0].key);
|
||||
}
|
||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
|
||||
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tableName) {
|
||||
setAllDbColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setDbColumnsLoading(true);
|
||||
getTableColumns(tableName).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data?.columns) {
|
||||
setAllDbColumns(res.data.columns.map((c) => c.columnName));
|
||||
} else {
|
||||
setAllDbColumns([]);
|
||||
}
|
||||
setDbColumnsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
[allDbColumns, displaySet]
|
||||
);
|
||||
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
|
||||
|
||||
const toggleColumn = (col: string) => {
|
||||
setFilterColumns((prev) =>
|
||||
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
||||
|
||||
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: selectedOutput,
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: selectedTargetInput,
|
||||
filterConfig:
|
||||
!isEvent && filterColumns.length > 0
|
||||
? {
|
||||
targetColumn: filterColumns[0],
|
||||
targetColumns: filterColumns,
|
||||
filterMode,
|
||||
}
|
||||
: undefined,
|
||||
label: buildConnectionLabel(
|
||||
component,
|
||||
selectedOutput,
|
||||
allComponents.find((c) => c.id === selectedTargetId),
|
||||
selectedTargetInput,
|
||||
filterColumns
|
||||
),
|
||||
});
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
setSelectedTargetInput("");
|
||||
setFilterColumns([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-dashed p-3">
|
||||
{onCancel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!onCancel && (
|
||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{meta.sendable.map((s) => (
|
||||
<SelectItem key={s.key} value={s.key} className="text-xs">
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||
<Select
|
||||
value={selectedTargetId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedTargetId(v);
|
||||
setSelectedTargetInput("");
|
||||
setFilterColumns([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetCandidates.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.label || c.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{targetMeta && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetMeta.receivable.map((r) => (
|
||||
<SelectItem key={r.key} value={r.key} className="text-xs">
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
|
||||
{dbColumnsLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">컬럼 조회 중...</span>
|
||||
</div>
|
||||
) : hasAnyColumns ? (
|
||||
<div className="space-y-2">
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||
{displayColumns.map((col) => (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`col-${col}-${initial?.id || "new"}`}
|
||||
checked={filterColumns.includes(col)}
|
||||
onCheckedChange={() => toggleColumn(col)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||
className="cursor-pointer text-xs"
|
||||
>
|
||||
{col}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="my-1 h-px bg-gray-200" />
|
||||
)}
|
||||
<p className="text-[9px] font-medium text-amber-600">데이터 전용 컬럼</p>
|
||||
{dataOnlyColumns.map((col) => (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`col-${col}-${initial?.id || "new"}`}
|
||||
checked={filterColumns.includes(col)}
|
||||
onCheckedChange={() => toggleColumn(col)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||
className="cursor-pointer text-xs text-muted-foreground"
|
||||
>
|
||||
{col}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={filterColumns[0] || ""}
|
||||
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
|
||||
placeholder="컬럼명 입력"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterColumns.length > 0 && (
|
||||
<p className="text-[10px] text-blue-600">
|
||||
{filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contains" className="text-xs">포함</SelectItem>
|
||||
<SelectItem value="equals" className="text-xs">일치</SelectItem>
|
||||
<SelectItem value="starts_with" className="text-xs">시작</SelectItem>
|
||||
<SelectItem value="range" className="text-xs">범위</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||
// ========================================
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-field": "입력",
|
||||
"pop-profile": "프로필",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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<PopComponentType, { colSpan: nu
|
|||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-search": { colSpan: 4, rowSpan: 2 },
|
||||
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||
"pop-profile": { colSpan: 1, rowSpan: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -322,7 +322,9 @@ export async function executeTaskList(
|
|||
}
|
||||
|
||||
case "custom-event":
|
||||
if (task.eventName) {
|
||||
if (task.flowId) {
|
||||
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
|
||||
} else if (task.eventName) {
|
||||
publish(task.eventName, task.eventPayload ?? {});
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
|
|||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ConnectionMetaItem,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
interface UseConnectionResolverOptions {
|
||||
|
|
@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
|
|||
componentTypes?: Map<string, string>;
|
||||
}
|
||||
|
||||
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<string, unknown> | 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<string, unknown> | 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -79,6 +79,23 @@ export interface ApiResponse<T> {
|
|||
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<ApiResponse<MenuItem[]>> => {
|
||||
|
|
@ -94,6 +111,12 @@ export const menuApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴)
|
||||
getPopMenus: async (): Promise<ApiResponse<PopMenuResponse>> => {
|
||||
const response = await apiClient.get("/admin/pop-menus");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시)
|
||||
getAdminMenusForManagement: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface PopComponentDefinition {
|
|||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||
defaultProps?: Record<string, any>;
|
||||
connectionMeta?: ComponentConnectionMeta;
|
||||
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
|
||||
// POP 전용 속성
|
||||
touchOptimized?: boolean;
|
||||
minTouchArea?: number;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,5 @@ import "./pop-string-list";
|
|||
import "./pop-search";
|
||||
|
||||
import "./pop-field";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-list";
|
||||
import "./pop-scanner";
|
||||
import "./pop-profile";
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2039,16 +2039,29 @@ function FilterSettingsSection({
|
|||
{filters.map((filter, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
|
||||
className="space-y-1.5 rounded-md border bg-card p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
조건 {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={filter.column || ""}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, column: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
|
|
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, {
|
||||
...filter,
|
||||
operator: val as FilterOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) =>
|
||||
updateFilter(index, { ...filter, value: e.target.value })
|
||||
}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, {
|
||||
...filter,
|
||||
operator: val as FilterOperator,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) =>
|
||||
updateFilter(index, { ...filter, value: e.target.value })
|
||||
}
|
||||
placeholder="값 입력"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
|
|||
) : (
|
||||
<div className="space-y-2">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
|
||||
<div className="flex-1">
|
||||
<GroupedColumnSelect
|
||||
columnGroups={columnGroups}
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
|
||||
placeholder="컬럼 선택"
|
||||
<div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
조건 {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<GroupedColumnSelect
|
||||
columnGroups={columnGroups}
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, operator: val as FilterOperator })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
|
||||
placeholder="값 입력"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, operator: val as FilterOperator })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
const fieldNames = new Set<string>();
|
||||
for (const section of cfg.sections) {
|
||||
for (const f of section.fields ?? []) {
|
||||
if (f.fieldName) fieldNames.add(f.fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
const matched: Record<string, unknown> = {};
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
/>
|
||||
<Label className="text-[10px]">결과 모달</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={m.shareAcrossItems ?? false}
|
||||
onCheckedChange={(v) => updateAutoGenMapping(m.id, { shareAcrossItems: v })}
|
||||
/>
|
||||
<Label className="text-[10px]">일괄 채번</Label>
|
||||
</div>
|
||||
</div>
|
||||
{m.shareAcrossItems && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
저장되는 모든 행에 동일한 번호를 부여합니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1414,7 +1437,7 @@ function SectionEditor({
|
|||
const newField: PopFieldItem = {
|
||||
id: fieldId,
|
||||
inputType: "text",
|
||||
fieldName: fieldId,
|
||||
fieldName: "",
|
||||
labelText: "",
|
||||
readOnly: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
|
|||
numberingRuleId?: string;
|
||||
showInForm: boolean;
|
||||
showResultModal: boolean;
|
||||
shareAcrossItems?: boolean;
|
||||
}
|
||||
|
||||
export interface PopFieldSaveConfig {
|
||||
|
|
|
|||
|
|
@ -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<AvatarSize, { container: string; text: string; px: number }> = {
|
||||
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<AvatarSize, string> = {
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full",
|
||||
"bg-primary text-primary-foreground font-bold",
|
||||
"border-2 border-primary/20 cursor-pointer",
|
||||
"transition-all duration-150",
|
||||
"hover:scale-105 hover:border-primary/40",
|
||||
"active:scale-95",
|
||||
sizeInfo.container,
|
||||
sizeInfo.text,
|
||||
)}
|
||||
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
|
||||
>
|
||||
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
src={user.photo}
|
||||
alt={user.userName || "User"}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-60 p-0"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
{/* 사용자 정보 */}
|
||||
<div className="flex items-center gap-3 border-b p-4">
|
||||
<div className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full",
|
||||
"bg-primary text-primary-foreground font-bold",
|
||||
"h-10 w-10 text-base",
|
||||
)}>
|
||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
src={user.photo}
|
||||
alt={user.userName || "User"}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{user.userName || "사용자"} ({user.userId || ""})
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.deptName || "부서 정보 없음"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 항목 */}
|
||||
<div className="p-1.5">
|
||||
{config.showDashboardLink && (
|
||||
<button
|
||||
onClick={handleDashboard}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
|
||||
style={{ minHeight: 48 }}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
|
||||
POP 대시보드
|
||||
</button>
|
||||
)}
|
||||
{config.showPcMode && (
|
||||
<button
|
||||
onClick={handlePcMode}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
|
||||
style={{ minHeight: 48 }}
|
||||
>
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
PC 모드
|
||||
</button>
|
||||
)}
|
||||
{config.showLogout && (
|
||||
<>
|
||||
<div className="mx-2 my-1 border-t" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-destructive transition-colors hover:bg-destructive/10"
|
||||
style={{ minHeight: 48 }}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
로그아웃
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<p className="mb-3 text-center text-sm text-muted-foreground">
|
||||
로그인이 필요합니다
|
||||
</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
style={{ minHeight: 48 }}
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 패널
|
||||
// ========================================
|
||||
|
||||
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<PopProfileConfig>) => {
|
||||
onUpdate({ ...config, ...partial });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
{/* 아바타 크기 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs sm:text-sm">아바타 크기</Label>
|
||||
<Select
|
||||
value={config.avatarSize || "md"}
|
||||
onValueChange={(v) => updateConfig({ avatarSize: v as AvatarSize })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(AVATAR_SIZE_LABELS) as [AvatarSize, string][]).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs sm:text-sm">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 항목 토글 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs sm:text-sm">메뉴 항목</Label>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">POP 대시보드 이동</Label>
|
||||
<Switch
|
||||
checked={config.showDashboardLink ?? true}
|
||||
onCheckedChange={(v) => updateConfig({ showDashboardLink: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">PC 모드 전환</Label>
|
||||
<Switch
|
||||
checked={config.showPcMode ?? true}
|
||||
onCheckedChange={(v) => updateConfig({ showPcMode: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">로그아웃</Label>
|
||||
<Switch
|
||||
checked={config.showLogout ?? true}
|
||||
onCheckedChange={(v) => updateConfig({ showLogout: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 디자이너 미리보기
|
||||
// ========================================
|
||||
|
||||
function PopProfilePreview({ config }: { config?: PopProfileConfig }) {
|
||||
const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"];
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center gap-2">
|
||||
<div className={cn(
|
||||
"flex items-center justify-center rounded-full",
|
||||
"bg-primary/20 text-primary",
|
||||
size.container, size.text,
|
||||
)}>
|
||||
<UserCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">프로필</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 레지스트리 등록
|
||||
// ========================================
|
||||
|
||||
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"],
|
||||
});
|
||||
|
|
@ -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<string, string> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
const result: Record<string, string> = {};
|
||||
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<string, string> | 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<string, unknown>;
|
||||
const compType = comp.type || "";
|
||||
const compName = (comp as Record<string, unknown>).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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
variant={cfg.buttonVariant}
|
||||
size="icon"
|
||||
onClick={handleClick}
|
||||
className="h-full w-full rounded-md transition-transform active:scale-95"
|
||||
>
|
||||
<ScanLine className="h-7! w-7!" />
|
||||
<span className="sr-only">{cfg.buttonLabel}</span>
|
||||
</Button>
|
||||
|
||||
{cfg.showLastScan && lastScan && (
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-background/80 px-1 text-center text-[8px] text-muted-foreground backdrop-blur-sm">
|
||||
{lastScan}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDesignMode && (
|
||||
<BarcodeScanModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
barcodeFormat={cfg.barcodeFormat}
|
||||
autoSubmit={cfg.autoSubmit}
|
||||
onScanSuccess={handleScanSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 패널
|
||||
// ========================================
|
||||
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
all: "모든 형식",
|
||||
"1d": "1D 바코드",
|
||||
"2d": "2D 바코드 (QR)",
|
||||
};
|
||||
|
||||
const VARIANT_LABELS: Record<string, string> = {
|
||||
default: "기본 (Primary)",
|
||||
outline: "외곽선 (Outline)",
|
||||
secondary: "보조 (Secondary)",
|
||||
};
|
||||
|
||||
const PARSE_MODE_LABELS: Record<string, string> = {
|
||||
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<PopScannerConfig>) => {
|
||||
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 (
|
||||
<div className="space-y-4 pr-1 pb-16">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">바코드 형식</Label>
|
||||
<Select
|
||||
value={cfg.barcodeFormat}
|
||||
onValueChange={(v) => update({ barcodeFormat: v as PopScannerConfig["barcodeFormat"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FORMAT_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">인식할 바코드 종류를 선택합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={cfg.buttonLabel}
|
||||
onChange={(e) => update({ buttonLabel: e.target.value })}
|
||||
placeholder="스캔"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">버튼 스타일</Label>
|
||||
<Select
|
||||
value={cfg.buttonVariant}
|
||||
onValueChange={(v) => update({ buttonVariant: v as PopScannerConfig["buttonVariant"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">인식 후 자동 확인</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{cfg.autoSubmit
|
||||
? "바코드 인식 즉시 값 전달 (확인 버튼 생략)"
|
||||
: "인식 후 확인 버튼을 눌러야 값 전달"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cfg.autoSubmit}
|
||||
onCheckedChange={(v) => update({ autoSubmit: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">마지막 스캔값 표시</Label>
|
||||
<p className="text-[10px] text-muted-foreground">버튼 아래에 마지막 스캔값 표시</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cfg.showLastScan}
|
||||
onCheckedChange={(v) => update({ showLastScan: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파싱 설정 섹션 */}
|
||||
<div className="border-t pt-4">
|
||||
<Label className="text-xs font-semibold">스캔 데이터 파싱</Label>
|
||||
<p className="mb-3 text-[10px] text-muted-foreground">
|
||||
바코드/QR에 여러 정보가 담긴 경우, 파싱하여 각각 다른 컴포넌트에 전달
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">파싱 모드</Label>
|
||||
<Select
|
||||
value={cfg.parseMode}
|
||||
onValueChange={(v) => {
|
||||
const mode = v as PopScannerConfig["parseMode"];
|
||||
update({
|
||||
parseMode: mode,
|
||||
fieldMappings: mode === "none" ? [] : cfg.fieldMappings,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PARSE_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{cfg.parseMode === "auto" && (
|
||||
<div className="mt-3 rounded-md bg-muted/50 p-3">
|
||||
<p className="text-[10px] font-medium">자동 매칭 방식</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
QR/바코드의 JSON 키가 연결된 컴포넌트의 필드명과 같으면 자동 입력됩니다.
|
||||
</p>
|
||||
{connectedFields.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-[10px] font-medium">연결된 필드 목록:</p>
|
||||
{connectedFields.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<span className="font-mono text-primary">{f.fieldName}</span>
|
||||
<span>- {f.fieldLabel}</span>
|
||||
<span className="text-muted-foreground/50">({f.componentName})</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
QR에 위 필드명이 JSON 키로 포함되면 자동 매칭됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{connectedFields.length === 0 && (
|
||||
<p className="mt-2 text-[10px] text-muted-foreground">
|
||||
연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결하세요.
|
||||
연결 없이도 같은 화면의 모든 컴포넌트에 전역으로 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.parseMode === "json" && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
연결된 컴포넌트의 필드를 선택하고, 매핑할 JSON 키를 지정합니다.
|
||||
필드명과 같은 JSON 키가 있으면 자동 매칭됩니다.
|
||||
</p>
|
||||
|
||||
{connectedFields.length === 0 ? (
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
연결 탭에서 스캐너와 다른 컴포넌트를 먼저 연결해주세요.
|
||||
연결된 컴포넌트의 필드 목록이 여기에 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold">필드 매핑</Label>
|
||||
<div className="space-y-1.5">
|
||||
{cfg.fieldMappings.map((mapping) => (
|
||||
<div
|
||||
key={`${mapping.targetComponentId}_${mapping.targetFieldName}`}
|
||||
className="flex items-start gap-2 rounded-md border p-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
|
||||
checked={mapping.enabled}
|
||||
onCheckedChange={() =>
|
||||
toggleMapping(mapping.targetFieldName, mapping.targetComponentId)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label
|
||||
htmlFor={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
|
||||
className="flex cursor-pointer items-center gap-1 text-[11px]"
|
||||
>
|
||||
<span className="font-mono text-primary">
|
||||
{mapping.targetFieldName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({mapping.label})
|
||||
</span>
|
||||
</label>
|
||||
{mapping.enabled && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
JSON 키:
|
||||
</span>
|
||||
<Input
|
||||
value={mapping.sourceKey}
|
||||
onChange={(e) =>
|
||||
updateMappingSourceKey(
|
||||
mapping.targetFieldName,
|
||||
mapping.targetComponentId,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={mapping.targetFieldName}
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{cfg.fieldMappings.some((m) => m.enabled) && (
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">활성 매핑:</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{cfg.fieldMappings
|
||||
.filter((m) => m.enabled)
|
||||
.map((m, i) => (
|
||||
<li key={i} className="text-[10px] text-muted-foreground">
|
||||
<span className="font-mono">{m.sourceKey || "?"}</span>
|
||||
{" -> "}
|
||||
<span className="font-mono text-primary">{m.targetFieldName}</span>
|
||||
{m.label && <span className="ml-1">({m.label})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 미리보기
|
||||
// ========================================
|
||||
|
||||
function PopScannerPreview({ config }: { config?: PopScannerConfig }) {
|
||||
const cfg = config || DEFAULT_SCANNER_CONFIG;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<Button
|
||||
variant={cfg.buttonVariant}
|
||||
size="icon"
|
||||
className="pointer-events-none h-full w-full rounded-md"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ScanLine className="h-7! w-7!" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 동적 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<string, unknown>) => ({
|
||||
sendable: buildSendableMeta(config as unknown as PopScannerConfig),
|
||||
receivable: [],
|
||||
}),
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -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<string, unknown> | 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full overflow-hidden",
|
||||
showLabel && config.labelPosition === "left"
|
||||
? "flex-row items-center gap-2 p-1.5"
|
||||
: "flex-col justify-center gap-0.5 p-1.5"
|
||||
)}
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{config.labelText}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
|
||||
<SearchInputRenderer
|
||||
config={config}
|
||||
value={value}
|
||||
onChange={emitFilterChanged}
|
||||
modalDisplayText={modalDisplayText}
|
||||
onModalOpen={handleModalOpen}
|
||||
onModalClear={handleModalClear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -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 <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||
case "select":
|
||||
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||
case "date": {
|
||||
const dateMode: DateSelectionMode = config.dateSelectionMode || "single";
|
||||
return dateMode === "range"
|
||||
? <DateRangeInput config={config} value={value} onChange={onChange} />
|
||||
: <DateSingleInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||
}
|
||||
case "date-preset":
|
||||
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
||||
case "toggle":
|
||||
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
|
||||
case "modal":
|
||||
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
|
||||
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
|
||||
default:
|
||||
return <PlaceholderInput inputType={config.inputType} />;
|
||||
}
|
||||
|
|
@ -215,7 +272,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
|||
const isNumber = config.inputType === "number";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative h-full">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type={isNumber ? "number" : "text"}
|
||||
|
|
@ -224,12 +281,283 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
|||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
||||
className="h-8 pl-7 text-xs"
|
||||
className="h-full min-h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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 = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
onClick={useModal ? () => setOpen(true) : undefined}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{value ? format(new Date(value + "T00:00:00"), "yyyy.MM.dd (EEE)", { locale: ko }) : (config.placeholder || "날짜 선택")}
|
||||
</span>
|
||||
{value && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (useModal) {
|
||||
return (
|
||||
<>
|
||||
{triggerButton}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-0">
|
||||
<DialogTitle className="text-sm">날짜 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center pb-4">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
locale={ko}
|
||||
defaultMonth={selected || new Date()}
|
||||
className="touch-date-calendar"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{triggerButton}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
locale={ko}
|
||||
defaultMonth={selected || new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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 = (
|
||||
<div className="flex gap-1 px-3">
|
||||
{RANGE_PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.key}
|
||||
variant={activePreset === p.key ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 flex-1 px-1 text-[10px]"
|
||||
onClick={() => {
|
||||
handlePreset(p.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const calendarEl = (
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={calendarRange}
|
||||
onSelect={handleRangeSelect}
|
||||
locale={ko}
|
||||
defaultMonth={calendarRange?.from || new Date()}
|
||||
numberOfMonths={1}
|
||||
className={useModal ? "touch-date-calendar" : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
const triggerButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
|
||||
!rangeVal.from && "text-muted-foreground"
|
||||
)}
|
||||
onClick={useModal ? () => setOpen(true) : undefined}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{displayText || (config.placeholder || "기간 선택")}
|
||||
</span>
|
||||
{rangeVal.from && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (useModal) {
|
||||
return (
|
||||
<>
|
||||
{triggerButton}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-0">
|
||||
<DialogTitle className="text-sm">기간 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 pb-4">
|
||||
{presetBar}
|
||||
<div className="flex justify-center">
|
||||
{calendarEl}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{triggerButton}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="space-y-2 pt-2 pb-1">
|
||||
{presetBar}
|
||||
{calendarEl}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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 (
|
||||
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-full min-h-8 text-xs">
|
||||
<SelectValue placeholder={config.placeholder || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -266,7 +594,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex h-full flex-wrap items-center gap-1">
|
||||
{presets.map((preset) => (
|
||||
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
|
||||
{DATE_PRESET_LABELS[preset]}
|
||||
|
|
@ -282,7 +610,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
|
|||
|
||||
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
||||
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
||||
</div>
|
||||
|
|
@ -293,17 +621,32 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
|
|||
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
|
||||
// ========================================
|
||||
|
||||
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
|
||||
function ModalSearchInput({ config, displayText, onClick, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) {
|
||||
const hasValue = !!displayText;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
|
||||
className="flex h-full min-h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
|
||||
>
|
||||
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
|
||||
{displayText || config.placeholder || "선택..."}
|
||||
</span>
|
||||
{hasValue && onClear ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
||||
aria-label="선택 해제"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -314,7 +657,7 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
|
|||
|
||||
function PlaceholderInput({ inputType }: { inputType: string }) {
|
||||
return (
|
||||
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
|
||||
<div className="flex h-full min-h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
|
||||
<span className="text-[10px] text-muted-foreground">{inputType} (후속 구현 예정)</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -382,6 +725,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
|
|||
columnLabels,
|
||||
displayStyle = "table",
|
||||
displayField,
|
||||
distinct,
|
||||
} = modalConfig;
|
||||
|
||||
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
|
||||
|
|
@ -393,13 +737,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
|
|||
setLoading(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
|
||||
setAllRows(result.data || []);
|
||||
let rows = result.data || [];
|
||||
|
||||
if (distinct && displayField) {
|
||||
const seen = new Set<string>();
|
||||
rows = rows.filter((row) => {
|
||||
const val = String(row[displayField] ?? "");
|
||||
if (seen.has(val)) return false;
|
||||
seen.add(val);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
setAllRows(rows);
|
||||
} catch {
|
||||
setAllRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tableName]);
|
||||
}, [tableName, distinct, displayField]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -30,6 +30,9 @@ import {
|
|||
import type {
|
||||
PopSearchConfig,
|
||||
SearchInputType,
|
||||
SearchFilterMode,
|
||||
DateSelectionMode,
|
||||
CalendarDisplayMode,
|
||||
DatePresetOption,
|
||||
ModalSelectConfig,
|
||||
ModalDisplayStyle,
|
||||
|
|
@ -38,6 +41,7 @@ import type {
|
|||
} from "./types";
|
||||
import {
|
||||
SEARCH_INPUT_TYPE_LABELS,
|
||||
SEARCH_FILTER_MODE_LABELS,
|
||||
DATE_PRESET_LABELS,
|
||||
MODAL_DISPLAY_STYLE_LABELS,
|
||||
MODAL_SEARCH_MODE_LABELS,
|
||||
|
|
@ -57,7 +61,6 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
|||
placeholder: "검색어 입력",
|
||||
debounceMs: 500,
|
||||
triggerOnEnter: true,
|
||||
labelPosition: "top",
|
||||
labelText: "",
|
||||
labelVisible: true,
|
||||
};
|
||||
|
|
@ -69,9 +72,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
|||
interface ConfigPanelProps {
|
||||
config: PopSearchConfig | undefined;
|
||||
onUpdate: (config: PopSearchConfig) => void;
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||
export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
||||
const cfg: PopSearchConfig = {
|
||||
|
|
@ -110,7 +116,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
|||
</div>
|
||||
|
||||
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
|
||||
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
|
||||
{step === 1 && <StepDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button
|
||||
|
|
@ -145,6 +151,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
|||
interface StepProps {
|
||||
cfg: PopSearchConfig;
|
||||
update: (partial: Partial<PopSearchConfig>) => void;
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
function StepBasicSettings({ cfg, update }: StepProps) {
|
||||
|
|
@ -189,33 +198,17 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
|||
</div>
|
||||
|
||||
{cfg.labelVisible !== false && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={cfg.labelText || ""}
|
||||
onChange={(e) => update({ labelText: e.target.value })}
|
||||
placeholder="예: 거래처명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨 위치</Label>
|
||||
<Select
|
||||
value={cfg.labelPosition || "top"}
|
||||
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top" className="text-xs">위 (기본)</SelectItem>
|
||||
<SelectItem value="left" className="text-xs">왼쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={cfg.labelText || ""}
|
||||
onChange={(e) => update({ labelText: e.target.value })}
|
||||
placeholder="예: 거래처명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 <TextDetailSettings cfg={cfg} update={update} />;
|
||||
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "select":
|
||||
return <SelectDetailSettings cfg={cfg} update={update} />;
|
||||
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "date":
|
||||
return <DateDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "date-preset":
|
||||
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
||||
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||
case "modal":
|
||||
return <ModalDetailSettings cfg={cfg} update={update} />;
|
||||
case "toggle":
|
||||
|
|
@ -255,11 +250,278 @@ function StepDetailSettings({ cfg, update }: StepProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 공통: 필터 연결 설정 섹션
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionSectionProps {
|
||||
cfg: PopSearchConfig;
|
||||
update: (partial: Partial<PopSearchConfig>) => 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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결된 대상 컴포넌트의 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<string>();
|
||||
const displayedColumns = new Set<string>();
|
||||
|
||||
for (const tid of targetIds) {
|
||||
const comp = allComponents.find((c) => c.id === tid);
|
||||
if (!comp?.config) continue;
|
||||
const compCfg = comp.config as Record<string, any>;
|
||||
|
||||
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<ColumnTypeInfo[]>([]);
|
||||
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<string>();
|
||||
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) => (
|
||||
<div key={col.columnName} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`filter_col_${col.columnName}`}
|
||||
checked={selectedFilterCols.includes(col.columnName)}
|
||||
onCheckedChange={() => toggleFilterColumn(col.columnName)}
|
||||
/>
|
||||
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
|
||||
{col.displayName || col.columnName}
|
||||
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 border-t pt-3">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">필터 연결 설정</span>
|
||||
</div>
|
||||
|
||||
{!hasConnection && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
||||
<p className="text-[9px] text-amber-700">
|
||||
연결 탭에서 대상 컴포넌트를 먼저 연결해주세요.
|
||||
연결된 리스트의 컬럼 목록이 여기에 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasConnection && showFieldName && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">
|
||||
필터 대상 컬럼 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{columnsLoading ? (
|
||||
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
컬럼 로딩...
|
||||
</div>
|
||||
) : targetColumns.length > 0 ? (
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded border p-2">
|
||||
{displayedCols.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-primary">카드에서 표시 중</p>
|
||||
{displayedCols.map(renderColumnCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
{displayedCols.length > 0 && otherCols.length > 0 && (
|
||||
<div className="border-t" />
|
||||
)}
|
||||
{otherCols.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-muted-foreground">기타 컬럼</p>
|
||||
{otherCols.map(renderColumnCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 테이블에서 컬럼을 찾을 수 없습니다
|
||||
</p>
|
||||
)}
|
||||
{selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
||||
<p className="text-[9px] text-amber-700">
|
||||
필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedFilterCols.length > 0 && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
{selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다
|
||||
</p>
|
||||
)}
|
||||
{selectedFilterCols.length === 0 && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fixedFilterMode ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필터 방식</Label>
|
||||
<div className="flex h-8 items-center rounded-md border bg-muted px-3 text-xs text-muted-foreground">
|
||||
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필터 방식</Label>
|
||||
<Select
|
||||
value={cfg.filterMode || "contains"}
|
||||
onValueChange={(v) => update({ filterMode: v as SearchFilterMode })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(SEARCH_FILTER_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 리스트에 값을 보낼 때 적용되는 매칭 방식
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// text/number 상세 설정
|
||||
// ========================================
|
||||
|
||||
function TextDetailSettings({ cfg, update }: StepProps) {
|
||||
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -285,6 +547,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
|
|||
/>
|
||||
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter 키로 즉시 발행</Label>
|
||||
</div>
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
|||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// date 상세 설정
|
||||
// ========================================
|
||||
|
||||
const DATE_SELECTION_MODE_LABELS: Record<DateSelectionMode, string> = {
|
||||
single: "단일 날짜",
|
||||
range: "기간 선택",
|
||||
};
|
||||
|
||||
const CALENDAR_DISPLAY_LABELS: Record<CalendarDisplayMode, string> = {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">날짜 선택 모드</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v) => update({ dateSelectionMode: v as DateSelectionMode })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DATE_SELECTION_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
{mode === "single"
|
||||
? "캘린더에서 날짜 하나를 선택합니다"
|
||||
: "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">캘린더 표시 방식</Label>
|
||||
<Select
|
||||
value={calDisplay}
|
||||
onValueChange={(v) => update({ calendarDisplay: v as CalendarDisplayMode })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CALENDAR_DISPLAY_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
{calDisplay === "modal"
|
||||
? "터치 친화적인 큰 모달로 캘린더가 열립니다"
|
||||
: "입력란 아래에 작은 팝오버로 열립니다"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FilterConnectionSection
|
||||
cfg={cfg}
|
||||
update={update}
|
||||
showFieldName
|
||||
fixedFilterMode={autoFilterMode}
|
||||
allComponents={allComponents}
|
||||
connections={connections}
|
||||
componentId={componentId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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가 표시됩니다 (후속 구현)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -647,6 +997,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 중복 제거 (Distinct) */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="modal_distinct"
|
||||
checked={mc.distinct ?? false}
|
||||
onCheckedChange={(checked) => updateModal({ distinct: !!checked })}
|
||||
/>
|
||||
<Label htmlFor="modal_distinct" className="text-[10px]">중복 제거 (Distinct)</Label>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 검색창에 보일 값 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">검색창에 보일 값</Label>
|
||||
|
|
@ -694,6 +1059,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
|||
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ModalFilterTab, string> = {
|
|||
alphabet: "ABC",
|
||||
};
|
||||
|
||||
/** 검색 필터 방식 라벨 (설정 패널용) */
|
||||
export const SEARCH_FILTER_MODE_LABELS: Record<SearchFilterMode, string> = {
|
||||
contains: "포함",
|
||||
equals: "일치",
|
||||
starts_with: "시작",
|
||||
range: "범위",
|
||||
};
|
||||
|
||||
/** 한글 초성 추출 */
|
||||
const KOREAN_CONSONANTS = [
|
||||
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -50,7 +64,7 @@ export function ColumnCombobox({
|
|||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value || placeholder}
|
||||
<span className="truncate">{displayValue || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -61,7 +75,7 @@ export function ColumnCombobox({
|
|||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="컬럼명 검색..."
|
||||
placeholder="컬럼명 또는 한글명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
|
|
@ -88,8 +102,15 @@ export function ColumnCombobox({
|
|||
value === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.name}</span>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.name}</span>
|
||||
{col.comment && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
({col.comment})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.type}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface LoginResponse {
|
|||
token?: string;
|
||||
userInfo?: any;
|
||||
firstMenuPath?: string | null;
|
||||
popLandingPath?: string | null;
|
||||
};
|
||||
errorCode?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue