Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
commit
9fdcde3e8c
|
|
@ -31,6 +31,10 @@ dist/
|
||||||
build/
|
build/
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
**/backend/.gradle/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
.npm
|
.npm
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 정보 조회
|
* 메뉴 정보 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -1814,7 +1854,7 @@ export async function toggleMenuStatus(
|
||||||
|
|
||||||
// 현재 상태 및 회사 코드 조회
|
// 현재 상태 및 회사 코드 조회
|
||||||
const currentMenu = await queryOne<any>(
|
const currentMenu = await queryOne<any>(
|
||||||
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
|
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||||
[Number(menuId)]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { auditLogService } from "../services/auditLogService";
|
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||||
import { query } from "../database/db";
|
import { query } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
|
||||||
|
*/
|
||||||
|
export const createAuditLog = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
|
||||||
|
|
||||||
|
if (!action || !resourceType) {
|
||||||
|
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: action as AuditAction,
|
||||||
|
resourceType: resourceType as AuditResourceType,
|
||||||
|
resourceId: resourceId || undefined,
|
||||||
|
resourceName: resourceName || undefined,
|
||||||
|
tableName: tableName || undefined,
|
||||||
|
summary: summary || undefined,
|
||||||
|
changes: changes || undefined,
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("감사 로그 기록 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,29 +51,24 @@ export class AuthController {
|
||||||
|
|
||||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
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;
|
let firstMenuPath: string | null = null;
|
||||||
try {
|
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);
|
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||||
|
|
||||||
// 접근 가능한 첫 번째 메뉴 찾기
|
|
||||||
// 조건:
|
|
||||||
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
|
|
||||||
// 2. MENU_URL이 있고 비어있지 않음
|
|
||||||
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
|
|
||||||
const firstMenu = menuList.find((menu: any) => {
|
const firstMenu = menuList.find((menu: any) => {
|
||||||
const level = menu.lev || menu.level;
|
const level = menu.lev || menu.level;
|
||||||
const url = menu.menu_url || menu.url;
|
const url = menu.menu_url || menu.url;
|
||||||
|
|
||||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -94,6 +89,22 @@ export class AuthController {
|
||||||
useType: "접속",
|
useType: "접속",
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// 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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "로그인 성공",
|
message: "로그인 성공",
|
||||||
|
|
@ -101,6 +112,7 @@ export class AuthController {
|
||||||
userInfo,
|
userInfo,
|
||||||
token: loginResult.token,
|
token: loginResult.token,
|
||||||
firstMenuPath,
|
firstMenuPath,
|
||||||
|
popLandingPath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
|
||||||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ router.use(authenticateToken);
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
user?: {
|
user?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
userName: string;
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: String(value.valueId),
|
||||||
|
resourceName: input.valueLabel,
|
||||||
|
tableName: "category_values",
|
||||||
|
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
|
||||||
|
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: value,
|
data: value,
|
||||||
|
|
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const updatedBy = req.user?.userId;
|
const updatedBy = req.user?.userId;
|
||||||
|
|
||||||
|
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: valueId,
|
||||||
|
resourceName: value.valueLabel,
|
||||||
|
tableName: "category_values",
|
||||||
|
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
|
||||||
|
changes: {
|
||||||
|
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
|
||||||
|
after: input,
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: value,
|
data: value,
|
||||||
|
|
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||||
const { valueId } = req.params;
|
const { valueId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
|
|
@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: valueId,
|
||||||
|
resourceName: beforeValue?.valueLabel || valueId,
|
||||||
|
tableName: "category_values",
|
||||||
|
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
|
||||||
|
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "삭제되었습니다",
|
message: "삭제되었습니다",
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,20 @@ export class CommonCodeController {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: userId || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "CODE",
|
||||||
|
resourceId: codeValue,
|
||||||
|
resourceName: codeData.codeName || codeValue,
|
||||||
|
tableName: "code_info",
|
||||||
|
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
|
||||||
|
changes: { after: codeData },
|
||||||
|
ipAddress: getClientIp(req as any),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: code,
|
data: code,
|
||||||
|
|
@ -440,6 +454,19 @@ export class CommonCodeController {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "CODE",
|
||||||
|
resourceId: codeValue,
|
||||||
|
tableName: "code_info",
|
||||||
|
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
|
||||||
|
changes: { before: { categoryCode, codeValue } },
|
||||||
|
ipAddress: getClientIp(req as any),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "코드 삭제 성공",
|
message: "코드 삭제 성공",
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,19 @@ export class DDLController {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId,
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "TABLE",
|
||||||
|
resourceId: tableName,
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName,
|
||||||
|
summary: `테이블 "${tableName}" 삭제`,
|
||||||
|
ipAddress: getClientIp(req as any),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ router.post(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "CREATE",
|
action: "CREATE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: String(newRule.ruleId),
|
resourceId: String(newRule.ruleId),
|
||||||
|
|
@ -243,6 +244,7 @@ router.put(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "UPDATE",
|
action: "UPDATE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: ruleId,
|
resourceId: ruleId,
|
||||||
|
|
@ -285,6 +287,7 @@ router.delete(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "DELETE",
|
action: "DELETE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: ruleId,
|
resourceId: ruleId,
|
||||||
|
|
@ -521,6 +524,56 @@ router.post(
|
||||||
companyCode,
|
companyCode,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isUpdate = !!ruleConfig.ruleId;
|
||||||
|
|
||||||
|
const resetPeriodLabel: Record<string, string> = {
|
||||||
|
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
|
||||||
|
};
|
||||||
|
const partTypeLabel: Record<string, string> = {
|
||||||
|
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
|
||||||
|
};
|
||||||
|
const partsDescription = (ruleConfig.parts || [])
|
||||||
|
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||||
|
.map((p: any) => {
|
||||||
|
const type = partTypeLabel[p.partType] || p.partType;
|
||||||
|
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
|
||||||
|
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
|
||||||
|
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
|
||||||
|
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
|
||||||
|
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
|
||||||
|
return type;
|
||||||
|
})
|
||||||
|
.join(` ${ruleConfig.separator || "-"} `);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: isUpdate ? "UPDATE" : "CREATE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: String(savedRule.ruleId),
|
||||||
|
resourceName: ruleConfig.ruleName,
|
||||||
|
tableName: "numbering_rules",
|
||||||
|
summary: isUpdate
|
||||||
|
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
|
||||||
|
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||||
|
changes: {
|
||||||
|
after: {
|
||||||
|
규칙명: ruleConfig.ruleName,
|
||||||
|
적용테이블: ruleConfig.tableName || "(미지정)",
|
||||||
|
적용컬럼: ruleConfig.columnName || "(미지정)",
|
||||||
|
구분자: ruleConfig.separator || "-",
|
||||||
|
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
|
||||||
|
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
|
||||||
|
코드구성: partsDescription || "(파트 없음)",
|
||||||
|
파트수: (ruleConfig.parts || []).length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({ success: true, data: savedRule });
|
return res.json({ success: true, data: savedRule });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||||
|
|
@ -535,10 +588,25 @@ router.delete(
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: ruleId,
|
||||||
|
tableName: "numbering_rules",
|
||||||
|
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||||
|
|
|
||||||
|
|
@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
|
||||||
modalScreens: modalScreens || [],
|
modalScreens: modalScreens || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
auditLogService.log({
|
|
||||||
companyCode: targetCompanyCode || companyCode,
|
|
||||||
userId: userId || "",
|
|
||||||
userName: (req.user as any)?.userName || "",
|
|
||||||
action: "COPY",
|
|
||||||
resourceType: "SCREEN",
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: mainScreen?.screenName,
|
|
||||||
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
|
||||||
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
|
||||||
ipAddress: getClientIp(req),
|
|
||||||
requestPath: req.originalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
|
@ -663,20 +649,6 @@ export const copyScreen = async (
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
auditLogService.log({
|
|
||||||
companyCode,
|
|
||||||
userId: userId || "",
|
|
||||||
userName: (req.user as any)?.userName || "",
|
|
||||||
action: "COPY",
|
|
||||||
resourceType: "SCREEN",
|
|
||||||
resourceId: String(copiedScreen?.screenId || ""),
|
|
||||||
resourceName: screenName,
|
|
||||||
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
|
||||||
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
|
||||||
ipAddress: getClientIp(req),
|
|
||||||
requestPath: req.originalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: copiedScreen,
|
data: copiedScreen,
|
||||||
|
|
|
||||||
|
|
@ -963,6 +963,15 @@ export async function addTableData(
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||||
|
|
||||||
|
const systemFields = new Set([
|
||||||
|
"id", "created_date", "updated_date", "writer", "company_code",
|
||||||
|
"createdDate", "updatedDate", "companyCode",
|
||||||
|
]);
|
||||||
|
const auditData: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (!systemFields.has(k)) auditData[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode: req.user?.companyCode || "",
|
companyCode: req.user?.companyCode || "",
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
|
@ -973,7 +982,7 @@ export async function addTableData(
|
||||||
resourceName: tableName,
|
resourceName: tableName,
|
||||||
tableName,
|
tableName,
|
||||||
summary: `${tableName} 데이터 추가`,
|
summary: `${tableName} 데이터 추가`,
|
||||||
changes: { after: data },
|
changes: { after: auditData },
|
||||||
ipAddress: getClientIp(req),
|
ipAddress: getClientIp(req),
|
||||||
requestPath: req.originalUrl,
|
requestPath: req.originalUrl,
|
||||||
});
|
});
|
||||||
|
|
@ -1096,10 +1105,14 @@ export async function editTableData(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 변경된 필드만 추출
|
const systemFieldsForEdit = new Set([
|
||||||
|
"id", "created_date", "updated_date", "writer", "company_code",
|
||||||
|
"createdDate", "updatedDate", "companyCode",
|
||||||
|
]);
|
||||||
const changedBefore: Record<string, any> = {};
|
const changedBefore: Record<string, any> = {};
|
||||||
const changedAfter: Record<string, any> = {};
|
const changedAfter: Record<string, any> = {};
|
||||||
for (const key of Object.keys(updatedData)) {
|
for (const key of Object.keys(updatedData)) {
|
||||||
|
if (systemFieldsForEdit.has(key)) continue;
|
||||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||||
changedBefore[key] = originalData[key];
|
changedBefore[key] = originalData[key];
|
||||||
changedAfter[key] = updatedData[key];
|
changedAfter[key] = updatedData[key];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
getAdminMenus,
|
getAdminMenus,
|
||||||
getUserMenus,
|
getUserMenus,
|
||||||
|
getPopMenus,
|
||||||
getMenuInfo,
|
getMenuInfo,
|
||||||
saveMenu, // 메뉴 추가
|
saveMenu, // 메뉴 추가
|
||||||
updateMenu, // 메뉴 수정
|
updateMenu, // 메뉴 수정
|
||||||
|
|
@ -40,6 +41,7 @@ router.use(authenticateToken);
|
||||||
// 메뉴 관련 API
|
// 메뉴 관련 API
|
||||||
router.get("/menus", getAdminMenus);
|
router.get("/menus", getAdminMenus);
|
||||||
router.get("/user-menus", getUserMenus);
|
router.get("/user-menus", getUserMenus);
|
||||||
|
router.get("/pop-menus", getPopMenus);
|
||||||
router.get("/menus/:menuId", getMenuInfo);
|
router.get("/menus/:menuId", getMenuInfo);
|
||||||
router.post("/menus", saveMenu); // 메뉴 추가
|
router.post("/menus", saveMenu); // 메뉴 추가
|
||||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", authenticateToken, getAuditLogs);
|
router.get("/", authenticateToken, getAuditLogs);
|
||||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||||
|
router.post("/", authenticateToken, createAuditLog);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface AutoGenMappingInfo {
|
||||||
numberingRuleId: string;
|
numberingRuleId: string;
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
showResultModal?: boolean;
|
showResultModal?: boolean;
|
||||||
|
shareAcrossItems?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HiddenMappingInfo {
|
interface HiddenMappingInfo {
|
||||||
|
|
@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
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) {
|
for (const item of items) {
|
||||||
const columns: string[] = ["company_code"];
|
const columns: string[] = ["company_code"];
|
||||||
const values: unknown[] = [companyCode];
|
const values: unknown[] = [companyCode];
|
||||||
|
|
@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allAutoGen = [
|
|
||||||
...(fieldMapping?.autoGenMappings ?? []),
|
|
||||||
...(cardMapping?.autoGenMappings ?? []),
|
|
||||||
];
|
|
||||||
for (const ag of allAutoGen) {
|
for (const ag of allAutoGen) {
|
||||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||||
try {
|
|
||||||
const generatedCode = await numberingRuleService.allocateCode(
|
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||||
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
|
||||||
);
|
|
||||||
columns.push(`"${ag.targetColumn}"`);
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
values.push(generatedCode);
|
values.push(sharedCodes[ag.targetColumn]);
|
||||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
} else if (!ag.shareAcrossItems) {
|
||||||
} catch (err: any) {
|
try {
|
||||||
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!columns.includes('"created_date"')) {
|
||||||
|
columns.push('"created_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"updated_date"')) {
|
||||||
|
columns.push('"updated_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"writer"') && userId) {
|
||||||
|
columns.push('"writer"');
|
||||||
|
values.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
for (let i = 0; i < lookupValues.length; i++) {
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||||
|
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
[resolved, companyCode, lookupValues[i]],
|
[resolved, companyCode, lookupValues[i]],
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
|
|
||||||
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
||||||
|
|
||||||
|
const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
||||||
[thenVal, elseVal, companyCode, ...lookupValues],
|
[thenVal, elseVal, companyCode, ...lookupValues],
|
||||||
);
|
);
|
||||||
processedCount += lookupValues.length;
|
processedCount += lookupValues.length;
|
||||||
|
|
@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
if (valSource === "linked") {
|
if (valSource === "linked") {
|
||||||
value = item[task.sourceField ?? ""] ?? null;
|
value = item[task.sourceField ?? ""] ?? null;
|
||||||
} else {
|
} else {
|
||||||
value = task.fixedValue ?? "";
|
const raw = task.fixedValue ?? "";
|
||||||
|
if (raw === "__CURRENT_USER__") {
|
||||||
|
value = userId;
|
||||||
|
} else if (raw === "__CURRENT_TIME__") {
|
||||||
|
value = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
value = raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let setSql: string;
|
let setSql: string;
|
||||||
|
|
@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
setSql = `"${task.targetColumn}" = $1`;
|
setSql = `"${task.targetColumn}" = $1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
[value, companyCode, lookupValues[i]],
|
[value, companyCode, lookupValues[i]],
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
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) {
|
for (const item of items) {
|
||||||
const columns: string[] = ["company_code"];
|
const columns: string[] = ["company_code"];
|
||||||
const values: unknown[] = [companyCode];
|
const values: unknown[] = [companyCode];
|
||||||
|
|
@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
|
||||||
const allHidden = [
|
const allHidden = [
|
||||||
...(fieldMapping?.hiddenMappings ?? []),
|
...(fieldMapping?.hiddenMappings ?? []),
|
||||||
...(cardMapping?.hiddenMappings ?? []),
|
...(cardMapping?.hiddenMappings ?? []),
|
||||||
|
|
@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
|
||||||
const allAutoGen = [
|
|
||||||
...(fieldMapping?.autoGenMappings ?? []),
|
|
||||||
...(cardMapping?.autoGenMappings ?? []),
|
|
||||||
];
|
|
||||||
for (const ag of allAutoGen) {
|
for (const ag of allAutoGen) {
|
||||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||||
try {
|
|
||||||
const generatedCode = await numberingRuleService.allocateCode(
|
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
|
||||||
ag.numberingRuleId,
|
|
||||||
companyCode,
|
|
||||||
{ ...fieldValues, ...item },
|
|
||||||
);
|
|
||||||
columns.push(`"${ag.targetColumn}"`);
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
values.push(generatedCode);
|
values.push(sharedCodes[ag.targetColumn]);
|
||||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
} else if (!ag.shareAcrossItems) {
|
||||||
logger.info("[pop/execute-action] 채번 완료", {
|
try {
|
||||||
ruleId: ag.numberingRuleId,
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
targetColumn: ag.targetColumn,
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||||
generatedCode,
|
);
|
||||||
});
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
} catch (err: any) {
|
values.push(generatedCode);
|
||||||
logger.error("[pop/execute-action] 채번 실패", {
|
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||||
ruleId: ag.numberingRuleId,
|
logger.info("[pop/execute-action] 채번 완료", {
|
||||||
error: err.message,
|
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
|
||||||
});
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!columns.includes('"created_date"')) {
|
||||||
|
columns.push('"created_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"updated_date"')) {
|
||||||
|
columns.push('"updated_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"writer"') && userId) {
|
||||||
|
columns.push('"writer"');
|
||||||
|
values.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
|
@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
values.push(fieldValues[sourceField] ?? null);
|
values.push(fieldValues[sourceField] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!columns.includes('"created_date"')) {
|
||||||
|
columns.push('"created_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"updated_date"')) {
|
||||||
|
columns.push('"updated_date"');
|
||||||
|
values.push(new Date().toISOString());
|
||||||
|
}
|
||||||
|
if (!columns.includes('"writer"') && userId) {
|
||||||
|
columns.push('"writer"');
|
||||||
|
values.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
|
@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valueType === "fixed") {
|
if (valueType === "fixed") {
|
||||||
|
const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
||||||
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
||||||
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
||||||
processedCount += lookupValues.length;
|
processedCount += lookupValues.length;
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < lookupValues.length; i++) {
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
||||||
|
const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
[resolvedValue, companyCode, lookupValues[i]]
|
[resolvedValue, companyCode, lookupValues[i]]
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 정보 조회
|
* 메뉴 정보 조회
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export interface AuditLogParams {
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
company_name: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
user_name: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
|
|
@ -107,6 +108,7 @@ class AuditLogService {
|
||||||
*/
|
*/
|
||||||
async log(params: AuditLogParams): Promise<void> {
|
async log(params: AuditLogParams): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO system_audit_log
|
`INSERT INTO system_audit_log
|
||||||
(company_code, user_id, user_name, action, resource_type,
|
(company_code, user_id, user_name, action, resource_type,
|
||||||
|
|
@ -128,8 +130,9 @@ class AuditLogService {
|
||||||
params.requestPath || null,
|
params.requestPath || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
} catch (error: any) {
|
||||||
|
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,40 +189,40 @@ class AuditLogService {
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (!isSuperAdmin && filters.companyCode) {
|
if (!isSuperAdmin && filters.companyCode) {
|
||||||
conditions.push(`company_code = $${paramIndex++}`);
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||||
params.push(filters.companyCode);
|
params.push(filters.companyCode);
|
||||||
} else if (isSuperAdmin && filters.companyCode) {
|
} else if (isSuperAdmin && filters.companyCode) {
|
||||||
conditions.push(`company_code = $${paramIndex++}`);
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||||
params.push(filters.companyCode);
|
params.push(filters.companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.userId) {
|
if (filters.userId) {
|
||||||
conditions.push(`user_id = $${paramIndex++}`);
|
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||||
params.push(filters.userId);
|
params.push(filters.userId);
|
||||||
}
|
}
|
||||||
if (filters.resourceType) {
|
if (filters.resourceType) {
|
||||||
conditions.push(`resource_type = $${paramIndex++}`);
|
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||||
params.push(filters.resourceType);
|
params.push(filters.resourceType);
|
||||||
}
|
}
|
||||||
if (filters.action) {
|
if (filters.action) {
|
||||||
conditions.push(`action = $${paramIndex++}`);
|
conditions.push(`sal.action = $${paramIndex++}`);
|
||||||
params.push(filters.action);
|
params.push(filters.action);
|
||||||
}
|
}
|
||||||
if (filters.tableName) {
|
if (filters.tableName) {
|
||||||
conditions.push(`table_name = $${paramIndex++}`);
|
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||||
params.push(filters.tableName);
|
params.push(filters.tableName);
|
||||||
}
|
}
|
||||||
if (filters.dateFrom) {
|
if (filters.dateFrom) {
|
||||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||||
params.push(filters.dateFrom);
|
params.push(filters.dateFrom);
|
||||||
}
|
}
|
||||||
if (filters.dateTo) {
|
if (filters.dateTo) {
|
||||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||||
params.push(filters.dateTo);
|
params.push(filters.dateTo);
|
||||||
}
|
}
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
||||||
);
|
);
|
||||||
params.push(`%${filters.search}%`);
|
params.push(`%${filters.search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -233,14 +236,17 @@ class AuditLogService {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const countResult = await query<{ count: string }>(
|
const countResult = await query<{ count: string }>(
|
||||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
const total = parseInt(countResult[0].count, 10);
|
const total = parseInt(countResult[0].count, 10);
|
||||||
|
|
||||||
const data = await query<AuditLogEntry>(
|
const data = await query<AuditLogEntry>(
|
||||||
`SELECT * FROM system_audit_log ${whereClause}
|
`SELECT sal.*, ci.company_name
|
||||||
ORDER BY created_at DESC
|
FROM system_audit_log sal
|
||||||
|
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY sal.created_at DESC
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
[...params, limit, offset]
|
[...params, limit, offset]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4504,26 +4504,30 @@ export class TableManagementService {
|
||||||
|
|
||||||
const rawColumns = await query<any>(
|
const rawColumns = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
column_name as "columnName",
|
c.column_name as "columnName",
|
||||||
column_name as "displayName",
|
c.column_name as "displayName",
|
||||||
data_type as "dataType",
|
c.data_type as "dataType",
|
||||||
udt_name as "dbType",
|
c.udt_name as "dbType",
|
||||||
is_nullable as "isNullable",
|
c.is_nullable as "isNullable",
|
||||||
column_default as "defaultValue",
|
c.column_default as "defaultValue",
|
||||||
character_maximum_length as "maxLength",
|
c.character_maximum_length as "maxLength",
|
||||||
numeric_precision as "numericPrecision",
|
c.numeric_precision as "numericPrecision",
|
||||||
numeric_scale as "numericScale",
|
c.numeric_scale as "numericScale",
|
||||||
CASE
|
CASE
|
||||||
WHEN column_name IN (
|
WHEN c.column_name IN (
|
||||||
SELECT column_name FROM information_schema.key_column_usage
|
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
|
||||||
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
|
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
|
||||||
) THEN true
|
) THEN true
|
||||||
ELSE false
|
ELSE false
|
||||||
END as "isPrimaryKey"
|
END as "isPrimaryKey",
|
||||||
FROM information_schema.columns
|
col_description(
|
||||||
WHERE table_name = $1
|
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
|
||||||
AND table_schema = 'public'
|
c.ordinal_position
|
||||||
ORDER BY 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]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -4533,10 +4537,10 @@ export class TableManagementService {
|
||||||
displayName: col.displayName,
|
displayName: col.displayName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
dbType: col.dbType,
|
dbType: col.dbType,
|
||||||
webType: "text", // 기본값
|
webType: "text",
|
||||||
inputType: "direct",
|
inputType: "direct",
|
||||||
detailSettings: "{}",
|
detailSettings: "{}",
|
||||||
description: "", // 필수 필드 추가
|
description: col.columnComment || "",
|
||||||
isNullable: col.isNullable,
|
isNullable: col.isNullable,
|
||||||
isPrimaryKey: col.isPrimaryKey,
|
isPrimaryKey: col.isPrimaryKey,
|
||||||
defaultValue: col.defaultValue,
|
defaultValue: col.defaultValue,
|
||||||
|
|
@ -4547,6 +4551,7 @@ export class TableManagementService {
|
||||||
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
||||||
displayOrder: 0,
|
displayOrder: 0,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
|
columnComment: col.columnComment || "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { LoginFooter } from "@/components/auth/LoginFooter";
|
import { LoginFooter } from "@/components/auth/LoginFooter";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
|
const {
|
||||||
useLogin();
|
formData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
showPassword,
|
||||||
|
isPopMode,
|
||||||
|
handleInputChange,
|
||||||
|
handleLogin,
|
||||||
|
togglePasswordVisibility,
|
||||||
|
togglePopMode,
|
||||||
|
} = useLogin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
|
||||||
|
|
@ -19,9 +28,11 @@ export default function LoginPage() {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
showPassword={showPassword}
|
showPassword={showPassword}
|
||||||
|
isPopMode={isPopMode}
|
||||||
onInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
onSubmit={handleLogin}
|
onSubmit={handleLogin}
|
||||||
onTogglePassword={togglePasswordVisibility}
|
onTogglePassword={togglePasswordVisibility}
|
||||||
|
onTogglePop={togglePopMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoginFooter />
|
<LoginFooter />
|
||||||
|
|
|
||||||
|
|
@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record<
|
||||||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
|
||||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||||
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
|
@ -817,7 +815,7 @@ export default function AuditLogPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
{entry.company_code && entry.company_code !== "*" && (
|
{entry.company_code && entry.company_code !== "*" && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
[{entry.company_code}]
|
[{entry.company_name || entry.company_code}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -862,9 +860,11 @@ export default function AuditLogPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground text-xs">
|
<label className="text-muted-foreground text-xs">
|
||||||
회사코드
|
회사
|
||||||
</label>
|
</label>
|
||||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
<p className="font-medium">
|
||||||
|
{selectedEntry.company_name || selectedEntry.company_code}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground text-xs">
|
<label className="text-muted-foreground text-xs">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -285,14 +285,23 @@ function PopScreenViewPage() {
|
||||||
</div>
|
</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 화면 컨텐츠 */}
|
{/* POP 화면 컨텐츠 */}
|
||||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
<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
|
<div
|
||||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
|
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "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 [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
const [screenSearchText, setScreenSearchText] = useState("");
|
const [screenSearchText, setScreenSearchText] = useState("");
|
||||||
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
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 [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
|
||||||
const [dashboards, setDashboards] = useState<any[]>([]);
|
const [dashboards, setDashboards] = useState<any[]>([]);
|
||||||
|
|
@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
|
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 타입 변경 시 처리
|
// URL 타입 변경 시 처리
|
||||||
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
|
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
|
||||||
// console.log("🔄 URL 타입 변경:", {
|
// console.log("🔄 URL 타입 변경:", {
|
||||||
// from: urlType,
|
// from: urlType,
|
||||||
// to: type,
|
// to: type,
|
||||||
|
|
@ -208,36 +234,53 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setUrlType(type);
|
setUrlType(type);
|
||||||
|
|
||||||
if (type === "direct") {
|
if (type === "direct") {
|
||||||
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
|
setSelectedPopScreen(null);
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: "",
|
menuUrl: "",
|
||||||
screenCode: undefined, // 화면 코드도 함께 초기화
|
screenCode: undefined,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else if (type === "pop") {
|
||||||
// 화면 할당 모드로 변경 시
|
setSelectedScreen(null);
|
||||||
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
|
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) {
|
if (selectedScreen) {
|
||||||
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
|
|
||||||
// 현재 선택된 화면으로 URL 재생성
|
|
||||||
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
||||||
let screenUrl = `/screens/${actualScreenId}`;
|
let screenUrl = `/screens/${actualScreenId}`;
|
||||||
|
|
||||||
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
|
||||||
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||||
if (isAdminMenu) {
|
if (isAdminMenu) {
|
||||||
screenUrl += "?mode=admin";
|
screenUrl += "?mode=admin";
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: screenUrl,
|
menuUrl: screenUrl,
|
||||||
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
|
screenCode: selectedScreen.screenCode,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// 선택된 화면이 없으면 URL과 screenCode 초기화
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: "",
|
||||||
|
screenCode: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// dashboard
|
||||||
|
setSelectedScreen(null);
|
||||||
|
setSelectedPopScreen(null);
|
||||||
|
if (!selectedDashboard) {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: "",
|
menuUrl: "",
|
||||||
|
|
@ -297,8 +340,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
||||||
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
||||||
|
|
||||||
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
|
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
|
||||||
const isScreenUrl = menuUrl.startsWith("/screens/");
|
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
objid: menu.objid || menu.OBJID,
|
objid: menu.objid || menu.OBJID,
|
||||||
|
|
@ -360,10 +403,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}, 500);
|
}, 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/")) {
|
} else if (menuUrl.startsWith("/dashboard/")) {
|
||||||
setUrlType("dashboard");
|
setUrlType("dashboard");
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
|
|
||||||
} else {
|
} else {
|
||||||
setUrlType("direct");
|
setUrlType("direct");
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
|
|
@ -408,6 +472,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
} else {
|
} else {
|
||||||
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
|
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
|
||||||
setIsEdit(false);
|
setIsEdit(false);
|
||||||
|
setIsPopLanding(false);
|
||||||
|
|
||||||
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
|
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
|
||||||
let defaultMenuType = "1"; // 기본값은 사용자
|
let defaultMenuType = "1"; // 기본값은 사용자
|
||||||
|
|
@ -470,6 +535,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
}, [isOpen, formData.companyCode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -517,6 +607,22 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|
@ -533,16 +639,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setIsDashboardDropdownOpen(false);
|
setIsDashboardDropdownOpen(false);
|
||||||
setDashboardSearchText("");
|
setDashboardSearchText("");
|
||||||
}
|
}
|
||||||
|
if (!target.closest(".pop-screen-dropdown")) {
|
||||||
|
setIsPopScreenDropdownOpen(false);
|
||||||
|
setPopScreenSearchText("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
|
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
|
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
|
||||||
|
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -590,10 +700,17 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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 = {
|
const submitData = {
|
||||||
...formData,
|
...formData,
|
||||||
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
|
menuDesc: finalMenuDesc,
|
||||||
status: formData.status.toLowerCase(),
|
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>
|
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||||
|
|
||||||
{/* URL 타입 선택 */}
|
{/* 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">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="screen" id="screen" />
|
<RadioGroupItem value="screen" id="screen" />
|
||||||
<Label htmlFor="screen" className="cursor-pointer">
|
<Label htmlFor="screen" className="cursor-pointer">
|
||||||
|
|
@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
대시보드 할당
|
대시보드 할당
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</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">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="direct" id="direct" />
|
<RadioGroupItem value="direct" id="direct" />
|
||||||
<Label htmlFor="direct" className="cursor-pointer">
|
<Label htmlFor="direct" className="cursor-pointer">
|
||||||
|
|
@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
</div>
|
</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 직접 입력 */}
|
{/* URL 직접 입력 */}
|
||||||
{urlType === "direct" && (
|
{urlType === "direct" && (
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { LoginFormData } from "@/types/auth";
|
||||||
import { ErrorMessage } from "./ErrorMessage";
|
import { ErrorMessage } from "./ErrorMessage";
|
||||||
|
|
||||||
|
|
@ -11,9 +12,11 @@ interface LoginFormProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
|
isPopMode: boolean;
|
||||||
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
onTogglePassword: () => void;
|
onTogglePassword: () => void;
|
||||||
|
onTogglePop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,9 +27,11 @@ export function LoginForm({
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
showPassword,
|
showPassword,
|
||||||
|
isPopMode,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onTogglePassword,
|
onTogglePassword,
|
||||||
|
onTogglePop,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border shadow-lg">
|
<Card className="border shadow-lg">
|
||||||
|
|
@ -82,6 +87,19 @@ export function LoginForm({
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,12 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 바코드 리더 초기화
|
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setScannedCode("");
|
||||||
|
setError("");
|
||||||
|
setIsScanning(false);
|
||||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
{/* 스캔 가이드 오버레이 */}
|
{/* 스캔 가이드 오버레이 */}
|
||||||
{isScanning && (
|
{isScanning && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<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="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">
|
<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" />
|
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
||||||
|
|
@ -355,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
</Button>
|
</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 && (
|
{scannedCode && !autoSubmit && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||||
if (savedMode === "true") {
|
if (savedMode === "true") {
|
||||||
setContinuousMode(true);
|
setContinuousMode(true);
|
||||||
// console.log("🔄 연속 모드 복원: true");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// dataBinding: 테이블 선택 시 바인딩된 input의 formData를 자동 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalState.isOpen || !screenData?.components?.length) return;
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (!detail?.source || !detail?.data) return;
|
||||||
|
|
||||||
|
const bindingUpdates: Record<string, any> = {};
|
||||||
|
for (const comp of screenData.components) {
|
||||||
|
const db =
|
||||||
|
comp.componentConfig?.dataBinding ||
|
||||||
|
(comp as any).dataBinding;
|
||||||
|
if (!db?.sourceComponentId || !db?.sourceColumn) continue;
|
||||||
|
if (db.sourceComponentId !== detail.source) continue;
|
||||||
|
|
||||||
|
const colName = (comp as any).columnName || comp.componentConfig?.columnName;
|
||||||
|
if (!colName) continue;
|
||||||
|
|
||||||
|
const selectedRow = detail.data[0];
|
||||||
|
const value = selectedRow?.[db.sourceColumn] ?? "";
|
||||||
|
bindingUpdates[colName] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(bindingUpdates).length > 0) {
|
||||||
|
setFormData((prev) => ({ ...prev, ...bindingUpdates }));
|
||||||
|
formDataChangedRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("v2-table-selection", handler);
|
||||||
|
return () => window.removeEventListener("v2-table-selection", handler);
|
||||||
|
}, [modalState.isOpen, screenData?.components]);
|
||||||
|
|
||||||
// 화면의 실제 크기 계산 함수
|
// 화면의 실제 크기 계산 함수
|
||||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||||
if (components.length === 0) {
|
if (components.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ import {
|
||||||
User,
|
User,
|
||||||
Building2,
|
Building2,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
Monitor,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useProfile } from "@/hooks/useProfile";
|
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 { menuScreenApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -453,6 +454,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
e.dataTransfer.setData("text/plain", menuName);
|
e.dataTransfer.setData("text/plain", menuName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 renderMenu = (menu: any, level: number = 0) => {
|
||||||
const isExpanded = expandedMenus.has(menu.id);
|
const isExpanded = expandedMenus.has(menu.id);
|
||||||
const isLeaf = !menu.hasChildren;
|
const isLeaf = !menu.hasChildren;
|
||||||
|
|
@ -576,6 +602,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<FileCheck className="mr-2 h-4 w-4" />
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
<span>결재함</span>
|
<span>결재함</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
<span>POP 모드</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-1 py-0.5">
|
<div className="px-1 py-0.5">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
@ -748,6 +778,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<FileCheck className="mr-2 h-4 w-4" />
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
<span>결재함</span>
|
<span>결재함</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
<span>POP 모드</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ interface MainHeaderProps {
|
||||||
user: any;
|
user: any;
|
||||||
onSidebarToggle: () => void;
|
onSidebarToggle: () => void;
|
||||||
onProfileClick: () => void;
|
onProfileClick: () => void;
|
||||||
|
onPopModeClick?: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메인 헤더 컴포넌트
|
* 메인 헤더 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
|
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
|
||||||
return (
|
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">
|
<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">
|
<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 */}
|
{/* Right side - Admin Button + User Menu */}
|
||||||
<div className="flex h-8 items-center gap-2">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,20 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface UserDropdownProps {
|
interface UserDropdownProps {
|
||||||
user: any;
|
user: any;
|
||||||
onProfileClick: () => void;
|
onProfileClick: () => void;
|
||||||
|
onPopModeClick?: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 드롭다운 메뉴 컴포넌트
|
* 사용자 드롭다운 메뉴 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
? `${user.deptName}, ${user.positionName}`
|
? `${user.deptName}, ${user.positionName}`
|
||||||
: user.deptName || user.positionName || "부서 정보 없음"}
|
: user.deptName || user.positionName || "부서 정보 없음"}
|
||||||
</p>
|
</p>
|
||||||
{/* 사진 상태 표시 */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
<FileCheck className="mr-2 h-4 w-4" />
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
<span>결재함</span>
|
<span>결재함</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{onPopModeClick && (
|
||||||
|
<DropdownMenuItem onClick={onPopModeClick}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
<span>POP 모드</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
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";
|
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
interface DashboardHeaderProps {
|
||||||
|
|
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
|
||||||
company: CompanyInfo;
|
company: CompanyInfo;
|
||||||
onThemeToggle: () => void;
|
onThemeToggle: () => void;
|
||||||
onUserClick: () => void;
|
onUserClick: () => void;
|
||||||
|
onPcModeClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardHeader({
|
export function DashboardHeader({
|
||||||
|
|
@ -20,6 +21,7 @@ export function DashboardHeader({
|
||||||
company,
|
company,
|
||||||
onThemeToggle,
|
onThemeToggle,
|
||||||
onUserClick,
|
onUserClick,
|
||||||
|
onPcModeClick,
|
||||||
}: DashboardHeaderProps) {
|
}: DashboardHeaderProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
@ -81,6 +83,17 @@ export function DashboardHeader({
|
||||||
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
||||||
</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}>
|
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
||||||
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardHeader } from "./DashboardHeader";
|
import { DashboardHeader } from "./DashboardHeader";
|
||||||
import { NoticeBanner } from "./NoticeBanner";
|
import { NoticeBanner } from "./NoticeBanner";
|
||||||
import { KpiBar } from "./KpiBar";
|
import { KpiBar } from "./KpiBar";
|
||||||
|
|
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
|
||||||
import { ActivityList } from "./ActivityList";
|
import { ActivityList } from "./ActivityList";
|
||||||
import { NoticeList } from "./NoticeList";
|
import { NoticeList } from "./NoticeList";
|
||||||
import { DashboardFooter } from "./DashboardFooter";
|
import { DashboardFooter } from "./DashboardFooter";
|
||||||
|
import { MenuItem as DashboardMenuItem } from "./types";
|
||||||
|
import { menuApi, PopMenuItem } from "@/lib/api/menu";
|
||||||
import {
|
import {
|
||||||
KPI_ITEMS,
|
KPI_ITEMS,
|
||||||
MENU_ITEMS,
|
MENU_ITEMS,
|
||||||
|
|
@ -17,10 +20,31 @@ import {
|
||||||
} from "./data";
|
} from "./data";
|
||||||
import "./dashboard.css";
|
import "./dashboard.css";
|
||||||
|
|
||||||
export function PopDashboard() {
|
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
"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(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||||
if (savedTheme) {
|
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 handleThemeToggle = () => {
|
||||||
const newTheme = theme === "dark" ? "light" : "dark";
|
const newTheme = theme === "dark" ? "light" : "dark";
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
|
|
@ -40,6 +80,10 @@ export function PopDashboard() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePcModeClick = () => {
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
const handleActivityMore = () => {
|
const handleActivityMore = () => {
|
||||||
alert("전체 활동 내역 화면으로 이동합니다.");
|
alert("전체 활동 내역 화면으로 이동합니다.");
|
||||||
};
|
};
|
||||||
|
|
@ -58,13 +102,14 @@ export function PopDashboard() {
|
||||||
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
||||||
onThemeToggle={handleThemeToggle}
|
onThemeToggle={handleThemeToggle}
|
||||||
onUserClick={handleUserClick}
|
onUserClick={handleUserClick}
|
||||||
|
onPcModeClick={handlePcModeClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
||||||
|
|
||||||
<KpiBar items={KPI_ITEMS} />
|
<KpiBar items={KPI_ITEMS} />
|
||||||
|
|
||||||
<MenuGrid items={MENU_ITEMS} />
|
<MenuGrid items={menuItems} />
|
||||||
|
|
||||||
<div className="pop-dashboard-bottom-section">
|
<div className="pop-dashboard-bottom-section">
|
||||||
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export default function PopDesigner({
|
||||||
try {
|
try {
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
|
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
||||||
// v5 레이아웃 로드
|
// v5 레이아웃 로드
|
||||||
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
||||||
if (!loadedLayout.settings.gapPreset) {
|
if (!loadedLayout.settings.gapPreset) {
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,12 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||||
"pop-icon": "아이콘",
|
"pop-icon": "아이콘",
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-card-list-v2": "카드 목록 V2",
|
||||||
"pop-field": "필드",
|
"pop-field": "필드",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
"pop-search": "검색",
|
"pop-search": "검색",
|
||||||
|
"pop-status-bar": "상태 바",
|
||||||
"pop-list": "리스트",
|
"pop-list": "리스트",
|
||||||
"pop-indicator": "인디케이터",
|
"pop-indicator": "인디케이터",
|
||||||
"pop-scanner": "스캐너",
|
"pop-scanner": "스캐너",
|
||||||
|
|
@ -169,9 +171,7 @@ export default function ComponentEditorPanel({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{allComponents.map((comp) => {
|
{allComponents.map((comp) => {
|
||||||
const label = comp.label
|
const label = comp.label || comp.id;
|
||||||
|| COMPONENT_TYPE_LABELS[comp.type]
|
|
||||||
|| comp.type;
|
|
||||||
const isActive = comp.id === selectedComponentId;
|
const isActive = comp.id === selectedComponentId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
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, BarChart2 } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
description: "테이블 데이터를 카드 형태로 표시",
|
description: "테이블 데이터를 카드 형태로 표시",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-card-list-v2",
|
||||||
|
label: "카드 목록 V2",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "pop-button",
|
type: "pop-button",
|
||||||
label: "버튼",
|
label: "버튼",
|
||||||
|
|
@ -63,12 +69,30 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: Search,
|
icon: Search,
|
||||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-status-bar",
|
||||||
|
label: "상태 바",
|
||||||
|
icon: BarChart2,
|
||||||
|
description: "상태별 건수 대시보드 + 필터",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "pop-field",
|
type: "pop-field",
|
||||||
label: "입력 필드",
|
label: "입력 필드",
|
||||||
icon: TextCursorInput,
|
icon: TextCursorInput,
|
||||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-scanner",
|
||||||
|
label: "스캐너",
|
||||||
|
icon: ScanLine,
|
||||||
|
description: "바코드/QR 카메라 스캔",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pop-profile",
|
||||||
|
label: "프로필",
|
||||||
|
icon: UserCircle,
|
||||||
|
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import React from "react";
|
||||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -19,7 +18,6 @@ import {
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
PopComponentRegistry,
|
PopComponentRegistry,
|
||||||
type ComponentConnectionMeta,
|
|
||||||
} from "@/lib/registry/PopComponentRegistry";
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
|
@ -36,15 +34,6 @@ interface ConnectionEditorProps {
|
||||||
onRemoveConnection?: (connectionId: string) => void;
|
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
|
// ConnectionEditor
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -84,17 +73,13 @@ export default function ConnectionEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFilterSource = hasFilterSendable(meta);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{hasSendable && (
|
{hasSendable && (
|
||||||
<SendSection
|
<SendSection
|
||||||
component={component}
|
component={component}
|
||||||
meta={meta!}
|
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
outgoing={outgoing}
|
outgoing={outgoing}
|
||||||
isFilterSource={isFilterSource}
|
|
||||||
onAddConnection={onAddConnection}
|
onAddConnection={onAddConnection}
|
||||||
onUpdateConnection={onUpdateConnection}
|
onUpdateConnection={onUpdateConnection}
|
||||||
onRemoveConnection={onRemoveConnection}
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
|
@ -112,47 +97,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 {
|
interface SendSectionProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
meta: ComponentConnectionMeta;
|
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinitionV5[];
|
||||||
outgoing: PopDataConnection[];
|
outgoing: PopDataConnection[];
|
||||||
isFilterSource: boolean;
|
|
||||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onRemoveConnection?: (connectionId: string) => void;
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
|
@ -160,10 +112,8 @@ interface SendSectionProps {
|
||||||
|
|
||||||
function SendSection({
|
function SendSection({
|
||||||
component,
|
component,
|
||||||
meta,
|
|
||||||
allComponents,
|
allComponents,
|
||||||
outgoing,
|
outgoing,
|
||||||
isFilterSource,
|
|
||||||
onAddConnection,
|
onAddConnection,
|
||||||
onUpdateConnection,
|
onUpdateConnection,
|
||||||
onRemoveConnection,
|
onRemoveConnection,
|
||||||
|
|
@ -180,34 +130,20 @@ function SendSection({
|
||||||
{outgoing.map((conn) => (
|
{outgoing.map((conn) => (
|
||||||
<div key={conn.id}>
|
<div key={conn.id}>
|
||||||
{editingId === conn.id ? (
|
{editingId === conn.id ? (
|
||||||
isFilterSource ? (
|
<SimpleConnectionForm
|
||||||
<FilterConnectionForm
|
component={component}
|
||||||
component={component}
|
allComponents={allComponents}
|
||||||
meta={meta}
|
initial={conn}
|
||||||
allComponents={allComponents}
|
onSubmit={(data) => {
|
||||||
initial={conn}
|
onUpdateConnection?.(conn.id, data);
|
||||||
onSubmit={(data) => {
|
setEditingId(null);
|
||||||
onUpdateConnection?.(conn.id, data);
|
}}
|
||||||
setEditingId(null);
|
onCancel={() => setEditingId(null)}
|
||||||
}}
|
submitLabel="수정"
|
||||||
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-primary/10/50 px-3 py-2">
|
<div className="space-y-1 rounded border bg-primary/10 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<span className="flex-1 truncate text-xs">
|
<span className="flex-1 truncate text-xs">
|
||||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -225,27 +161,33 @@ function SendSection({
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{conn.filterConfig?.targetColumn && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{conn.filterConfig.targetColumn}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{conn.filterConfig.filterMode}
|
||||||
|
</span>
|
||||||
|
{conn.filterConfig.isSubTable && (
|
||||||
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
|
||||||
|
하위 테이블
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isFilterSource ? (
|
<SimpleConnectionForm
|
||||||
<FilterConnectionForm
|
component={component}
|
||||||
component={component}
|
allComponents={allComponents}
|
||||||
meta={meta}
|
onSubmit={(data) => onAddConnection?.(data)}
|
||||||
allComponents={allComponents}
|
submitLabel="연결 추가"
|
||||||
onSubmit={(data) => onAddConnection?.(data)}
|
/>
|
||||||
submitLabel="연결 추가"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SimpleConnectionForm
|
|
||||||
component={component}
|
|
||||||
allComponents={allComponents}
|
|
||||||
onSubmit={(data) => onAddConnection?.(data)}
|
|
||||||
submitLabel="연결 추가"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +205,19 @@ interface SimpleConnectionFormProps {
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
|
||||||
|
const cfg = comp.config as Record<string, unknown> | undefined;
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined;
|
||||||
|
if (grid?.cells) {
|
||||||
|
for (const cell of grid.cells) {
|
||||||
|
if (cell.timelineSource?.processTable) return cell.timelineSource.processTable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function SimpleConnectionForm({
|
function SimpleConnectionForm({
|
||||||
component,
|
component,
|
||||||
allComponents,
|
allComponents,
|
||||||
|
|
@ -274,6 +229,18 @@ function SimpleConnectionForm({
|
||||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||||
initial?.targetComponent || ""
|
initial?.targetComponent || ""
|
||||||
);
|
);
|
||||||
|
const [isSubTable, setIsSubTable] = React.useState(
|
||||||
|
initial?.filterConfig?.isSubTable || false
|
||||||
|
);
|
||||||
|
const [targetColumn, setTargetColumn] = React.useState(
|
||||||
|
initial?.filterConfig?.targetColumn || ""
|
||||||
|
);
|
||||||
|
const [filterMode, setFilterMode] = React.useState<string>(
|
||||||
|
initial?.filterConfig?.filterMode || "equals"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [subColumns, setSubColumns] = React.useState<string[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = React.useState(false);
|
||||||
|
|
||||||
const targetCandidates = allComponents.filter((c) => {
|
const targetCandidates = allComponents.filter((c) => {
|
||||||
if (c.id === component.id) return false;
|
if (c.id === component.id) return false;
|
||||||
|
|
@ -281,14 +248,39 @@ function SimpleConnectionForm({
|
||||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sourceReg = PopComponentRegistry.getComponent(component.type);
|
||||||
|
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||||
|
const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null;
|
||||||
|
const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value")
|
||||||
|
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
|
||||||
|
|
||||||
|
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isSubTable || !subTableName) {
|
||||||
|
setSubColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingColumns(true);
|
||||||
|
getTableColumns(subTableName)
|
||||||
|
.then((res) => {
|
||||||
|
const cols = res.success && res.data?.columns;
|
||||||
|
if (Array.isArray(cols)) {
|
||||||
|
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setSubColumns([]))
|
||||||
|
.finally(() => setLoadingColumns(false));
|
||||||
|
}, [isSubTable, subTableName]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedTargetId) return;
|
if (!selectedTargetId) return;
|
||||||
|
|
||||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
const tComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||||
const srcLabel = component.label || component.id;
|
const srcLabel = component.label || component.id;
|
||||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
const tgtLabel = tComp?.label || tComp?.id || "?";
|
||||||
|
|
||||||
onSubmit({
|
const conn: Omit<PopDataConnection, "id"> = {
|
||||||
sourceComponent: component.id,
|
sourceComponent: component.id,
|
||||||
sourceField: "",
|
sourceField: "",
|
||||||
sourceOutput: "_auto",
|
sourceOutput: "_auto",
|
||||||
|
|
@ -296,10 +288,23 @@ function SimpleConnectionForm({
|
||||||
targetField: "",
|
targetField: "",
|
||||||
targetInput: "_auto",
|
targetInput: "_auto",
|
||||||
label: `${srcLabel} → ${tgtLabel}`,
|
label: `${srcLabel} → ${tgtLabel}`,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (isFilterConnection && isSubTable && targetColumn) {
|
||||||
|
conn.filterConfig = {
|
||||||
|
targetColumn,
|
||||||
|
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
|
||||||
|
isSubTable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(conn);
|
||||||
|
|
||||||
if (!initial) {
|
if (!initial) {
|
||||||
setSelectedTargetId("");
|
setSelectedTargetId("");
|
||||||
|
setIsSubTable(false);
|
||||||
|
setTargetColumn("");
|
||||||
|
setFilterMode("equals");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -319,224 +324,12 @@ function SimpleConnectionForm({
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||||
<Select
|
|
||||||
value={selectedTargetId}
|
|
||||||
onValueChange={setSelectedTargetId}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 w-full text-xs"
|
|
||||||
disabled={!selectedTargetId}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 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
|
<Select
|
||||||
value={selectedTargetId}
|
value={selectedTargetId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setSelectedTargetId(v);
|
setSelectedTargetId(v);
|
||||||
setSelectedTargetInput("");
|
setIsSubTable(false);
|
||||||
setFilterColumns([]);
|
setTargetColumn("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
|
@ -552,109 +345,62 @@ function FilterConnectionForm({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{targetMeta && (
|
{isFilterConnection && selectedTargetId && subTableName && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2 rounded bg-muted/50 p-2">
|
||||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
<div className="flex items-center gap-2">
|
||||||
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
|
<Checkbox
|
||||||
<SelectTrigger className="h-7 text-xs">
|
id={`isSubTable_${component.id}`}
|
||||||
<SelectValue placeholder="선택" />
|
checked={isSubTable}
|
||||||
</SelectTrigger>
|
onCheckedChange={(v) => {
|
||||||
<SelectContent>
|
setIsSubTable(v === true);
|
||||||
{targetMeta.receivable.map((r) => (
|
if (!v) setTargetColumn("");
|
||||||
<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-muted 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-emerald-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-muted/80" />
|
|
||||||
)}
|
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
|
||||||
|
하위 테이블 기준으로 필터 ({subTableName})
|
||||||
{filterColumns.length > 0 && (
|
</label>
|
||||||
<p className="text-[10px] text-primary">
|
|
||||||
{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>
|
||||||
|
|
||||||
|
{isSubTable && (
|
||||||
|
<div className="space-y-2 pl-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">대상 컬럼</span>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<div className="flex items-center gap-1 py-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={targetColumn} onValueChange={setTargetColumn}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{subColumns.filter(Boolean).map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">비교 방식</span>
|
||||||
|
<Select value={filterMode} onValueChange={setFilterMode}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals" className="text-xs">일치 (equals)</SelectItem>
|
||||||
|
<SelectItem value="contains" className="text-xs">포함 (contains)</SelectItem>
|
||||||
|
<SelectItem value="starts_with" className="text-xs">시작 (starts_with)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -662,7 +408,7 @@ function FilterConnectionForm({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 w-full text-xs"
|
className="h-7 w-full text-xs"
|
||||||
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
|
disabled={!selectedTargetId}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||||
|
|
@ -722,32 +468,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}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-icon": "아이콘",
|
"pop-icon": "아이콘",
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-card-list-v2": "카드 목록 V2",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
"pop-search": "검색",
|
"pop-search": "검색",
|
||||||
|
"pop-status-bar": "상태 바",
|
||||||
"pop-field": "입력",
|
"pop-field": "입력",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-profile": "프로필",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||||
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
||||||
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
|
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* 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-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -33,6 +33,7 @@ export interface PopDataConnection {
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
targetColumns?: string[];
|
targetColumns?: string[];
|
||||||
filterMode: "equals" | "contains" | "starts_with" | "range";
|
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||||
|
isSubTable?: boolean;
|
||||||
};
|
};
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -358,10 +359,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
||||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||||
|
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-search": { colSpan: 4, rowSpan: 2 },
|
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||||
|
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
|
||||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||||
|
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||||
|
"pop-profile": { colSpan: 1, rowSpan: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1165,6 +1165,28 @@ export default function CopyScreenModal({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 그룹 복제 요약 감사 로그 1건 기록
|
||||||
|
try {
|
||||||
|
await apiClient.post("/audit-log", {
|
||||||
|
action: "COPY",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: String(sourceGroup.id),
|
||||||
|
resourceName: sourceGroup.group_name,
|
||||||
|
summary: `그룹 "${sourceGroup.group_name}" → "${rootGroupName}" 복제 (그룹 ${stats.groups}개, 화면 ${stats.screens}개)${finalCompanyCode !== sourceGroup.company_code ? ` [${sourceGroup.company_code} → ${finalCompanyCode}]` : ""}`,
|
||||||
|
changes: {
|
||||||
|
after: {
|
||||||
|
원본그룹: sourceGroup.group_name,
|
||||||
|
대상그룹: rootGroupName,
|
||||||
|
복제그룹수: stats.groups,
|
||||||
|
복제화면수: stats.screens,
|
||||||
|
대상회사: finalCompanyCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (auditError) {
|
||||||
|
console.warn("그룹 복제 감사 로그 기록 실패 (무시):", auditError);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ interface RealtimePreviewProps {
|
||||||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||||
|
|
||||||
// 버튼 액션을 위한 props
|
// 버튼 액션을 위한 props
|
||||||
|
|
@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||||
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
|
onNestedPanelSelect,
|
||||||
onResize, // 🆕 리사이즈 콜백
|
onResize, // 🆕 리사이즈 콜백
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 화면 다국어 컨텍스트
|
// 🆕 화면 다국어 컨텍스트
|
||||||
|
|
@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
selectedTabComponentId={selectedTabComponentId}
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
onSelectPanelComponent={onSelectPanelComponent}
|
onSelectPanelComponent={onSelectPanelComponent}
|
||||||
selectedPanelComponentId={selectedPanelComponentId}
|
selectedPanelComponentId={selectedPanelComponentId}
|
||||||
|
onNestedPanelSelect={onNestedPanelSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ interface ProcessedRow {
|
||||||
mainComponent?: ComponentData;
|
mainComponent?: ComponentData;
|
||||||
overlayComps: ComponentData[];
|
overlayComps: ComponentData[];
|
||||||
normalComps: ComponentData[];
|
normalComps: ComponentData[];
|
||||||
|
rowMinY?: number;
|
||||||
|
rowMaxBottom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FullWidthOverlayRow({
|
function FullWidthOverlayRow({
|
||||||
|
|
@ -299,6 +301,10 @@ export function ResponsiveGridRenderer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allComps = [...fullWidthComps, ...normalComps];
|
||||||
|
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
|
||||||
|
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
|
||||||
|
|
||||||
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
||||||
for (const fwComp of fullWidthComps) {
|
for (const fwComp of fullWidthComps) {
|
||||||
processedRows.push({
|
processedRows.push({
|
||||||
|
|
@ -306,6 +312,8 @@ export function ResponsiveGridRenderer({
|
||||||
mainComponent: fwComp,
|
mainComponent: fwComp,
|
||||||
overlayComps: normalComps,
|
overlayComps: normalComps,
|
||||||
normalComps: [],
|
normalComps: [],
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (fullWidthComps.length > 0) {
|
} else if (fullWidthComps.length > 0) {
|
||||||
|
|
@ -315,6 +323,8 @@ export function ResponsiveGridRenderer({
|
||||||
mainComponent: fwComp,
|
mainComponent: fwComp,
|
||||||
overlayComps: [],
|
overlayComps: [],
|
||||||
normalComps: [],
|
normalComps: [],
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -322,6 +332,8 @@ export function ResponsiveGridRenderer({
|
||||||
type: "normal",
|
type: "normal",
|
||||||
overlayComps: [],
|
overlayComps: [],
|
||||||
normalComps,
|
normalComps,
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -333,15 +345,26 @@ export function ResponsiveGridRenderer({
|
||||||
style={{ minHeight: "200px" }}
|
style={{ minHeight: "200px" }}
|
||||||
>
|
>
|
||||||
{processedRows.map((processedRow, rowIndex) => {
|
{processedRows.map((processedRow, rowIndex) => {
|
||||||
|
const rowMarginTop = (() => {
|
||||||
|
if (rowIndex === 0) return 0;
|
||||||
|
const prevRow = processedRows[rowIndex - 1];
|
||||||
|
const prevBottom = prevRow.rowMaxBottom ?? 0;
|
||||||
|
const currTop = processedRow.rowMinY ?? 0;
|
||||||
|
const designGap = currTop - prevBottom;
|
||||||
|
if (designGap <= 0) return 0;
|
||||||
|
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
|
||||||
|
})();
|
||||||
|
|
||||||
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
||||||
return (
|
return (
|
||||||
<FullWidthOverlayRow
|
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
|
||||||
key={`row-${rowIndex}`}
|
<FullWidthOverlayRow
|
||||||
main={processedRow.mainComponent}
|
main={processedRow.mainComponent}
|
||||||
overlayComps={processedRow.overlayComps}
|
overlayComps={processedRow.overlayComps}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
renderComponent={renderComponent}
|
renderComponent={renderComponent}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,7 +385,7 @@ export function ResponsiveGridRenderer({
|
||||||
allButtons && "justify-end px-2 py-1",
|
allButtons && "justify-end px-2 py-1",
|
||||||
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
||||||
)}
|
)}
|
||||||
style={{ gap: `${gap}px` }}
|
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
|
||||||
>
|
>
|
||||||
{normalComps.map((component) => {
|
{normalComps.map((component) => {
|
||||||
const typeId = getComponentTypeId(component);
|
const typeId = getComponentTypeId(component);
|
||||||
|
|
@ -409,10 +432,10 @@ export function ResponsiveGridRenderer({
|
||||||
flexGrow: percentWidth,
|
flexGrow: percentWidth,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: isMobile ? "100%" : undefined,
|
minWidth: isMobile ? "100%" : undefined,
|
||||||
minHeight: useFlexHeight ? "300px" : undefined,
|
minHeight: useFlexHeight ? "300px" : (component.size?.height
|
||||||
height: useFlexHeight ? "100%" : (component.size?.height
|
|
||||||
? `${component.size.height}px`
|
? `${component.size.height}px`
|
||||||
: "auto"),
|
: undefined),
|
||||||
|
height: useFlexHeight ? "100%" : "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderComponent(component)}
|
{renderComponent(component)}
|
||||||
|
|
|
||||||
|
|
@ -2861,9 +2861,190 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
|
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
|
||||||
|
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
|
||||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||||
if (tabsContainer) {
|
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||||
|
|
||||||
|
// 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리
|
||||||
|
const splitPanelFirst =
|
||||||
|
splitPanelContainer &&
|
||||||
|
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||||
|
|
||||||
|
if (splitPanelFirst && splitPanelContainer) {
|
||||||
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||||
|
if (containerId && panelSide) {
|
||||||
|
// 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기
|
||||||
|
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||||
|
let parentTabsId: string | null = null;
|
||||||
|
let parentTabId: string | null = null;
|
||||||
|
let parentSplitId: string | null = null;
|
||||||
|
let parentSplitSide: string | null = null;
|
||||||
|
|
||||||
|
if (!targetComponent) {
|
||||||
|
// 탭 안에 중첩된 분할패널 찾기
|
||||||
|
// top-level: overrides.type / overrides.tabs
|
||||||
|
// nested: componentType / componentConfig.tabs
|
||||||
|
for (const comp of layout.components) {
|
||||||
|
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
|
||||||
|
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
|
||||||
|
const tabs = compConfig.tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentTabsId = comp.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") {
|
||||||
|
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||||
|
const panelComps = compConfig[side]?.components || [];
|
||||||
|
for (const pc of panelComps) {
|
||||||
|
const pct = pc.componentType || pc.overrides?.type;
|
||||||
|
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||||
|
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentSplitId = comp.id;
|
||||||
|
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||||
|
parentTabsId = pc.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const compType = (targetComponent as any)?.componentType;
|
||||||
|
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||||
|
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const cs1 = window.getComputedStyle(splitPanelContainer);
|
||||||
|
const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel;
|
||||||
|
|
||||||
|
const componentType = component.id || component.componentType || "v2-text-display";
|
||||||
|
|
||||||
|
const newPanelComponent = {
|
||||||
|
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
componentType: componentType,
|
||||||
|
label: component.name || component.label || "새 컴포넌트",
|
||||||
|
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||||
|
size: component.defaultSize || { width: 200, height: 100 },
|
||||||
|
componentConfig: component.defaultConfig || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPanelConfig = {
|
||||||
|
...panelConfig,
|
||||||
|
components: [...currentComponents, newPanelComponent],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSplitPanel = {
|
||||||
|
...targetComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: updatedPanelConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let newLayout;
|
||||||
|
if (parentTabsId && parentTabId) {
|
||||||
|
// 중첩: (최상위 분할패널 →) 탭 → 분할패널
|
||||||
|
const updateTabsComponent = (tabsComp: any) => {
|
||||||
|
const ck = tabsComp.componentConfig ? "componentConfig" : "overrides";
|
||||||
|
const cfg = tabsComp[ck] || {};
|
||||||
|
const tabs = cfg.tabs || [];
|
||||||
|
return {
|
||||||
|
...tabsComp,
|
||||||
|
[ck]: {
|
||||||
|
...cfg,
|
||||||
|
tabs: tabs.map((tab: any) =>
|
||||||
|
tab.id === parentTabId
|
||||||
|
? {
|
||||||
|
...tab,
|
||||||
|
components: (tab.components || []).map((c: any) =>
|
||||||
|
c.id === containerId ? updatedSplitPanel : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: tab,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parentSplitId && parentSplitSide) {
|
||||||
|
// 최상위 분할패널 → 탭 → 분할패널
|
||||||
|
const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => {
|
||||||
|
if (c.id === parentSplitId) {
|
||||||
|
const sc = (c as any).componentConfig || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
componentConfig: {
|
||||||
|
...sc,
|
||||||
|
[pKey]: {
|
||||||
|
...sc[pKey],
|
||||||
|
components: (sc[pKey]?.components || []).map((pc: any) =>
|
||||||
|
pc.id === parentTabsId ? updateTabsComponent(pc) : pc,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 최상위 탭 → 분할패널
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) =>
|
||||||
|
c.id === parentTabsId ? updateTabsComponent(c) : c,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 최상위 분할패널
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabsContainer && !splitPanelFirst) {
|
||||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||||
if (containerId && activeTabId) {
|
if (containerId && activeTabId) {
|
||||||
|
|
@ -3004,69 +3185,6 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
|
|
||||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
|
||||||
if (splitPanelContainer) {
|
|
||||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
|
||||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
|
||||||
if (containerId && panelSide) {
|
|
||||||
const targetComponent = layout.components.find((c) => c.id === containerId);
|
|
||||||
const compType = (targetComponent as any)?.componentType;
|
|
||||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
|
||||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
|
||||||
const currentComponents = panelConfig.components || [];
|
|
||||||
|
|
||||||
// 드롭 위치 계산
|
|
||||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
|
||||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
|
||||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
|
||||||
|
|
||||||
// 새 컴포넌트 생성
|
|
||||||
const componentType = component.id || component.componentType || "v2-text-display";
|
|
||||||
|
|
||||||
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
|
|
||||||
componentId: component.id,
|
|
||||||
componentType: componentType,
|
|
||||||
panelSide: panelSide,
|
|
||||||
dropPosition: { x: dropX, y: dropY },
|
|
||||||
});
|
|
||||||
|
|
||||||
const newPanelComponent = {
|
|
||||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
componentType: componentType,
|
|
||||||
label: component.name || component.label || "새 컴포넌트",
|
|
||||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
|
||||||
size: component.defaultSize || { width: 200, height: 100 },
|
|
||||||
componentConfig: component.defaultConfig || {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedPanelConfig = {
|
|
||||||
...panelConfig,
|
|
||||||
components: [...currentComponents, newPanelComponent],
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedComponent = {
|
|
||||||
...targetComponent,
|
|
||||||
componentConfig: {
|
|
||||||
...currentConfig,
|
|
||||||
[panelKey]: updatedPanelConfig,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const newLayout = {
|
|
||||||
...layout,
|
|
||||||
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
|
|
||||||
};
|
|
||||||
|
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
|
||||||
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
|
||||||
return; // 분할 패널 처리 완료
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
@ -3378,15 +3496,12 @@ export default function ScreenDesigner({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const dragData = e.dataTransfer.getData("application/json");
|
const dragData = e.dataTransfer.getData("application/json");
|
||||||
// console.log("🎯 드롭 이벤트:", { dragData });
|
|
||||||
if (!dragData) {
|
if (!dragData) {
|
||||||
// console.log("❌ 드래그 데이터가 없습니다");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(dragData);
|
const parsedData = JSON.parse(dragData);
|
||||||
// console.log("📋 파싱된 데이터:", parsedData);
|
|
||||||
|
|
||||||
// 템플릿 드래그인 경우
|
// 템플릿 드래그인 경우
|
||||||
if (parsedData.type === "template") {
|
if (parsedData.type === "template") {
|
||||||
|
|
@ -3480,9 +3595,225 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
|
||||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||||
if (tabsContainer && type === "column" && column) {
|
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||||
|
|
||||||
|
// 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선
|
||||||
|
const splitPanelFirst =
|
||||||
|
splitPanelContainer &&
|
||||||
|
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||||
|
|
||||||
|
// 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리)
|
||||||
|
if (splitPanelFirst && splitPanelContainer && type === "column" && column) {
|
||||||
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
|
let panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||||
|
|
||||||
|
// panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별
|
||||||
|
if (!panelSide) {
|
||||||
|
const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10);
|
||||||
|
const containerRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const relativeX = e.clientX - containerRect.left;
|
||||||
|
const splitPoint = containerRect.width * (splitRatio / 100);
|
||||||
|
panelSide = relativeX < splitPoint ? "left" : "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerId && panelSide) {
|
||||||
|
// 최상위에서 찾기
|
||||||
|
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||||
|
let parentTabsId: string | null = null;
|
||||||
|
let parentTabId: string | null = null;
|
||||||
|
let parentSplitId: string | null = null;
|
||||||
|
let parentSplitSide: string | null = null;
|
||||||
|
|
||||||
|
if (!targetComponent) {
|
||||||
|
// 탭 안 중첩 분할패널 찾기
|
||||||
|
// top-level 컴포넌트: overrides.type / overrides.tabs
|
||||||
|
// nested 컴포넌트: componentType / componentConfig.tabs
|
||||||
|
for (const comp of layout.components) {
|
||||||
|
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
|
||||||
|
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||||
|
const tabs = compConfig.tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentTabsId = comp.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
// 분할패널 → 탭 → 분할패널 중첩
|
||||||
|
if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") {
|
||||||
|
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||||
|
const panelComps = compConfig[side]?.components || [];
|
||||||
|
for (const pc of panelComps) {
|
||||||
|
const pct = pc.componentType || pc.overrides?.type;
|
||||||
|
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||||
|
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||||
|
if (found) {
|
||||||
|
targetComponent = found;
|
||||||
|
parentSplitId = comp.id;
|
||||||
|
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||||
|
parentTabsId = pc.id;
|
||||||
|
parentTabId = tab.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
if (targetComponent) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compType = (targetComponent as any)?.componentType;
|
||||||
|
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||||
|
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const computedStyle = window.getComputedStyle(splitPanelContainer);
|
||||||
|
const padLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||||
|
const padTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||||
|
const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel;
|
||||||
|
|
||||||
|
const v2Mapping = createV2ConfigFromColumn({
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.columnLabel,
|
||||||
|
codeCategory: column.codeCategory,
|
||||||
|
inputType: column.inputType,
|
||||||
|
required: column.required,
|
||||||
|
detailSettings: column.detailSettings,
|
||||||
|
referenceTable: column.referenceTable,
|
||||||
|
referenceColumn: column.referenceColumn,
|
||||||
|
displayColumn: column.displayColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPanelComponent = {
|
||||||
|
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
componentType: v2Mapping.componentType,
|
||||||
|
label: column.columnLabel || column.columnName,
|
||||||
|
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||||
|
size: { width: 200, height: 36 },
|
||||||
|
inputType: column.inputType || column.widgetType,
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
componentConfig: {
|
||||||
|
...v2Mapping.componentConfig,
|
||||||
|
columnName: column.columnName,
|
||||||
|
tableName: column.tableName,
|
||||||
|
inputType: column.inputType || column.widgetType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSplitPanel = {
|
||||||
|
...targetComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
displayMode: "custom",
|
||||||
|
components: [...currentComponents, newPanelComponent],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let newLayout;
|
||||||
|
|
||||||
|
if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) {
|
||||||
|
// 분할패널 → 탭 → 분할패널 3중 중첩
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => {
|
||||||
|
if (c.id !== parentSplitId) return c;
|
||||||
|
const sc = (c as any).componentConfig || {};
|
||||||
|
const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
componentConfig: {
|
||||||
|
...sc,
|
||||||
|
[pk]: {
|
||||||
|
...sc[pk],
|
||||||
|
components: (sc[pk]?.components || []).map((pc: any) => {
|
||||||
|
if (pc.id !== parentTabsId) return pc;
|
||||||
|
return {
|
||||||
|
...pc,
|
||||||
|
componentConfig: {
|
||||||
|
...pc.componentConfig,
|
||||||
|
tabs: (pc.componentConfig?.tabs || []).map((tab: any) => {
|
||||||
|
if (tab.id !== parentTabId) return tab;
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
components: (tab.components || []).map((tc: any) =>
|
||||||
|
tc.id === containerId ? updatedSplitPanel : tc,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else if (parentTabsId && parentTabId) {
|
||||||
|
// 탭 → 분할패널 2중 중첩
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => {
|
||||||
|
if (c.id !== parentTabsId) return c;
|
||||||
|
// top-level은 overrides, nested는 componentConfig
|
||||||
|
const configKey = (c as any).componentConfig ? "componentConfig" : "overrides";
|
||||||
|
const tabsConfig = (c as any)[configKey] || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
[configKey]: {
|
||||||
|
...tabsConfig,
|
||||||
|
tabs: (tabsConfig.tabs || []).map((tab: any) => {
|
||||||
|
if (tab.id !== parentTabId) return tab;
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
components: (tab.components || []).map((tc: any) =>
|
||||||
|
tc.id === containerId ? updatedSplitPanel : tc,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 최상위 분할패널
|
||||||
|
newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("컬럼이 분할패널에 추가되었습니다");
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
||||||
|
if (tabsContainer && !splitPanelFirst && type === "column" && column) {
|
||||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||||
if (containerId && activeTabId) {
|
if (containerId && activeTabId) {
|
||||||
|
|
@ -3648,9 +3979,8 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
|
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
|
||||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
|
||||||
if (splitPanelContainer && type === "column" && column) {
|
|
||||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||||
if (containerId && panelSide) {
|
if (containerId && panelSide) {
|
||||||
|
|
@ -3662,12 +3992,11 @@ export default function ScreenDesigner({
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
const currentComponents = panelConfig.components || [];
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
// 드롭 위치 계산
|
|
||||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
const cs2 = window.getComputedStyle(splitPanelContainer);
|
||||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel;
|
||||||
|
|
||||||
// V2 컴포넌트 매핑 사용
|
|
||||||
const v2Mapping = createV2ConfigFromColumn({
|
const v2Mapping = createV2ConfigFromColumn({
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
|
|
@ -6415,15 +6744,6 @@ export default function ScreenDesigner({
|
||||||
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
console.log("🔧 updatePanelComponentProperty 호출:", {
|
|
||||||
componentId,
|
|
||||||
path,
|
|
||||||
value,
|
|
||||||
splitPanelId,
|
|
||||||
panelSide,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
|
|
||||||
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
||||||
const result = JSON.parse(JSON.stringify(obj));
|
const result = JSON.parse(JSON.stringify(obj));
|
||||||
const parts = pathStr.split(".");
|
const parts = pathStr.split(".");
|
||||||
|
|
@ -6440,9 +6760,27 @@ export default function ScreenDesigner({
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 중첩 구조 포함 분할패널 찾기 헬퍼
|
||||||
|
const findSplitPanelInLayout = (components: any[]): { found: any; path: "top" | "nested"; parentTabId?: string; parentTabTabId?: string } | null => {
|
||||||
|
const direct = components.find((c) => c.id === splitPanelId);
|
||||||
|
if (direct) return { found: direct, path: "top" };
|
||||||
|
for (const comp of components) {
|
||||||
|
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||||
|
for (const tab of (cfg.tabs || [])) {
|
||||||
|
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||||
|
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
setLayout((prevLayout) => {
|
setLayout((prevLayout) => {
|
||||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
const result = findSplitPanelInLayout(prevLayout.components);
|
||||||
if (!splitPanelComponent) return prevLayout;
|
if (!result) return prevLayout;
|
||||||
|
const splitPanelComponent = result.found;
|
||||||
|
|
||||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
|
@ -6478,17 +6816,37 @@ export default function ScreenDesigner({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// selectedPanelComponentInfo 업데이트
|
|
||||||
setSelectedPanelComponentInfo((prev) =>
|
setSelectedPanelComponentInfo((prev) =>
|
||||||
prev ? { ...prev, component: updatedComp } : null,
|
prev ? { ...prev, component: updatedComp } : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
// 중첩 구조 반영
|
||||||
...prevLayout,
|
const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => {
|
||||||
components: prevLayout.components.map((c) =>
|
if (info.path === "top") {
|
||||||
c.id === splitPanelId ? updatedComponent : c,
|
return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) };
|
||||||
),
|
}
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c: any) => {
|
||||||
|
if (c.id !== info.parentTabId) return c;
|
||||||
|
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||||
|
const cfg = c[cfgKey] || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
[cfgKey]: {
|
||||||
|
...cfg,
|
||||||
|
tabs: (cfg.tabs || []).map((t: any) =>
|
||||||
|
t.id === info.parentTabTabId
|
||||||
|
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return applyUpdatedSplitPanel(prevLayout, updatedComponent, result);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -6498,8 +6856,23 @@ export default function ScreenDesigner({
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
setLayout((prevLayout) => {
|
setLayout((prevLayout) => {
|
||||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
const findResult = (() => {
|
||||||
if (!splitPanelComponent) return prevLayout;
|
const direct = prevLayout.components.find((c: any) => c.id === splitPanelId);
|
||||||
|
if (direct) return { found: direct, path: "top" as const };
|
||||||
|
for (const comp of prevLayout.components) {
|
||||||
|
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||||
|
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||||
|
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||||
|
for (const tab of (cfg.tabs || [])) {
|
||||||
|
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||||
|
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
if (!findResult) return prevLayout;
|
||||||
|
const splitPanelComponent = findResult.found;
|
||||||
|
|
||||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
|
@ -6520,11 +6893,27 @@ export default function ScreenDesigner({
|
||||||
|
|
||||||
setSelectedPanelComponentInfo(null);
|
setSelectedPanelComponentInfo(null);
|
||||||
|
|
||||||
|
if (findResult.path === "top") {
|
||||||
|
return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...prevLayout,
|
...prevLayout,
|
||||||
components: prevLayout.components.map((c) =>
|
components: prevLayout.components.map((c: any) => {
|
||||||
c.id === splitPanelId ? updatedComponent : c,
|
if (c.id !== findResult.parentTabId) return c;
|
||||||
),
|
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||||
|
const cfg = c[cfgKey] || {};
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
[cfgKey]: {
|
||||||
|
...cfg,
|
||||||
|
tabs: (cfg.tabs || []).map((t: any) =>
|
||||||
|
t.id === findResult.parentTabTabId
|
||||||
|
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updatedComponent : tc) }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -7128,6 +7517,7 @@ export default function ScreenDesigner({
|
||||||
onSelectPanelComponent={(panelSide, compId, comp) =>
|
onSelectPanelComponent={(panelSide, compId, comp) =>
|
||||||
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
||||||
}
|
}
|
||||||
|
onNestedPanelSelect={handleSelectPanelComponent}
|
||||||
selectedPanelComponentId={
|
selectedPanelComponentId={
|
||||||
selectedPanelComponentInfo?.splitPanelId === component.id
|
selectedPanelComponentInfo?.splitPanelId === component.id
|
||||||
? selectedPanelComponentInfo.componentId
|
? selectedPanelComponentInfo.componentId
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||||
}
|
}
|
||||||
|
if (componentId === "v2-input") {
|
||||||
|
extraProps.allComponents = allComponents;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={selectedComponent.id} className="space-y-4">
|
<div key={selectedComponent.id} className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -430,28 +430,28 @@ export function TabsWidget({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveGridRenderer
|
<ResponsiveGridRenderer
|
||||||
components={componentDataList}
|
components={componentDataList}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
canvasHeight={canvasHeight}
|
canvasHeight={canvasHeight}
|
||||||
renderComponent={(comp) => (
|
renderComponent={(comp) => (
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
{...restProps}
|
{...restProps}
|
||||||
component={comp}
|
component={comp}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
selectedRowsData={localSelectedRowsData}
|
selectedRowsData={localSelectedRowsData}
|
||||||
onSelectedRowsChange={handleSelectedRowsChange}
|
onSelectedRowsChange={handleSelectedRowsChange}
|
||||||
parentTabId={tab.id}
|
parentTabId={tab.id}
|
||||||
parentTabsComponentId={component.id}
|
parentTabsComponentId={component.id}
|
||||||
{...(screenInfoMap[tab.id]
|
{...(screenInfoMap[tab.id]
|
||||||
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
interface V2InputConfigPanelProps {
|
interface V2InputConfigPanelProps {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
onChange: (config: Record<string, any>) => void;
|
onChange: (config: Record<string, any>) => void;
|
||||||
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
|
menuObjid?: number;
|
||||||
|
allComponents?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
|
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid, allComponents = [] }) => {
|
||||||
// 채번 규칙 목록 상태
|
// 채번 규칙 목록 상태
|
||||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [loadingRules, setLoadingRules] = useState(false);
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
|
@ -483,73 +484,202 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
|
|
||||||
{/* 데이터 바인딩 설정 */}
|
{/* 데이터 바인딩 설정 */}
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="space-y-2">
|
<DataBindingSection config={config} onChange={onChange} allComponents={allComponents} />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="dataBindingEnabled"
|
|
||||||
checked={!!config.dataBinding?.sourceComponentId}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
updateConfig("dataBinding", {
|
|
||||||
sourceComponentId: config.dataBinding?.sourceComponentId || "",
|
|
||||||
sourceColumn: config.dataBinding?.sourceColumn || "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateConfig("dataBinding", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
|
||||||
테이블 선택 데이터 바인딩
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{config.dataBinding && (
|
|
||||||
<div className="space-y-2 rounded border p-2">
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">소스 컴포넌트 ID</Label>
|
|
||||||
<Input
|
|
||||||
value={config.dataBinding?.sourceComponentId || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateConfig("dataBinding", {
|
|
||||||
...config.dataBinding,
|
|
||||||
sourceComponentId: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="예: tbl_items"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
같은 화면 내 v2-table-list 컴포넌트의 ID
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">소스 컬럼명</Label>
|
|
||||||
<Input
|
|
||||||
value={config.dataBinding?.sourceColumn || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
updateConfig("dataBinding", {
|
|
||||||
...config.dataBinding,
|
|
||||||
sourceColumn: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="예: item_number"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
선택된 행에서 가져올 컬럼명
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 바인딩 설정 섹션
|
||||||
|
* 같은 화면의 v2-table-list 컴포넌트를 자동 감지하여 드롭다운으로 표시
|
||||||
|
*/
|
||||||
|
function DataBindingSection({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
allComponents,
|
||||||
|
}: {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
allComponents: any[];
|
||||||
|
}) {
|
||||||
|
const [tableColumns, setTableColumns] = useState<string[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
|
||||||
|
// 같은 화면의 v2-table-list 컴포넌트만 필터링
|
||||||
|
const tableListComponents = React.useMemo(() => {
|
||||||
|
return allComponents.filter((comp) => {
|
||||||
|
const type =
|
||||||
|
comp.componentType ||
|
||||||
|
comp.widgetType ||
|
||||||
|
comp.componentConfig?.type ||
|
||||||
|
(comp.url && comp.url.split("/").pop());
|
||||||
|
return type === "v2-table-list";
|
||||||
|
});
|
||||||
|
}, [allComponents]);
|
||||||
|
|
||||||
|
// 선택된 테이블 컴포넌트의 테이블명 추출
|
||||||
|
const selectedTableComponent = React.useMemo(() => {
|
||||||
|
if (!config.dataBinding?.sourceComponentId) return null;
|
||||||
|
return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId);
|
||||||
|
}, [tableListComponents, config.dataBinding?.sourceComponentId]);
|
||||||
|
|
||||||
|
const selectedTableName = React.useMemo(() => {
|
||||||
|
if (!selectedTableComponent) return null;
|
||||||
|
return (
|
||||||
|
selectedTableComponent.componentConfig?.selectedTable ||
|
||||||
|
selectedTableComponent.selectedTable ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [selectedTableComponent]);
|
||||||
|
|
||||||
|
// 선택된 테이블의 컬럼 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTableName) {
|
||||||
|
setTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadColumns = async () => {
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||||
|
const response = await tableTypeApi.getTableTypeColumns(selectedTableName);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const cols = response.data.map((col: any) => col.column_name).filter(Boolean);
|
||||||
|
setTableColumns(cols);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 컬럼 정보를 못 가져오면 테이블 컴포넌트의 columns에서 추출
|
||||||
|
const configColumns = selectedTableComponent?.componentConfig?.columns;
|
||||||
|
if (Array.isArray(configColumns)) {
|
||||||
|
setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [selectedTableName, selectedTableComponent]);
|
||||||
|
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
onChange({ ...config, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="dataBindingEnabled"
|
||||||
|
checked={!!config.dataBinding?.sourceComponentId}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
const firstTable = tableListComponents[0];
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
sourceComponentId: firstTable?.id || "",
|
||||||
|
sourceColumn: "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateConfig("dataBinding", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
||||||
|
테이블 선택 데이터 바인딩
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.dataBinding && (
|
||||||
|
<div className="space-y-2 rounded border p-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 소스 테이블 컴포넌트 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">소스 테이블</Label>
|
||||||
|
{tableListComponents.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-amber-500">이 화면에 v2-table-list 컴포넌트가 없습니다</p>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={config.dataBinding?.sourceComponentId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
...config.dataBinding,
|
||||||
|
sourceComponentId: value,
|
||||||
|
sourceColumn: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableListComponents.map((comp) => {
|
||||||
|
const tblName =
|
||||||
|
comp.componentConfig?.selectedTable || comp.selectedTable || "";
|
||||||
|
const label = comp.componentConfig?.label || comp.label || comp.id;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
{label} ({tblName || comp.id})
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 컬럼 선택 */}
|
||||||
|
{config.dataBinding?.sourceComponentId && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">가져올 컬럼</Label>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground">컬럼 로딩 중...</p>
|
||||||
|
) : tableColumns.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={config.dataBinding?.sourceColumn || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
...config.dataBinding,
|
||||||
|
sourceColumn: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="컬럼명 직접 입력"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">컬럼 정보를 불러올 수 없어 직접 입력</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={config.dataBinding?.sourceColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateConfig("dataBinding", {
|
||||||
|
...config.dataBinding,
|
||||||
|
sourceColumn: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col}>
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default V2InputConfigPanel;
|
export default V2InputConfigPanel;
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,9 @@ export async function executeTaskList(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "custom-event":
|
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 ?? {});
|
publish(task.eventName, task.eventPayload ?? {});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
|
||||||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||||
import {
|
import {
|
||||||
PopComponentRegistry,
|
PopComponentRegistry,
|
||||||
type ConnectionMetaItem,
|
|
||||||
} from "@/lib/registry/PopComponentRegistry";
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
|
|
||||||
interface UseConnectionResolverOptions {
|
interface UseConnectionResolverOptions {
|
||||||
|
|
@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
|
||||||
componentTypes?: Map<string, string>;
|
componentTypes?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoMatchPair {
|
||||||
|
sourceKey: string;
|
||||||
|
targetKey: string;
|
||||||
|
isFilter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다.
|
* 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다.
|
||||||
* 규칙: category="event"이고 key가 동일한 쌍
|
* 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭)
|
||||||
|
* 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭)
|
||||||
*/
|
*/
|
||||||
function getAutoMatchPairs(
|
function getAutoMatchPairs(
|
||||||
sourceType: string,
|
sourceType: string,
|
||||||
targetType: string
|
targetType: string
|
||||||
): { sourceKey: string; targetKey: string }[] {
|
): AutoMatchPair[] {
|
||||||
const sourceDef = PopComponentRegistry.getComponent(sourceType);
|
const sourceDef = PopComponentRegistry.getComponent(sourceType);
|
||||||
const targetDef = PopComponentRegistry.getComponent(targetType);
|
const targetDef = PopComponentRegistry.getComponent(targetType);
|
||||||
|
|
||||||
|
|
@ -44,14 +50,18 @@ function getAutoMatchPairs(
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pairs: { sourceKey: string; targetKey: string }[] = [];
|
const pairs: AutoMatchPair[] = [];
|
||||||
|
|
||||||
for (const s of sourceDef.connectionMeta.sendable) {
|
for (const s of sourceDef.connectionMeta.sendable) {
|
||||||
if (s.category !== "event") continue;
|
|
||||||
for (const r of targetDef.connectionMeta.receivable) {
|
for (const r of targetDef.connectionMeta.receivable) {
|
||||||
if (r.category !== "event") continue;
|
if (s.category === "event" && r.category === "event" && s.key === r.key) {
|
||||||
if (s.key === r.key) {
|
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
|
||||||
pairs.push({ sourceKey: s.key, targetKey: r.key });
|
}
|
||||||
|
if (s.type === "filter_value" && r.type === "filter_value") {
|
||||||
|
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
|
||||||
|
}
|
||||||
|
if (s.type === "all_rows" && r.type === "all_rows") {
|
||||||
|
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,10 +103,30 @@ export function useConnectionResolver({
|
||||||
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
|
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
|
||||||
|
|
||||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||||
publish(targetEvent, {
|
if (pair.isFilter) {
|
||||||
value: payload,
|
const data = payload as Record<string, unknown> | null;
|
||||||
_connectionId: conn.id,
|
const fieldName = data?.fieldName as string | undefined;
|
||||||
});
|
const filterColumns = data?.filterColumns as string[] | undefined;
|
||||||
|
const filterMode = (data?.filterMode as string) || "contains";
|
||||||
|
// conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용
|
||||||
|
const effectiveColumn = conn.filterConfig?.targetColumn || fieldName;
|
||||||
|
const effectiveMode = conn.filterConfig?.filterMode || filterMode;
|
||||||
|
const baseFilterConfig = effectiveColumn
|
||||||
|
? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode }
|
||||||
|
: conn.filterConfig;
|
||||||
|
publish(targetEvent, {
|
||||||
|
value: payload,
|
||||||
|
filterConfig: conn.filterConfig?.isSubTable
|
||||||
|
? { ...baseFilterConfig, isSubTable: true }
|
||||||
|
: baseFilterConfig,
|
||||||
|
_connectionId: conn.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
publish(targetEvent, {
|
||||||
|
value: payload,
|
||||||
|
_connectionId: conn.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
unsubscribers.push(unsub);
|
unsubscribers.push(unsub);
|
||||||
}
|
}
|
||||||
|
|
@ -121,13 +151,22 @@ export function useConnectionResolver({
|
||||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||||
|
|
||||||
const enrichedPayload = {
|
let resolvedFilterConfig = conn.filterConfig;
|
||||||
value: payload,
|
if (!resolvedFilterConfig) {
|
||||||
filterConfig: conn.filterConfig,
|
const data = payload as Record<string, unknown> | null;
|
||||||
_connectionId: conn.id,
|
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);
|
unsubscribers.push(unsub);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,21 @@ export const useLogin = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
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`;
|
document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`;
|
||||||
|
|
||||||
// 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트
|
if (isPopMode) {
|
||||||
const firstMenuPath = result.data?.firstMenuPath;
|
const popPath = result.data?.popLandingPath;
|
||||||
|
if (popPath) {
|
||||||
if (firstMenuPath) {
|
router.push(popPath);
|
||||||
// 접근 가능한 메뉴가 있으면 해당 메뉴로 이동
|
} else {
|
||||||
console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath);
|
setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요.");
|
||||||
router.push(firstMenuPath);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 접근 가능한 메뉴가 없으면 메인 페이지로 이동
|
const firstMenuPath = result.data?.firstMenuPath;
|
||||||
console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동");
|
if (firstMenuPath) {
|
||||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
router.push(firstMenuPath);
|
||||||
|
} else {
|
||||||
|
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 로그인 실패
|
// 로그인 실패
|
||||||
|
|
@ -165,7 +185,7 @@ export const useLogin = () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[formData, validateForm, apiCall, router],
|
[formData, validateForm, apiCall, router, isPopMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 기존 인증 상태 확인
|
// 컴포넌트 마운트 시 기존 인증 상태 확인
|
||||||
|
|
@ -179,10 +199,12 @@ export const useLogin = () => {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
showPassword,
|
showPassword,
|
||||||
|
isPopMode,
|
||||||
|
|
||||||
// 액션
|
// 액션
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleLogin,
|
handleLogin,
|
||||||
togglePasswordVisibility,
|
togglePasswordVisibility,
|
||||||
|
togglePopMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { apiClient } from "./client";
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
company_name: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
user_name: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,23 @@ export interface ApiResponse<T> {
|
||||||
errorCode?: string;
|
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 = {
|
export const menuApi = {
|
||||||
// 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
|
// 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
|
||||||
getAdminMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
getAdminMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||||
|
|
@ -96,6 +113,12 @@ export const menuApi = {
|
||||||
return response.data;
|
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[]>> => {
|
getAdminMenusForManagement: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||||
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });
|
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,8 @@ export interface DynamicComponentRendererProps {
|
||||||
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
selectedPanelComponentId?: string;
|
selectedPanelComponentId?: string;
|
||||||
|
// 중첩된 분할패널 내부 컴포넌트 선택 콜백 (탭 안의 분할패널)
|
||||||
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
||||||
// 테이블 새로고침 키
|
// 테이블 새로고침 키
|
||||||
|
|
@ -868,6 +870,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
onSelectPanelComponent: props.onSelectPanelComponent,
|
onSelectPanelComponent: props.onSelectPanelComponent,
|
||||||
selectedPanelComponentId: props.selectedPanelComponentId,
|
selectedPanelComponentId: props.selectedPanelComponentId,
|
||||||
|
onNestedPanelSelect: props.onNestedPanelSelect,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface PopComponentDefinition {
|
||||||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||||
defaultProps?: Record<string, any>;
|
defaultProps?: Record<string, any>;
|
||||||
connectionMeta?: ComponentConnectionMeta;
|
connectionMeta?: ComponentConnectionMeta;
|
||||||
|
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
|
||||||
// POP 전용 속성
|
// POP 전용 속성
|
||||||
touchOptimized?: boolean;
|
touchOptimized?: boolean;
|
||||||
minTouchArea?: number;
|
minTouchArea?: number;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
GeneratedLocation,
|
GeneratedLocation,
|
||||||
RackStructureContext,
|
RackStructureContext,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
|
|
||||||
|
|
||||||
// 기존 위치 데이터 타입
|
// 기존 위치 데이터 타입
|
||||||
interface ExistingLocation {
|
interface ExistingLocation {
|
||||||
|
|
@ -513,27 +512,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
return { totalLocations, totalRows, maxLevel };
|
return { totalLocations, totalRows, maxLevel };
|
||||||
}, [conditions]);
|
}, [conditions]);
|
||||||
|
|
||||||
// 위치 코드 생성 (패턴 기반)
|
// 위치 코드 생성
|
||||||
const generateLocationCode = useCallback(
|
const generateLocationCode = useCallback(
|
||||||
(row: number, level: number): { code: string; name: string } => {
|
(row: number, level: number): { code: string; name: string } => {
|
||||||
const vars = {
|
const warehouseCode = context?.warehouseCode || "WH001";
|
||||||
warehouse: context?.warehouseCode || "WH001",
|
const floor = context?.floor || "1";
|
||||||
warehouseName: context?.warehouseName || "",
|
const zone = context?.zone || "A";
|
||||||
floor: context?.floor || "1",
|
|
||||||
zone: context?.zone || "A",
|
|
||||||
row,
|
|
||||||
level,
|
|
||||||
};
|
|
||||||
|
|
||||||
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
|
// 코드 생성 (예: WH001-1층D구역-01-1)
|
||||||
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
|
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||||
|
|
||||||
return {
|
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
||||||
code: applyLocationPattern(codePattern, vars),
|
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||||
name: applyLocationPattern(namePattern, vars),
|
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||||
};
|
|
||||||
|
return { code, name };
|
||||||
},
|
},
|
||||||
[context, config.codePattern, config.namePattern],
|
[context],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 미리보기 생성
|
// 미리보기 생성
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
@ -12,47 +12,6 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
|
|
||||||
|
|
||||||
// 패턴 미리보기 서브 컴포넌트
|
|
||||||
const PatternPreview: React.FC<{
|
|
||||||
codePattern?: string;
|
|
||||||
namePattern?: string;
|
|
||||||
}> = ({ codePattern, namePattern }) => {
|
|
||||||
const sampleVars = {
|
|
||||||
warehouse: "WH002",
|
|
||||||
warehouseName: "2창고",
|
|
||||||
floor: "2층",
|
|
||||||
zone: "A구역",
|
|
||||||
row: 1,
|
|
||||||
level: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewCode = useMemo(
|
|
||||||
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
|
|
||||||
[codePattern],
|
|
||||||
);
|
|
||||||
const previewName = useMemo(
|
|
||||||
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
|
|
||||||
[namePattern],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
|
|
||||||
<div className="mb-1.5 text-[10px] font-medium text-primary">미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="w-14 shrink-0 text-muted-foreground">위치코드:</span>
|
|
||||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="w-14 shrink-0 text-muted-foreground">위치명:</span>
|
|
||||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RackStructureConfigPanelProps {
|
interface RackStructureConfigPanelProps {
|
||||||
config: RackStructureComponentConfig;
|
config: RackStructureComponentConfig;
|
||||||
|
|
@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 위치코드 패턴 설정 */}
|
|
||||||
<div className="space-y-3 border-t pt-3">
|
|
||||||
<div className="text-sm font-medium text-foreground">위치코드/위치명 패턴</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 위치코드 패턴 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">위치코드 패턴</Label>
|
|
||||||
<Input
|
|
||||||
value={config.codePattern || ""}
|
|
||||||
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
|
|
||||||
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
|
|
||||||
className="h-8 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
||||||
비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 위치명 패턴 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">위치명 패턴</Label>
|
|
||||||
<Input
|
|
||||||
value={config.namePattern || ""}
|
|
||||||
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
|
|
||||||
placeholder="{zone}-{row:02}열-{level}단"
|
|
||||||
className="h-8 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
||||||
비워두면 기본값: {"{zone}-{row:02}열-{level}단"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 실시간 미리보기 */}
|
|
||||||
<PatternPreview
|
|
||||||
codePattern={config.codePattern}
|
|
||||||
namePattern={config.namePattern}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 사용 가능한 변수 목록 */}
|
|
||||||
<div className="rounded-md border bg-muted/50 p-2">
|
|
||||||
<div className="mb-1 text-[10px] font-medium text-foreground">사용 가능한 변수</div>
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
|
||||||
{PATTERN_VARIABLES.map((v) => (
|
|
||||||
<div key={v.token} className="flex items-center gap-1 text-[10px]">
|
|
||||||
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
|
|
||||||
<span className="text-muted-foreground">{v.description}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 제한 설정 */}
|
{/* 제한 설정 */}
|
||||||
<div className="space-y-3 border-t pt-3">
|
<div className="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// rack-structure는 v2-rack-structure의 patternUtils를 재사용
|
|
||||||
export {
|
|
||||||
applyLocationPattern,
|
|
||||||
DEFAULT_CODE_PATTERN,
|
|
||||||
DEFAULT_NAME_PATTERN,
|
|
||||||
PATTERN_VARIABLES,
|
|
||||||
} from "../v2-rack-structure/patternUtils";
|
|
||||||
|
|
@ -5,71 +5,45 @@ import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponent
|
||||||
import { V2InputDefinition } from "./index";
|
import { V2InputDefinition } from "./index";
|
||||||
import { V2Input } from "@/components/v2/V2Input";
|
import { V2Input } from "@/components/v2/V2Input";
|
||||||
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
||||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* dataBinding이 설정된 v2-input을 위한 wrapper
|
* dataBinding이 설정된 v2-input을 위한 wrapper
|
||||||
* v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여
|
* v2-table-list의 선택 이벤트를 window CustomEvent로 수신하여
|
||||||
* 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영
|
* 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영
|
||||||
*/
|
*/
|
||||||
function DataBindingWrapper({
|
function DataBindingWrapper({
|
||||||
dataBinding,
|
dataBinding,
|
||||||
columnName,
|
columnName,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
isInteractive,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
dataBinding: { sourceComponentId: string; sourceColumn: string };
|
dataBinding: { sourceComponentId: string; sourceColumn: string };
|
||||||
columnName: string;
|
columnName: string;
|
||||||
onFormDataChange?: (field: string, value: any) => void;
|
onFormDataChange?: (field: string, value: any) => void;
|
||||||
isInteractive?: boolean;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const lastBoundValueRef = useRef<any>(null);
|
const lastBoundValueRef = useRef<any>(null);
|
||||||
|
const onFormDataChangeRef = useRef(onFormDataChange);
|
||||||
|
onFormDataChangeRef.current = onFormDataChange;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
|
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
|
||||||
|
|
||||||
console.log("[DataBinding] 구독 시작:", {
|
const handler = (e: Event) => {
|
||||||
sourceComponentId: dataBinding.sourceComponentId,
|
const detail = (e as CustomEvent).detail;
|
||||||
sourceColumn: dataBinding.sourceColumn,
|
if (!detail || detail.source !== dataBinding.sourceComponentId) return;
|
||||||
targetColumn: columnName,
|
|
||||||
isInteractive,
|
|
||||||
hasOnFormDataChange: !!onFormDataChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
|
const selectedRow = detail.data?.[0];
|
||||||
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
|
const value = selectedRow?.[dataBinding.sourceColumn] ?? "";
|
||||||
payloadSource: payload.source,
|
if (value !== lastBoundValueRef.current) {
|
||||||
expectedSource: dataBinding.sourceComponentId,
|
lastBoundValueRef.current = value;
|
||||||
dataLength: payload.data?.length,
|
onFormDataChangeRef.current?.(columnName, value);
|
||||||
match: payload.source === dataBinding.sourceComponentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (payload.source !== dataBinding.sourceComponentId) return;
|
|
||||||
|
|
||||||
const selectedData = payload.data;
|
|
||||||
if (selectedData && selectedData.length > 0) {
|
|
||||||
const value = selectedData[0][dataBinding.sourceColumn];
|
|
||||||
console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName });
|
|
||||||
if (value !== lastBoundValueRef.current) {
|
|
||||||
lastBoundValueRef.current = value;
|
|
||||||
if (onFormDataChange && columnName) {
|
|
||||||
onFormDataChange(columnName, value ?? "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (lastBoundValueRef.current !== null) {
|
|
||||||
lastBoundValueRef.current = null;
|
|
||||||
if (onFormDataChange && columnName) {
|
|
||||||
onFormDataChange(columnName, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
return () => unsubscribe();
|
window.addEventListener("v2-table-selection", handler);
|
||||||
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
|
return () => window.removeEventListener("v2-table-selection", handler);
|
||||||
|
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
@ -102,18 +76,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
|
||||||
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
|
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
|
||||||
|
|
||||||
if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) {
|
|
||||||
console.log("[V2InputRenderer] dataBinding 탐색:", {
|
|
||||||
componentId: component.id,
|
|
||||||
columnName,
|
|
||||||
configKeys: Object.keys(config),
|
|
||||||
configDataBinding: config.dataBinding,
|
|
||||||
componentDataBinding: (component as any).dataBinding,
|
|
||||||
nestedDataBinding: config.componentConfig?.dataBinding,
|
|
||||||
finalDataBinding: dataBinding,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputElement = (
|
const inputElement = (
|
||||||
<V2Input
|
<V2Input
|
||||||
id={component.id}
|
id={component.id}
|
||||||
|
|
@ -153,7 +115,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
dataBinding={dataBinding}
|
dataBinding={dataBinding}
|
||||||
columnName={columnName}
|
columnName={columnName}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
isInteractive={isInteractive}
|
|
||||||
>
|
>
|
||||||
{inputElement}
|
{inputElement}
|
||||||
</DataBindingWrapper>
|
</DataBindingWrapper>
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,12 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded-lg border border-border bg-white shadow-sm">
|
<div className="border-border relative rounded-lg border bg-white shadow-sm">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between rounded-t-lg bg-primary px-4 py-2 text-white">
|
<div className="bg-primary flex items-center justify-between rounded-t-lg px-4 py-2 text-white">
|
||||||
<span className="font-medium">조건 {index + 1}</span>
|
<span className="font-medium">조건 {index + 1}</span>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<button onClick={() => onRemove(condition.id)} className="rounded p-1 transition-colors hover:bg-primary/90">
|
<button onClick={() => onRemove(condition.id)} className="hover:bg-primary/90 rounded p-1 transition-colors">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -112,7 +112,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
{/* 열 범위 */}
|
{/* 열 범위 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
<label className="text-foreground mb-1 block text-xs font-medium">
|
||||||
열 범위 <span className="text-destructive">*</span>
|
열 범위 <span className="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -140,7 +140,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-20">
|
||||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
<label className="text-foreground mb-1 block text-xs font-medium">
|
||||||
단 수 <span className="text-destructive">*</span>
|
단 수 <span className="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -157,7 +157,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 계산 결과 */}
|
{/* 계산 결과 */}
|
||||||
<div className="rounded-md bg-primary/10 px-3 py-2 text-center text-sm text-primary">
|
<div className="bg-primary/10 text-primary rounded-md px-3 py-2 text-center text-sm">
|
||||||
{locationCount > 0 ? (
|
{locationCount > 0 ? (
|
||||||
<>
|
<>
|
||||||
{localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "}
|
{localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "}
|
||||||
|
|
@ -627,7 +627,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-primary/50" />렉 라인 구조 설정
|
<div className="to-primary/50 h-4 w-1 rounded bg-gradient-to-b from-green-500" />렉 라인 구조 설정
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -720,8 +720,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
|
|
||||||
{/* 기존 데이터 존재 알림 */}
|
{/* 기존 데이터 존재 알림 */}
|
||||||
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
|
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
|
||||||
<Alert className="mb-4 border-primary/20 bg-primary/10">
|
<Alert className="border-primary/20 bg-primary/10 mb-4">
|
||||||
<AlertCircle className="h-4 w-4 text-primary" />
|
<AlertCircle className="text-primary h-4 w-4" />
|
||||||
<AlertDescription className="text-primary">
|
<AlertDescription className="text-primary">
|
||||||
해당 창고/층/구역에 <strong>{existingLocations.length}개</strong>의 위치가 이미 등록되어 있습니다.
|
해당 창고/층/구역에 <strong>{existingLocations.length}개</strong>의 위치가 이미 등록되어 있습니다.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
@ -730,9 +730,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
|
|
||||||
{/* 현재 매핑된 값 표시 */}
|
{/* 현재 매핑된 값 표시 */}
|
||||||
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
|
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-muted p-3">
|
<div className="bg-muted mb-4 flex flex-wrap gap-2 rounded-lg p-3">
|
||||||
{(context.warehouseCode || context.warehouseName) && (
|
{(context.warehouseCode || context.warehouseName) && (
|
||||||
<span className="rounded bg-primary/10 px-2 py-1 text-xs text-primary">
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs">
|
||||||
창고: {context.warehouseName || context.warehouseCode}
|
창고: {context.warehouseName || context.warehouseCode}
|
||||||
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
|
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -749,28 +749,28 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{context.status && (
|
{context.status && (
|
||||||
<span className="rounded bg-muted/80 px-2 py-1 text-xs text-foreground">상태: {context.status}</span>
|
<span className="bg-muted/80 text-foreground rounded px-2 py-1 text-xs">상태: {context.status}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 안내 메시지 */}
|
{/* 안내 메시지 */}
|
||||||
<div className="mb-4 rounded-lg bg-primary/10 p-4">
|
<div className="bg-primary/10 mb-4 rounded-lg p-4">
|
||||||
<ol className="space-y-1 text-sm text-primary">
|
<ol className="text-primary space-y-1 text-sm">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
|
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
|
||||||
1
|
1
|
||||||
</span>
|
</span>
|
||||||
조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
|
조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
|
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
|
||||||
2
|
2
|
||||||
</span>
|
</span>
|
||||||
각 조건마다 열 범위와 단 수를 입력하세요
|
각 조건마다 열 범위와 단 수를 입력하세요
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
|
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
|
||||||
3
|
3
|
||||||
</span>
|
</span>
|
||||||
예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
|
예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
|
||||||
|
|
@ -780,9 +780,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
|
|
||||||
{/* 조건 목록 또는 빈 상태 */}
|
{/* 조건 목록 또는 빈 상태 */}
|
||||||
{conditions.length === 0 ? (
|
{conditions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-12">
|
<div className="border-border flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
|
||||||
<div className="mb-4 text-6xl text-muted-foreground/50">📦</div>
|
<div className="text-muted-foreground/50 mb-4 text-6xl">📦</div>
|
||||||
<p className="mb-4 text-muted-foreground">조건을 추가하여 렉 구조를 설정하세요</p>
|
<p className="text-muted-foreground mb-4">조건을 추가하여 렉 구조를 설정하세요</p>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<Button onClick={addCondition} className="gap-1">
|
<Button onClick={addCondition} className="gap-1">
|
||||||
<Plus className="h-4 w-4" />첫 번째 조건 추가
|
<Plus className="h-4 w-4" />첫 번째 조건 추가
|
||||||
|
|
@ -833,15 +833,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
{config.showStatistics && (
|
{config.showStatistics && (
|
||||||
<div className="mb-4 grid grid-cols-3 gap-4">
|
<div className="mb-4 grid grid-cols-3 gap-4">
|
||||||
<div className="rounded-lg border bg-white p-4 text-center">
|
<div className="rounded-lg border bg-white p-4 text-center">
|
||||||
<div className="text-sm text-muted-foreground">총 위치</div>
|
<div className="text-muted-foreground text-sm">총 위치</div>
|
||||||
<div className="text-2xl font-bold">{statistics.totalLocations}개</div>
|
<div className="text-2xl font-bold">{statistics.totalLocations}개</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-white p-4 text-center">
|
<div className="rounded-lg border bg-white p-4 text-center">
|
||||||
<div className="text-sm text-muted-foreground">열 수</div>
|
<div className="text-muted-foreground text-sm">열 수</div>
|
||||||
<div className="text-2xl font-bold">{statistics.totalRows}개</div>
|
<div className="text-2xl font-bold">{statistics.totalRows}개</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-white p-4 text-center">
|
<div className="rounded-lg border bg-white p-4 text-center">
|
||||||
<div className="text-sm text-muted-foreground">최대 단</div>
|
<div className="text-muted-foreground text-sm">최대 단</div>
|
||||||
<div className="text-2xl font-bold">{statistics.maxLevel}단</div>
|
<div className="text-2xl font-bold">{statistics.maxLevel}단</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -852,7 +852,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-muted">
|
<TableHeader className="bg-muted sticky top-0">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-12 text-center">No</TableHead>
|
<TableHead className="w-12 text-center">No</TableHead>
|
||||||
<TableHead>위치코드</TableHead>
|
<TableHead>위치코드</TableHead>
|
||||||
|
|
@ -884,8 +884,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-8 text-muted-foreground">
|
<div className="border-border text-muted-foreground flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8">
|
||||||
<Eye className="mb-2 h-8 w-8 text-muted-foreground/50" />
|
<Eye className="text-muted-foreground/50 mb-2 h-8 w-8" />
|
||||||
<p>미리보기 생성 버튼을 클릭하여 결과를 확인하세요</p>
|
<p>미리보기 생성 버튼을 클릭하여 결과를 확인하세요</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -932,16 +932,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
{/* 템플릿 목록 */}
|
{/* 템플릿 목록 */}
|
||||||
{templates.length > 0 ? (
|
{templates.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium text-foreground">저장된 템플릿</div>
|
<div className="text-foreground text-sm font-medium">저장된 템플릿</div>
|
||||||
<ScrollArea className="h-[200px]">
|
<ScrollArea className="h-[200px]">
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted"
|
className="hover:bg-muted flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{template.name}</div>
|
<div className="font-medium">{template.name}</div>
|
||||||
<div className="text-xs text-muted-foreground">{template.conditions.length}개 조건</div>
|
<div className="text-muted-foreground text-xs">{template.conditions.length}개 조건</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
|
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
|
||||||
|
|
@ -956,7 +956,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-muted-foreground">저장된 템플릿이 없습니다</div>
|
<div className="text-muted-foreground py-8 text-center">저장된 템플릿이 없습니다</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
||||||
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
||||||
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
||||||
|
|
@ -36,9 +30,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
tables = [],
|
tables = [],
|
||||||
}) => {
|
}) => {
|
||||||
// 사용 가능한 컬럼 목록 추출
|
// 사용 가능한 컬럼 목록 추출
|
||||||
const [availableColumns, setAvailableColumns] = useState<
|
const [availableColumns, setAvailableColumns] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
Array<{ value: string; label: string }>
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 모든 테이블의 컬럼을 플랫하게 추출
|
// 모든 테이블의 컬럼을 플랫하게 추출
|
||||||
|
|
@ -73,10 +65,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
|
|
||||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||||
|
|
||||||
const handleFormatChange = (
|
const handleFormatChange = (key: "codeSegments" | "nameSegments", segments: FormatSegment[]) => {
|
||||||
key: "codeSegments" | "nameSegments",
|
|
||||||
segments: FormatSegment[],
|
|
||||||
) => {
|
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
formatConfig: {
|
formatConfig: {
|
||||||
|
|
@ -90,10 +79,8 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 필드 매핑 섹션 */}
|
{/* 필드 매핑 섹션 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm font-medium text-foreground">필드 매핑</div>
|
<div className="text-foreground text-sm font-medium">필드 매핑</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요</p>
|
||||||
상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 창고 코드 필드 */}
|
{/* 창고 코드 필드 */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -222,64 +209,9 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 위치코드 패턴 설정 */}
|
|
||||||
<div className="space-y-3 border-t pt-3">
|
|
||||||
<div className="text-sm font-medium text-foreground">위치코드/위치명 패턴</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 위치코드 패턴 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">위치코드 패턴</Label>
|
|
||||||
<Input
|
|
||||||
value={config.codePattern || ""}
|
|
||||||
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
|
|
||||||
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
|
|
||||||
className="h-8 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
||||||
비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 위치명 패턴 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">위치명 패턴</Label>
|
|
||||||
<Input
|
|
||||||
value={config.namePattern || ""}
|
|
||||||
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
|
|
||||||
placeholder="{zone}-{row:02}열-{level}단"
|
|
||||||
className="h-8 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
||||||
비워두면 기본값: {"{zone}-{row:02}열-{level}단"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 실시간 미리보기 */}
|
|
||||||
<PatternPreview
|
|
||||||
codePattern={config.codePattern}
|
|
||||||
namePattern={config.namePattern}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 사용 가능한 변수 목록 */}
|
|
||||||
<div className="rounded-md border bg-muted/50 p-2">
|
|
||||||
<div className="mb-1 text-[10px] font-medium text-foreground">사용 가능한 변수</div>
|
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
|
||||||
{PATTERN_VARIABLES.map((v) => (
|
|
||||||
<div key={v.token} className="flex items-center gap-1 text-[10px]">
|
|
||||||
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
|
|
||||||
<span className="text-muted-foreground">{v.description}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 제한 설정 */}
|
{/* 제한 설정 */}
|
||||||
<div className="space-y-3 border-t pt-3">
|
<div className="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
<div className="text-foreground text-sm font-medium">제한 설정</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">최대 조건 수</Label>
|
<Label className="text-xs">최대 조건 수</Label>
|
||||||
|
|
@ -320,7 +252,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
|
|
||||||
{/* UI 설정 */}
|
{/* UI 설정 */}
|
||||||
<div className="space-y-3 border-t pt-3">
|
<div className="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-foreground">UI 설정</div>
|
<div className="text-foreground text-sm font-medium">UI 설정</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">템플릿 기능</Label>
|
<Label className="text-xs">템플릿 기능</Label>
|
||||||
|
|
@ -348,10 +280,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">읽기 전용</Label>
|
<Label className="text-xs">읽기 전용</Label>
|
||||||
<Switch
|
<Switch checked={config.readonly ?? false} onCheckedChange={(checked) => handleChange("readonly", checked)} />
|
||||||
checked={config.readonly ?? false}
|
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -359,8 +288,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
<div className="space-y-3 border-t pt-3">
|
<div className="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, 구분자/라벨을 편집할 수 있습니다
|
||||||
구분자/라벨을 편집할 수 있습니다
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<FormatSegmentEditor
|
<FormatSegmentEditor
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
/**
|
|
||||||
* 위치코드/위치명 패턴 변환 유틸리티
|
|
||||||
*
|
|
||||||
* 사용 가능한 변수:
|
|
||||||
* {warehouse} - 창고 코드 (예: WH002)
|
|
||||||
* {warehouseName} - 창고명 (예: 2창고)
|
|
||||||
* {floor} - 층 (예: 2층)
|
|
||||||
* {zone} - 구역 (예: A구역)
|
|
||||||
* {row} - 열 번호 (예: 1)
|
|
||||||
* {row:02} - 열 번호 2자리 (예: 01)
|
|
||||||
* {row:03} - 열 번호 3자리 (예: 001)
|
|
||||||
* {level} - 단 번호 (예: 1)
|
|
||||||
* {level:02} - 단 번호 2자리 (예: 01)
|
|
||||||
* {level:03} - 단 번호 3자리 (예: 001)
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface PatternVariables {
|
|
||||||
warehouse?: string;
|
|
||||||
warehouseName?: string;
|
|
||||||
floor?: string;
|
|
||||||
zone?: string;
|
|
||||||
row: number;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본 패턴 (하드코딩 대체)
|
|
||||||
export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}";
|
|
||||||
export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 패턴 문자열에서 변수를 치환하여 결과 문자열 반환
|
|
||||||
*/
|
|
||||||
export function applyLocationPattern(pattern: string, vars: PatternVariables): string {
|
|
||||||
let result = pattern;
|
|
||||||
|
|
||||||
// zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환
|
|
||||||
const simpleVars: Record<string, string | undefined> = {
|
|
||||||
warehouse: vars.warehouse,
|
|
||||||
warehouseName: vars.warehouseName,
|
|
||||||
floor: vars.floor,
|
|
||||||
zone: vars.zone,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 단순 문자열 변수 치환
|
|
||||||
for (const [key, value] of Object.entries(simpleVars)) {
|
|
||||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 숫자 변수 (row, level) - zero-pad 지원
|
|
||||||
const numericVars: Record<string, number> = {
|
|
||||||
row: vars.row,
|
|
||||||
level: vars.level,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(numericVars)) {
|
|
||||||
// {row:02}, {level:03} 같은 zero-pad 패턴
|
|
||||||
const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g");
|
|
||||||
result = result.replace(padRegex, (_, padWidth) => {
|
|
||||||
return value.toString().padStart(parseInt(padWidth), "0");
|
|
||||||
});
|
|
||||||
|
|
||||||
// {row}, {level} 같은 단순 패턴
|
|
||||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 패턴에서 사용 가능한 변수 목록
|
|
||||||
export const PATTERN_VARIABLES = [
|
|
||||||
{ token: "{warehouse}", description: "창고 코드", example: "WH002" },
|
|
||||||
{ token: "{warehouseName}", description: "창고명", example: "2창고" },
|
|
||||||
{ token: "{floor}", description: "층", example: "2층" },
|
|
||||||
{ token: "{zone}", description: "구역", example: "A구역" },
|
|
||||||
{ token: "{row}", description: "열 번호", example: "1" },
|
|
||||||
{ token: "{row:02}", description: "열 번호 (2자리)", example: "01" },
|
|
||||||
{ token: "{row:03}", description: "열 번호 (3자리)", example: "001" },
|
|
||||||
{ token: "{level}", description: "단 번호", example: "1" },
|
|
||||||
{ token: "{level:02}", description: "단 번호 (2자리)", example: "01" },
|
|
||||||
{ token: "{level:03}", description: "단 번호 (3자리)", example: "001" },
|
|
||||||
];
|
|
||||||
|
|
@ -91,6 +91,103 @@ const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value })
|
||||||
});
|
});
|
||||||
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 모드 런타임: 디자이너 좌표를 비례 스케일링하여 렌더링
|
||||||
|
*/
|
||||||
|
const ScaledCustomPanel: React.FC<{
|
||||||
|
components: PanelInlineComponent[];
|
||||||
|
formData: Record<string, any>;
|
||||||
|
onFormDataChange: (fieldName: string, value: any) => void;
|
||||||
|
tableName?: string;
|
||||||
|
menuObjid?: number;
|
||||||
|
screenId?: number;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
allComponents?: any;
|
||||||
|
selectedRowsData?: any[];
|
||||||
|
onSelectedRowsChange?: any;
|
||||||
|
}> = ({ components, formData, onFormDataChange, tableName, ...restProps }) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const w = entries[0]?.contentRect.width;
|
||||||
|
if (w && w > 0) setContainerWidth(w);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canvasW = Math.max(
|
||||||
|
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const canvasH = Math.max(
|
||||||
|
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full" style={{ height: `${canvasH}px` }}>
|
||||||
|
{containerWidth > 0 &&
|
||||||
|
components.map((comp) => {
|
||||||
|
const x = comp.position?.x || 0;
|
||||||
|
const y = comp.position?.y || 0;
|
||||||
|
const w = comp.size?.width || 200;
|
||||||
|
const h = comp.size?.height || 36;
|
||||||
|
|
||||||
|
const componentData = {
|
||||||
|
id: comp.id,
|
||||||
|
type: "component" as const,
|
||||||
|
componentType: comp.componentType,
|
||||||
|
label: comp.label,
|
||||||
|
position: { x, y },
|
||||||
|
size: { width: undefined, height: h },
|
||||||
|
componentConfig: comp.componentConfig || {},
|
||||||
|
style: { ...(comp.style || {}), width: "100%", height: "100%" },
|
||||||
|
tableName: comp.componentConfig?.tableName,
|
||||||
|
columnName: comp.componentConfig?.columnName,
|
||||||
|
webType: comp.componentConfig?.webType,
|
||||||
|
inputType: (comp as any).inputType || comp.componentConfig?.inputType,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${(x / canvasW) * 100}%`,
|
||||||
|
top: `${y}px`,
|
||||||
|
width: `${(w / canvasW) * 100}%`,
|
||||||
|
minHeight: `${h}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={componentData as any}
|
||||||
|
isDesignMode={false}
|
||||||
|
isInteractive={true}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={onFormDataChange}
|
||||||
|
tableName={tableName}
|
||||||
|
menuObjid={restProps.menuObjid}
|
||||||
|
screenId={restProps.screenId}
|
||||||
|
userId={restProps.userId}
|
||||||
|
userName={restProps.userName}
|
||||||
|
companyCode={restProps.companyCode}
|
||||||
|
allComponents={restProps.allComponents}
|
||||||
|
selectedRowsData={restProps.selectedRowsData}
|
||||||
|
onSelectedRowsChange={restProps.onSelectedRowsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitPanelLayout 컴포넌트
|
* SplitPanelLayout 컴포넌트
|
||||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||||
|
|
@ -271,8 +368,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
||||||
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
|
// 내부 선택 상태 (외부 prop 없을 때 fallback)
|
||||||
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
|
const [internalSelectedCompId, setInternalSelectedCompId] = useState<string | null>(null);
|
||||||
|
const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId;
|
||||||
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
|
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
|
||||||
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
|
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
@ -719,22 +817,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}, [leftData, leftGroupSumConfig]);
|
}, [leftData, leftGroupSumConfig]);
|
||||||
|
|
||||||
// 컴포넌트 스타일
|
// 컴포넌트 스타일
|
||||||
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
// height: component.size?.height 우선, 없으면 component.style?.height, 기본 600px
|
||||||
const getHeightValue = () => {
|
const getHeightValue = () => {
|
||||||
|
const sizeH = component.size?.height;
|
||||||
|
if (sizeH && typeof sizeH === "number" && sizeH > 0) return `${sizeH}px`;
|
||||||
const height = component.style?.height;
|
const height = component.style?.height;
|
||||||
if (!height) return "600px";
|
if (!height) return "600px";
|
||||||
if (typeof height === "string") return height; // 이미 '540px' 형태
|
if (typeof height === "string") return height;
|
||||||
return `${height}px`; // 숫자면 px 추가
|
return `${height}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const componentStyle: React.CSSProperties = isDesignMode
|
const componentStyle: React.CSSProperties = isDesignMode
|
||||||
? {
|
? {
|
||||||
position: "absolute",
|
|
||||||
left: `${component.style?.positionX || 0}px`,
|
|
||||||
top: `${component.style?.positionY || 0}px`,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: getHeightValue(),
|
height: "100%",
|
||||||
zIndex: component.style?.positionZ || 1,
|
minHeight: getHeightValue(),
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||||
}
|
}
|
||||||
|
|
@ -3144,9 +3241,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent
|
||||||
|
className="flex-1 overflow-auto p-4"
|
||||||
|
{...(isDesignMode ? {
|
||||||
|
"data-split-panel-container": "true",
|
||||||
|
"data-component-id": component.id,
|
||||||
|
"data-panel-side": "left",
|
||||||
|
} : {})}
|
||||||
|
>
|
||||||
{/* 좌측 데이터 목록/테이블/커스텀 */}
|
{/* 좌측 데이터 목록/테이블/커스텀 */}
|
||||||
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
|
|
||||||
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
<div
|
<div
|
||||||
|
|
@ -3158,59 +3261,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
||||||
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
|
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
|
||||||
!isDesignMode ? (
|
!isDesignMode ? (
|
||||||
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
|
<ScaledCustomPanel
|
||||||
(() => {
|
components={componentConfig.leftPanel!.components}
|
||||||
const leftComps = componentConfig.leftPanel!.components;
|
formData={{}}
|
||||||
const canvasW = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
|
onFormDataChange={(data: any) => {
|
||||||
const canvasH = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
|
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
||||||
const compDataList = leftComps.map((c: PanelInlineComponent) => ({
|
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
||||||
id: c.id,
|
setSelectedLeftItem(data.selectedRowsData[0]);
|
||||||
type: "component" as const,
|
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
||||||
componentType: c.componentType,
|
setCustomLeftSelectedData({});
|
||||||
label: c.label,
|
setSelectedLeftItem(null);
|
||||||
position: c.position || { x: 0, y: 0 },
|
}
|
||||||
size: c.size || { width: 400, height: 300 },
|
}}
|
||||||
componentConfig: c.componentConfig || {},
|
tableName={componentConfig.leftPanel?.tableName}
|
||||||
style: c.style || {},
|
menuObjid={(props as any).menuObjid}
|
||||||
tableName: c.componentConfig?.tableName,
|
screenId={(props as any).screenId}
|
||||||
columnName: c.componentConfig?.columnName,
|
userId={(props as any).userId}
|
||||||
webType: c.componentConfig?.webType,
|
userName={(props as any).userName}
|
||||||
inputType: (c as any).inputType || c.componentConfig?.inputType,
|
companyCode={companyCode}
|
||||||
})) as any;
|
allComponents={(props as any).allComponents}
|
||||||
return (
|
selectedRowsData={localSelectedRowsData}
|
||||||
<ResponsiveGridRenderer
|
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||||
components={compDataList}
|
/>
|
||||||
canvasWidth={canvasW}
|
|
||||||
canvasHeight={canvasH}
|
|
||||||
renderComponent={(comp) => (
|
|
||||||
<DynamicComponentRenderer
|
|
||||||
component={comp as any}
|
|
||||||
isDesignMode={false}
|
|
||||||
isInteractive={true}
|
|
||||||
formData={{}}
|
|
||||||
tableName={componentConfig.leftPanel?.tableName}
|
|
||||||
menuObjid={(props as any).menuObjid}
|
|
||||||
screenId={(props as any).screenId}
|
|
||||||
userId={(props as any).userId}
|
|
||||||
userName={(props as any).userName}
|
|
||||||
companyCode={companyCode}
|
|
||||||
allComponents={(props as any).allComponents}
|
|
||||||
selectedRowsData={localSelectedRowsData}
|
|
||||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
|
||||||
onFormDataChange={(data: any) => {
|
|
||||||
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
|
||||||
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
|
||||||
setSelectedLeftItem(data.selectedRowsData[0]);
|
|
||||||
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
|
||||||
setCustomLeftSelectedData({});
|
|
||||||
setSelectedLeftItem(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||||
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
|
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
|
||||||
|
|
@ -3250,10 +3322,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// 패널 컴포넌트 선택 시 탭 내 선택 해제
|
|
||||||
if (comp.componentType !== "v2-tabs-widget") {
|
if (comp.componentType !== "v2-tabs-widget") {
|
||||||
setNestedTabSelectedCompId(undefined);
|
setNestedTabSelectedCompId(undefined);
|
||||||
}
|
}
|
||||||
|
setInternalSelectedCompId(comp.id);
|
||||||
onSelectPanelComponent?.("left", comp.id, comp);
|
onSelectPanelComponent?.("left", comp.id, comp);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -3501,7 +3573,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasGroupedLeftActions && (
|
{hasGroupedLeftActions && (
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -3537,7 +3609,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasGroupedLeftActions && (
|
{hasGroupedLeftActions && (
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -3599,7 +3671,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasLeftTableActions && (
|
{hasLeftTableActions && (
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -3635,7 +3707,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasLeftTableActions && (
|
{hasLeftTableActions && (
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -4033,7 +4105,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent
|
||||||
|
className="flex-1 overflow-auto p-4"
|
||||||
|
{...(isDesignMode ? {
|
||||||
|
"data-split-panel-container": "true",
|
||||||
|
"data-component-id": component.id,
|
||||||
|
"data-panel-side": "right",
|
||||||
|
} : {})}
|
||||||
|
>
|
||||||
{/* 추가 탭 컨텐츠 */}
|
{/* 추가 탭 컨텐츠 */}
|
||||||
{activeTabIndex > 0 ? (
|
{activeTabIndex > 0 ? (
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -4316,53 +4395,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
||||||
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
|
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
|
||||||
!isDesignMode ? (
|
!isDesignMode ? (
|
||||||
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
|
<ScaledCustomPanel
|
||||||
(() => {
|
components={componentConfig.rightPanel!.components}
|
||||||
const rightComps = componentConfig.rightPanel!.components;
|
formData={customLeftSelectedData}
|
||||||
const canvasW = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
|
onFormDataChange={(fieldName: string, value: any) => {
|
||||||
const canvasH = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
|
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
|
||||||
const compDataList = rightComps.map((c: PanelInlineComponent) => ({
|
}}
|
||||||
id: c.id,
|
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
|
||||||
type: "component" as const,
|
menuObjid={(props as any).menuObjid}
|
||||||
componentType: c.componentType,
|
screenId={(props as any).screenId}
|
||||||
label: c.label,
|
userId={(props as any).userId}
|
||||||
position: c.position || { x: 0, y: 0 },
|
userName={(props as any).userName}
|
||||||
size: c.size || { width: 400, height: 300 },
|
companyCode={companyCode}
|
||||||
componentConfig: c.componentConfig || {},
|
allComponents={(props as any).allComponents}
|
||||||
style: c.style || {},
|
selectedRowsData={localSelectedRowsData}
|
||||||
tableName: c.componentConfig?.tableName,
|
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||||
columnName: c.componentConfig?.columnName,
|
/>
|
||||||
webType: c.componentConfig?.webType,
|
|
||||||
inputType: (c as any).inputType || c.componentConfig?.inputType,
|
|
||||||
})) as any;
|
|
||||||
return (
|
|
||||||
<ResponsiveGridRenderer
|
|
||||||
components={compDataList}
|
|
||||||
canvasWidth={canvasW}
|
|
||||||
canvasHeight={canvasH}
|
|
||||||
renderComponent={(comp) => (
|
|
||||||
<DynamicComponentRenderer
|
|
||||||
component={comp as any}
|
|
||||||
isDesignMode={false}
|
|
||||||
isInteractive={true}
|
|
||||||
formData={customLeftSelectedData}
|
|
||||||
onFormDataChange={(fieldName: string, value: any) => {
|
|
||||||
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
|
|
||||||
}}
|
|
||||||
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
|
|
||||||
menuObjid={(props as any).menuObjid}
|
|
||||||
screenId={(props as any).screenId}
|
|
||||||
userId={(props as any).userId}
|
|
||||||
userName={(props as any).userName}
|
|
||||||
companyCode={companyCode}
|
|
||||||
allComponents={(props as any).allComponents}
|
|
||||||
selectedRowsData={localSelectedRowsData}
|
|
||||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||||
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
|
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
|
||||||
|
|
@ -4405,6 +4453,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (comp.componentType !== "v2-tabs-widget") {
|
if (comp.componentType !== "v2-tabs-widget") {
|
||||||
setNestedTabSelectedCompId(undefined);
|
setNestedTabSelectedCompId(undefined);
|
||||||
}
|
}
|
||||||
|
setInternalSelectedCompId(comp.id);
|
||||||
onSelectPanelComponent?.("right", comp.id, comp);
|
onSelectPanelComponent?.("right", comp.id, comp);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ function SortableColumnRow({
|
||||||
onLabelChange,
|
onLabelChange,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
onFormatChange,
|
onFormatChange,
|
||||||
|
onSuffixChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
onShowInSummaryChange,
|
onShowInSummaryChange,
|
||||||
onShowInDetailChange,
|
onShowInDetailChange,
|
||||||
|
|
@ -87,6 +88,7 @@ function SortableColumnRow({
|
||||||
onLabelChange: (value: string) => void;
|
onLabelChange: (value: string) => void;
|
||||||
onWidthChange: (value: number) => void;
|
onWidthChange: (value: number) => void;
|
||||||
onFormatChange: (checked: boolean) => void;
|
onFormatChange: (checked: boolean) => void;
|
||||||
|
onSuffixChange?: (value: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onShowInSummaryChange?: (checked: boolean) => void;
|
onShowInSummaryChange?: (checked: boolean) => void;
|
||||||
onShowInDetailChange?: (checked: boolean) => void;
|
onShowInDetailChange?: (checked: boolean) => void;
|
||||||
|
|
@ -177,15 +179,24 @@ function SortableColumnRow({
|
||||||
className="h-6 w-14 shrink-0 text-xs"
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
/>
|
/>
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
<>
|
||||||
<input
|
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={col.format?.thousandSeparator ?? false}
|
type="checkbox"
|
||||||
onChange={(e) => onFormatChange(e.target.checked)}
|
checked={col.format?.thousandSeparator ?? false}
|
||||||
className="h-3 w-3"
|
onChange={(e) => onFormatChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
,
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={col.format?.suffix || ""}
|
||||||
|
onChange={(e) => onSuffixChange?.(e.target.value)}
|
||||||
|
placeholder="단위"
|
||||||
|
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
|
||||||
|
className="h-6 w-10 shrink-0 text-[10px]"
|
||||||
/>
|
/>
|
||||||
,
|
</>
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{/* 헤더/상세 표시 토글 */}
|
{/* 헤더/상세 표시 토글 */}
|
||||||
{onShowInSummaryChange && (
|
{onShowInSummaryChange && (
|
||||||
|
|
@ -818,6 +829,18 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
updateTab({ columns: newColumns });
|
updateTab({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
|
onSuffixChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
type: "number",
|
||||||
|
suffix: value || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
onShowInSummaryChange={(checked) => {
|
onShowInSummaryChange={(checked) => {
|
||||||
const newColumns = [...selectedColumns];
|
const newColumns = [...selectedColumns];
|
||||||
|
|
@ -2330,6 +2353,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
};
|
};
|
||||||
updateLeftPanel({ columns: newColumns });
|
updateLeftPanel({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
|
onSuffixChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
type: "number",
|
||||||
|
suffix: value || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
onRemove={() =>
|
onRemove={() =>
|
||||||
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
||||||
}
|
}
|
||||||
|
|
@ -2988,6 +3023,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
};
|
};
|
||||||
updateRightPanel({ columns: newColumns });
|
updateRightPanel({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
|
onSuffixChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
type: "number",
|
||||||
|
suffix: value || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
onRemove={() =>
|
onRemove={() =>
|
||||||
updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,11 @@ export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
|
||||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
updateLeftPanel({ columns: newColumns });
|
updateLeftPanel({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
|
onSuffixChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,11 @@ export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
|
||||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
updateRightPanel({ columns: newColumns });
|
updateRightPanel({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
|
onSuffixChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
onShowInSummaryChange={(checked) => {
|
onShowInSummaryChange={(checked) => {
|
||||||
const newColumns = [...selectedColumns];
|
const newColumns = [...selectedColumns];
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function SortableColumnRow({
|
export function SortableColumnRow({
|
||||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onSuffixChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||||
|
|
@ -23,6 +23,7 @@ export function SortableColumnRow({
|
||||||
onLabelChange: (value: string) => void;
|
onLabelChange: (value: string) => void;
|
||||||
onWidthChange: (value: number) => void;
|
onWidthChange: (value: number) => void;
|
||||||
onFormatChange: (checked: boolean) => void;
|
onFormatChange: (checked: boolean) => void;
|
||||||
|
onSuffixChange?: (value: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onShowInSummaryChange?: (checked: boolean) => void;
|
onShowInSummaryChange?: (checked: boolean) => void;
|
||||||
onShowInDetailChange?: (checked: boolean) => void;
|
onShowInDetailChange?: (checked: boolean) => void;
|
||||||
|
|
@ -61,15 +62,24 @@ export function SortableColumnRow({
|
||||||
className="h-6 w-14 shrink-0 text-xs"
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
/>
|
/>
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
<>
|
||||||
<input
|
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={col.format?.thousandSeparator ?? false}
|
type="checkbox"
|
||||||
onChange={(e) => onFormatChange(e.target.checked)}
|
checked={col.format?.thousandSeparator ?? false}
|
||||||
className="h-3 w-3"
|
onChange={(e) => onFormatChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
,
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={col.format?.suffix || ""}
|
||||||
|
onChange={(e) => onSuffixChange?.(e.target.value)}
|
||||||
|
placeholder="단위"
|
||||||
|
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
|
||||||
|
className="h-6 w-10 shrink-0 text-[10px]"
|
||||||
/>
|
/>
|
||||||
,
|
</>
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{onShowInSummaryChange && (
|
{onShowInSummaryChange && (
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||||
|
|
|
||||||
|
|
@ -2148,6 +2148,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
source: component.id || "table-list",
|
source: component.id || "table-list",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dataBinding 연동용 window CustomEvent
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("v2-table-selection", {
|
||||||
|
detail: {
|
||||||
|
tableName: tableConfig.selectedTable || "",
|
||||||
|
data: selectedRowsData,
|
||||||
|
source: component.id || "table-list",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ const TabsDesignEditor: React.FC<{
|
||||||
onUpdateComponent?: (updatedComponent: any) => void;
|
onUpdateComponent?: (updatedComponent: any) => void;
|
||||||
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
|
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
|
||||||
selectedTabComponentId?: string;
|
selectedTabComponentId?: string;
|
||||||
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => {
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
|
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId, onNestedPanelSelect }) => {
|
||||||
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
|
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
|
||||||
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
@ -324,15 +325,12 @@ const TabsDesignEditor: React.FC<{
|
||||||
const isDragging = draggingCompId === comp.id;
|
const isDragging = draggingCompId === comp.id;
|
||||||
const isResizing = resizingCompId === comp.id;
|
const isResizing = resizingCompId === comp.id;
|
||||||
|
|
||||||
// 드래그/리사이즈 중 표시할 크기
|
|
||||||
// resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지)
|
|
||||||
const compWidth = comp.size?.width || 200;
|
const compWidth = comp.size?.width || 200;
|
||||||
const compHeight = comp.size?.height || 100;
|
const compHeight = comp.size?.height || 100;
|
||||||
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
|
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
|
||||||
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
|
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
|
||||||
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
|
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
|
||||||
|
|
||||||
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
|
|
||||||
const componentData = {
|
const componentData = {
|
||||||
id: comp.id,
|
id: comp.id,
|
||||||
type: "component" as const,
|
type: "component" as const,
|
||||||
|
|
@ -344,7 +342,6 @@ const TabsDesignEditor: React.FC<{
|
||||||
style: comp.style || {},
|
style: comp.style || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용
|
|
||||||
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
||||||
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
||||||
|
|
||||||
|
|
@ -417,12 +414,43 @@ const TabsDesignEditor: React.FC<{
|
||||||
width: displayWidth,
|
width: displayWidth,
|
||||||
height: displayHeight,
|
height: displayHeight,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
>
|
>
|
||||||
<div className="h-full w-full pointer-events-none">
|
<div className={cn(
|
||||||
|
"h-full w-full",
|
||||||
|
comp.componentType !== "v2-split-panel-layout" && comp.componentType !== "split-panel-layout" && "pointer-events-none"
|
||||||
|
)}>
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
component={componentData as any}
|
component={componentData as any}
|
||||||
isDesignMode={true}
|
isDesignMode={true}
|
||||||
formData={{}}
|
formData={{}}
|
||||||
|
{...(comp.componentType === "v2-split-panel-layout" || comp.componentType === "split-panel-layout" ? {
|
||||||
|
onUpdateComponent: (updated: any) => {
|
||||||
|
if (!onUpdateComponent) return;
|
||||||
|
const updatedTabs = tabs.map((t) => {
|
||||||
|
if (t.id !== activeTabId) return t;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
components: (t.components || []).map((c) =>
|
||||||
|
c.id === comp.id ? { ...c, componentConfig: updated.componentConfig || updated.overrides || c.componentConfig } : c
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const configKey = component.componentConfig ? "componentConfig" : "overrides";
|
||||||
|
const existingConfig = component[configKey] || {};
|
||||||
|
onUpdateComponent({
|
||||||
|
...component,
|
||||||
|
[configKey]: { ...existingConfig, tabs: updatedTabs },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSelectPanelComponent: (panelSide: string, compId: string, panelComp: any) => {
|
||||||
|
if (onNestedPanelSelect) {
|
||||||
|
onNestedPanelSelect(comp.id, panelSide as "left" | "right", compId, panelComp);
|
||||||
|
} else {
|
||||||
|
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} : {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -483,6 +511,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
onUpdateComponent,
|
onUpdateComponent,
|
||||||
onSelectTabComponent,
|
onSelectTabComponent,
|
||||||
selectedTabComponentId,
|
selectedTabComponentId,
|
||||||
|
onNestedPanelSelect,
|
||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -499,6 +528,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
onUpdateComponent={onUpdateComponent}
|
onUpdateComponent={onUpdateComponent}
|
||||||
onSelectTabComponent={onSelectTabComponent}
|
onSelectTabComponent={onSelectTabComponent}
|
||||||
selectedTabComponentId={selectedTabComponentId}
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
|
onNestedPanelSelect={onNestedPanelSelect}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,13 @@ import "./pop-text";
|
||||||
import "./pop-icon";
|
import "./pop-icon";
|
||||||
import "./pop-dashboard";
|
import "./pop-dashboard";
|
||||||
import "./pop-card-list";
|
import "./pop-card-list";
|
||||||
|
import "./pop-card-list-v2";
|
||||||
|
|
||||||
import "./pop-button";
|
import "./pop-button";
|
||||||
import "./pop-string-list";
|
import "./pop-string-list";
|
||||||
import "./pop-search";
|
import "./pop-search";
|
||||||
|
import "./pop-status-bar";
|
||||||
|
|
||||||
import "./pop-field";
|
import "./pop-field";
|
||||||
|
import "./pop-scanner";
|
||||||
// 향후 추가될 컴포넌트들:
|
import "./pop-profile";
|
||||||
// import "./pop-list";
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 디자인 모드 미리보기
|
||||||
|
*
|
||||||
|
* 디자이너 캔버스에서 표시되는 미리보기.
|
||||||
|
* CSS Grid 기반 셀 배치를 시각적으로 보여준다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutGrid, Package } from "lucide-react";
|
||||||
|
import type { PopCardListV2Config } from "../types";
|
||||||
|
import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types";
|
||||||
|
|
||||||
|
interface PopCardListV2PreviewProps {
|
||||||
|
config?: PopCardListV2Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) {
|
||||||
|
const scrollDirection = config?.scrollDirection || "vertical";
|
||||||
|
const cardSize = config?.cardSize || "medium";
|
||||||
|
const dataSource = config?.dataSource;
|
||||||
|
const cardGrid = config?.cardGrid;
|
||||||
|
const hasTable = !!dataSource?.tableName;
|
||||||
|
const cellCount = cardGrid?.cells?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">카드 목록 V2</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
||||||
|
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
|
||||||
|
{CARD_SIZE_LABELS[cardSize]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasTable ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-xs text-muted-foreground">데이터 소스를 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 text-center">
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{dataSource!.tableName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground/60">
|
||||||
|
({cellCount}셀)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
{[0, 1].map((cardIdx) => (
|
||||||
|
<div key={cardIdx} className="rounded-md border bg-card p-2">
|
||||||
|
{cellCount === 0 ? (
|
||||||
|
<div className="flex h-12 items-center justify-center">
|
||||||
|
<span className="text-[10px] text-muted-foreground">셀을 추가하세요</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: cardGrid!.colWidths?.length
|
||||||
|
? cardGrid!.colWidths.map((w) => w || "1fr").join(" ")
|
||||||
|
: `repeat(${cardGrid!.cols || 1}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`,
|
||||||
|
gap: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardGrid!.cells.map((cell) => (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className="rounded border border-dashed border-border/50 bg-muted/20 px-1 py-0.5"
|
||||||
|
style={{
|
||||||
|
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
|
||||||
|
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] text-muted-foreground">
|
||||||
|
{cell.type}
|
||||||
|
{cell.columnName ? `: ${cell.columnName}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,733 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 셀 타입별 렌더러
|
||||||
|
*
|
||||||
|
* 각 셀 타입은 독립 함수로 구현되어 CardV2Grid에서 type별 dispatch로 호출된다.
|
||||||
|
* 기존 pop-card-list의 카드 내부 렌더링과 pop-string-list의 CardModeView 패턴을 결합.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||||
|
Loader2, CheckCircle2, CircleDot, Clock,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types";
|
||||||
|
import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
|
||||||
|
import type { ButtonVariant } from "../pop-button";
|
||||||
|
|
||||||
|
type RowData = Record<string, unknown>;
|
||||||
|
|
||||||
|
// ===== 공통 유틸 =====
|
||||||
|
|
||||||
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
|
||||||
|
};
|
||||||
|
|
||||||
|
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
|
||||||
|
if (!name) return <ShoppingCart size={size} />;
|
||||||
|
const IconComp = LUCIDE_ICON_MAP[name];
|
||||||
|
if (!IconComp) return <ShoppingCart size={size} />;
|
||||||
|
return <IconComp size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (typeof value === "number") return value.toLocaleString();
|
||||||
|
if (typeof value === "boolean") return value ? "예" : "아니오";
|
||||||
|
if (value instanceof Date) return value.toLocaleDateString();
|
||||||
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const;
|
||||||
|
const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const;
|
||||||
|
|
||||||
|
// ===== 셀 렌더러 Props =====
|
||||||
|
|
||||||
|
export interface CellRendererProps {
|
||||||
|
cell: CardCellDefinitionV2;
|
||||||
|
row: RowData;
|
||||||
|
inputValue?: number;
|
||||||
|
isCarted?: boolean;
|
||||||
|
isButtonLoading?: boolean;
|
||||||
|
onInputClick?: (e: React.MouseEvent) => void;
|
||||||
|
onCartAdd?: () => void;
|
||||||
|
onCartCancel?: () => void;
|
||||||
|
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
|
||||||
|
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
|
||||||
|
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||||
|
packageEntries?: PackageEntry[];
|
||||||
|
inputUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 디스패치 =====
|
||||||
|
|
||||||
|
export function renderCellV2(props: CellRendererProps): React.ReactNode {
|
||||||
|
switch (props.cell.type) {
|
||||||
|
case "text":
|
||||||
|
return <TextCell {...props} />;
|
||||||
|
case "field":
|
||||||
|
return <FieldCell {...props} />;
|
||||||
|
case "image":
|
||||||
|
return <ImageCell {...props} />;
|
||||||
|
case "badge":
|
||||||
|
return <BadgeCell {...props} />;
|
||||||
|
case "button":
|
||||||
|
return <ButtonCell {...props} />;
|
||||||
|
case "number-input":
|
||||||
|
return <NumberInputCell {...props} />;
|
||||||
|
case "cart-button":
|
||||||
|
return <CartButtonCell {...props} />;
|
||||||
|
case "package-summary":
|
||||||
|
return <PackageSummaryCell {...props} />;
|
||||||
|
case "status-badge":
|
||||||
|
return <StatusBadgeCell {...props} />;
|
||||||
|
case "timeline":
|
||||||
|
return <TimelineCell {...props} />;
|
||||||
|
case "action-buttons":
|
||||||
|
return <ActionButtonsCell {...props} />;
|
||||||
|
case "footer-status":
|
||||||
|
return <FooterStatusCell {...props} />;
|
||||||
|
default:
|
||||||
|
return <span className="text-[10px] text-muted-foreground">알 수 없는 셀 타입</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 1. text =====
|
||||||
|
|
||||||
|
function TextCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.columnName ? row[cell.columnName] : "";
|
||||||
|
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
|
||||||
|
const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
style={{ fontSize: fs, fontWeight: fw, color: cell.textColor || undefined }}
|
||||||
|
>
|
||||||
|
{formatValue(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. field (라벨+값) =====
|
||||||
|
|
||||||
|
function FieldCell({ cell, row, inputValue }: CellRendererProps) {
|
||||||
|
const valueType = cell.valueType || "column";
|
||||||
|
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
|
||||||
|
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (valueType !== "formula") {
|
||||||
|
const raw = cell.columnName ? row[cell.columnName] : undefined;
|
||||||
|
const formatted = formatValue(raw);
|
||||||
|
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.formulaLeft && cell.formulaOperator) {
|
||||||
|
const rightVal =
|
||||||
|
(cell.formulaRightType || "input") === "input"
|
||||||
|
? (inputValue ?? 0)
|
||||||
|
: Number(row[cell.formulaRight || ""] ?? 0);
|
||||||
|
const leftVal = Number(row[cell.formulaLeft] ?? 0);
|
||||||
|
|
||||||
|
let result: number | null = null;
|
||||||
|
switch (cell.formulaOperator) {
|
||||||
|
case "+": result = leftVal + rightVal; break;
|
||||||
|
case "-": result = leftVal - rightVal; break;
|
||||||
|
case "*": result = leftVal * rightVal; break;
|
||||||
|
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !== null && isFinite(result)) {
|
||||||
|
const formatted = (Math.round(result * 100) / 100).toLocaleString();
|
||||||
|
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}, [valueType, cell, row, inputValue]);
|
||||||
|
|
||||||
|
const isFormula = valueType === "formula";
|
||||||
|
const isLabelLeft = cell.labelPosition === "left";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}
|
||||||
|
style={{ fontSize: fs }}
|
||||||
|
>
|
||||||
|
{cell.label && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{cell.label}{isLabelLeft ? ":" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="truncate font-medium"
|
||||||
|
style={{ color: cell.textColor || (isFormula ? "#ea580c" : undefined) }}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. image =====
|
||||||
|
|
||||||
|
function ImageCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.columnName ? row[cell.columnName] : "";
|
||||||
|
const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-md border bg-muted/30">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={cell.label || ""}
|
||||||
|
className="h-full w-full object-contain p-1"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 4. badge =====
|
||||||
|
|
||||||
|
function BadgeCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.columnName ? row[cell.columnName] : "";
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
{formatValue(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 5. button =====
|
||||||
|
|
||||||
|
function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={cell.buttonVariant || "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
disabled={isButtonLoading}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onButtonClick?.(cell, row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isButtonLoading ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{cell.label || formatValue(cell.columnName ? row[cell.columnName] : "")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 6. number-input =====
|
||||||
|
|
||||||
|
function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) {
|
||||||
|
const unit = cell.inputUnit || "EA";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInputClick?.(e);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="block text-lg font-bold leading-tight">
|
||||||
|
{(inputValue ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[12px] text-muted-foreground">{unit}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 7. cart-button =====
|
||||||
|
|
||||||
|
function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) {
|
||||||
|
const iconSize = 18;
|
||||||
|
const label = cell.cartLabel || "담기";
|
||||||
|
const cancelLabel = cell.cartCancelLabel || "취소";
|
||||||
|
|
||||||
|
if (isCarted) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
|
||||||
|
>
|
||||||
|
<X size={iconSize} />
|
||||||
|
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
|
||||||
|
>
|
||||||
|
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
|
||||||
|
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
|
||||||
|
) : (
|
||||||
|
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-semibold leading-tight">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 8. package-summary =====
|
||||||
|
|
||||||
|
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
|
||||||
|
if (!packageEntries || packageEntries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full border-t bg-emerald-50">
|
||||||
|
{packageEntries.map((entry, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||||
|
포장완료
|
||||||
|
</span>
|
||||||
|
<Package className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="text-xs font-medium text-emerald-700">
|
||||||
|
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-emerald-700">
|
||||||
|
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 9. status-badge =====
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
|
waiting: { bg: "#94a3b820", text: "#64748b" },
|
||||||
|
accepted: { bg: "#3b82f620", text: "#2563eb" },
|
||||||
|
in_progress: { bg: "#f59e0b20", text: "#d97706" },
|
||||||
|
completed: { bg: "#10b98120", text: "#059669" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
||||||
|
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
||||||
|
const effectiveValue = hasSubStatus
|
||||||
|
? row[VIRTUAL_SUB_STATUS]
|
||||||
|
: (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""));
|
||||||
|
const strValue = String(effectiveValue || "");
|
||||||
|
const mapped = cell.statusMap?.find((m) => m.value === strValue);
|
||||||
|
|
||||||
|
if (mapped) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
||||||
|
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
|
||||||
|
>
|
||||||
|
{mapped.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultColors = STATUS_COLORS[strValue];
|
||||||
|
if (defaultColors) {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
||||||
|
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
|
||||||
|
>
|
||||||
|
{labelMap[strValue] || strValue}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{formatValue(effectiveValue)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 10. timeline =====
|
||||||
|
|
||||||
|
type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode };
|
||||||
|
|
||||||
|
const TIMELINE_SEMANTIC_STYLES: Record<string, TimelineStyle> = {
|
||||||
|
done: { chipBg: "#10b981", chipText: "#ffffff", icon: <CheckCircle2 className="h-2.5 w-2.5" /> },
|
||||||
|
active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" /> },
|
||||||
|
pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: <Clock className="h-2.5 w-2.5" /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레거시 status 값 → semantic 매핑 (기존 데이터 호환)
|
||||||
|
const LEGACY_STATUS_TO_SEMANTIC: Record<string, string> = {
|
||||||
|
completed: "done", in_progress: "active", accepted: "active", waiting: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTimelineStyle(step: TimelineProcessStep): TimelineStyle {
|
||||||
|
if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending;
|
||||||
|
const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status];
|
||||||
|
return TIMELINE_SEMANTIC_STYLES[fallback || "pending"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineCell({ cell, row }: CellRendererProps) {
|
||||||
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||||
|
|
||||||
|
if (!processFlow || processFlow.length === 0) {
|
||||||
|
const fallback = cell.processColumn ? row[cell.processColumn] : "";
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatValue(fallback)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVisible = cell.visibleCount || 5;
|
||||||
|
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
|
||||||
|
|
||||||
|
type DisplayItem =
|
||||||
|
| { kind: "step"; step: TimelineProcessStep }
|
||||||
|
| { kind: "count"; count: number; side: "before" | "after" };
|
||||||
|
|
||||||
|
// 현재 항목 기준으로 앞뒤 배분하여 축약
|
||||||
|
const displayItems = useMemo((): DisplayItem[] => {
|
||||||
|
if (processFlow.length <= maxVisible) {
|
||||||
|
return processFlow.map((s) => ({ kind: "step" as const, step: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveIdx = Math.max(0, currentIdx);
|
||||||
|
const priority = cell.timelinePriority || "before";
|
||||||
|
// 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정)
|
||||||
|
const slotForSteps = maxVisible - 2;
|
||||||
|
const half = Math.floor(slotForSteps / 2);
|
||||||
|
const extra = slotForSteps - half - 1;
|
||||||
|
const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra);
|
||||||
|
const afterSlots = slotForSteps - beforeSlots - 1;
|
||||||
|
|
||||||
|
let startIdx = effectiveIdx - beforeSlots;
|
||||||
|
let endIdx = effectiveIdx + afterSlots;
|
||||||
|
|
||||||
|
// 경계 보정
|
||||||
|
if (startIdx < 0) {
|
||||||
|
endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx));
|
||||||
|
startIdx = 0;
|
||||||
|
}
|
||||||
|
if (endIdx >= processFlow.length) {
|
||||||
|
startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1));
|
||||||
|
endIdx = processFlow.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: DisplayItem[] = [];
|
||||||
|
const beforeCount = startIdx;
|
||||||
|
const afterCount = processFlow.length - 1 - endIdx;
|
||||||
|
|
||||||
|
if (beforeCount > 0) {
|
||||||
|
items.push({ kind: "count", count: beforeCount, side: "before" });
|
||||||
|
}
|
||||||
|
for (let i = startIdx; i <= endIdx; i++) {
|
||||||
|
items.push({ kind: "step", step: processFlow[i] });
|
||||||
|
}
|
||||||
|
if (afterCount > 0) {
|
||||||
|
items.push({ kind: "count", count: afterCount, side: "after" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [processFlow, maxVisible, currentIdx]);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length;
|
||||||
|
const totalCount = processFlow.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-0.5 overflow-hidden px-0.5",
|
||||||
|
cell.showDetailModal !== false && "cursor-pointer",
|
||||||
|
cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined}
|
||||||
|
title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined}
|
||||||
|
>
|
||||||
|
{displayItems.map((item, idx) => {
|
||||||
|
const isLast = idx === displayItems.length - 1;
|
||||||
|
|
||||||
|
if (item.kind === "count") {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`cnt-${item.side}`}>
|
||||||
|
<div
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-bold text-muted-foreground"
|
||||||
|
title={item.side === "before" ? `이전 ${item.count}개` : `이후 ${item.count}개`}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = getTimelineStyle(item.step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.step.seqNo}>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: styles.chipBg,
|
||||||
|
color: styles.chipText,
|
||||||
|
outline: item.step.isCurrent ? "2px solid #2563eb" : "none",
|
||||||
|
outlineOffset: "1px",
|
||||||
|
}}
|
||||||
|
title={`${item.step.seqNo}. ${item.step.processName} (${item.step.status})`}
|
||||||
|
>
|
||||||
|
{styles.icon}
|
||||||
|
<span className="max-w-[48px] truncate text-[9px] font-medium leading-tight">
|
||||||
|
{item.step.processName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">전체 현황</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
총 {totalCount}개 중 {completedCount}개 완료
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-0">
|
||||||
|
{processFlow.map((step, idx) => {
|
||||||
|
const styles = getTimelineStyle(step);
|
||||||
|
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
|
||||||
|
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.seqNo} className="flex items-center">
|
||||||
|
{/* 세로 연결선 + 아이콘 */}
|
||||||
|
<div className="flex w-8 shrink-0 flex-col items-center">
|
||||||
|
{idx > 0 && <div className="h-3 w-px bg-border" />}
|
||||||
|
<div
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
|
||||||
|
>
|
||||||
|
{styles.icon}
|
||||||
|
</div>
|
||||||
|
{idx < processFlow.length - 1 && <div className="h-3 w-px bg-border" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 항목 정보 */}
|
||||||
|
<div className={cn(
|
||||||
|
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
|
||||||
|
step.isCurrent && "bg-primary/5 ring-1 ring-primary/30",
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{step.seqNo}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm",
|
||||||
|
step.isCurrent ? "font-semibold" : "font-medium",
|
||||||
|
)}>
|
||||||
|
{step.processName}
|
||||||
|
</span>
|
||||||
|
{step.isCurrent && (
|
||||||
|
<Star className="h-3 w-3 fill-primary text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 진행률 바 */}
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>진행률</span>
|
||||||
|
<span>{totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 11. action-buttons =====
|
||||||
|
|
||||||
|
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
|
||||||
|
const cond = btn.showCondition;
|
||||||
|
if (!cond || cond.type === "always") return "visible";
|
||||||
|
|
||||||
|
let matched = false;
|
||||||
|
|
||||||
|
if (cond.type === "timeline-status") {
|
||||||
|
const subStatus = row[VIRTUAL_SUB_STATUS];
|
||||||
|
matched = subStatus !== undefined && String(subStatus) === cond.value;
|
||||||
|
} else if (cond.type === "column-value" && cond.column) {
|
||||||
|
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
|
||||||
|
} else {
|
||||||
|
return "visible";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched) return "visible";
|
||||||
|
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
|
||||||
|
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
||||||
|
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
||||||
|
const currentProcessId = currentProcess?.processId;
|
||||||
|
|
||||||
|
if (cell.actionButtons && cell.actionButtons.length > 0) {
|
||||||
|
const evaluated = cell.actionButtons.map((btn) => ({
|
||||||
|
btn,
|
||||||
|
state: evaluateShowCondition(btn, row),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const activeBtn = evaluated.find((e) => e.state === "visible");
|
||||||
|
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
|
||||||
|
const pick = activeBtn || disabledBtn;
|
||||||
|
if (!pick) return null;
|
||||||
|
|
||||||
|
const { btn, state } = pick;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={btn.variant || "outline"}
|
||||||
|
size="sm"
|
||||||
|
disabled={state === "disabled"}
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
|
||||||
|
const firstAction = actions[0];
|
||||||
|
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
...firstAction,
|
||||||
|
__allActions: actions,
|
||||||
|
selectModeConfig: firstAction.selectModeButtons
|
||||||
|
? { filterStatus: btn.showCondition?.value || "", buttons: firstAction.selectModeButtons }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
if (currentProcessId !== undefined) config.__processId = currentProcessId;
|
||||||
|
|
||||||
|
if (firstAction.type === "select-mode" && onEnterSelectMode) {
|
||||||
|
onEnterSelectMode(btn.showCondition?.value || "", config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionButtonClick?.(btn.label, row, config);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 구조 (actionRules) 폴백
|
||||||
|
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
||||||
|
const statusValue = hasSubStatus
|
||||||
|
? String(row[VIRTUAL_SUB_STATUS] || "")
|
||||||
|
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
|
||||||
|
const rules = cell.actionRules || [];
|
||||||
|
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
|
||||||
|
if (!matchedRule) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{matchedRule.buttons.map((btn, idx) => (
|
||||||
|
<Button
|
||||||
|
key={idx}
|
||||||
|
variant={btn.variant || "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const config = { ...(btn as Record<string, unknown>) };
|
||||||
|
if (currentProcessId !== undefined) config.__processId = currentProcessId;
|
||||||
|
if (btn.clickMode === "select-mode" && onEnterSelectMode) {
|
||||||
|
onEnterSelectMode(matchedRule.whenStatus, config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onActionButtonClick?.(btn.taskPreset, row, config);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 12. footer-status =====
|
||||||
|
|
||||||
|
function FooterStatusCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
|
||||||
|
const strValue = String(value || "");
|
||||||
|
const mapped = cell.footerStatusMap?.find((m) => m.value === strValue);
|
||||||
|
|
||||||
|
if (!strValue && !cell.footerLabel) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex w-full items-center justify-between px-2 py-1"
|
||||||
|
style={{ borderTop: cell.showTopBorder !== false ? "1px solid hsl(var(--border))" : "none" }}
|
||||||
|
>
|
||||||
|
{cell.footerLabel && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{cell.footerLabel}</span>
|
||||||
|
)}
|
||||||
|
{mapped ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||||
|
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
|
||||||
|
>
|
||||||
|
{mapped.label}
|
||||||
|
</span>
|
||||||
|
) : strValue ? (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
{strValue}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 컴포넌트 레지스트리 등록 진입점
|
||||||
|
*
|
||||||
|
* import 시 side-effect로 PopComponentRegistry에 자동 등록
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||||
|
import { PopCardListV2Component } from "./PopCardListV2Component";
|
||||||
|
import { PopCardListV2ConfigPanel } from "./PopCardListV2Config";
|
||||||
|
import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview";
|
||||||
|
import type { PopCardListV2Config } from "../types";
|
||||||
|
|
||||||
|
const defaultConfig: PopCardListV2Config = {
|
||||||
|
dataSource: { tableName: "" },
|
||||||
|
cardGrid: {
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
colWidths: ["1fr"],
|
||||||
|
rowHeights: ["32px"],
|
||||||
|
gap: 4,
|
||||||
|
showCellBorder: true,
|
||||||
|
cells: [],
|
||||||
|
},
|
||||||
|
gridColumns: 3,
|
||||||
|
cardGap: 8,
|
||||||
|
scrollDirection: "vertical",
|
||||||
|
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
|
||||||
|
cardClickAction: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
PopComponentRegistry.registerComponent({
|
||||||
|
id: "pop-card-list-v2",
|
||||||
|
name: "카드 목록 V2",
|
||||||
|
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
|
||||||
|
category: "display",
|
||||||
|
icon: "LayoutGrid",
|
||||||
|
component: PopCardListV2Component,
|
||||||
|
configPanel: PopCardListV2ConfigPanel,
|
||||||
|
preview: PopCardListV2PreviewComponent,
|
||||||
|
defaultProps: defaultConfig,
|
||||||
|
connectionMeta: {
|
||||||
|
sendable: [
|
||||||
|
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||||
|
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
|
||||||
|
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||||
|
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||||
|
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||||
|
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||||
|
],
|
||||||
|
receivable: [
|
||||||
|
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||||
|
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||||
|
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||||
|
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
touchOptimized: true,
|
||||||
|
supportedDevices: ["mobile", "tablet"],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* pop-card-list v1 -> v2 마이그레이션 함수
|
||||||
|
*
|
||||||
|
* 기존 PopCardListConfig의 고정 레이아웃(헤더/이미지/필드/입력/담기/포장)을
|
||||||
|
* CardGridConfigV2 셀 배열로 변환하여 PopCardListV2Config를 생성한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PopCardListConfig,
|
||||||
|
PopCardListV2Config,
|
||||||
|
CardCellDefinitionV2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config {
|
||||||
|
const cells: CardCellDefinitionV2[] = [];
|
||||||
|
let nextRow = 1;
|
||||||
|
|
||||||
|
// 1. 헤더 행 (코드 + 제목)
|
||||||
|
if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) {
|
||||||
|
if (old.cardTemplate.header.codeField) {
|
||||||
|
cells.push({
|
||||||
|
id: "h-code",
|
||||||
|
row: nextRow,
|
||||||
|
col: 1,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: 1,
|
||||||
|
type: "text",
|
||||||
|
columnName: old.cardTemplate.header.codeField,
|
||||||
|
fontSize: "sm",
|
||||||
|
textColor: "hsl(var(--muted-foreground))",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (old.cardTemplate.header.titleField) {
|
||||||
|
cells.push({
|
||||||
|
id: "h-title",
|
||||||
|
row: nextRow,
|
||||||
|
col: 2,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: old.cardTemplate.header.codeField ? 2 : 3,
|
||||||
|
type: "text",
|
||||||
|
columnName: old.cardTemplate.header.titleField,
|
||||||
|
fontSize: "md",
|
||||||
|
fontWeight: "bold",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nextRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan)
|
||||||
|
const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0;
|
||||||
|
const bodyRowSpan = Math.max(1, bodyFieldCount);
|
||||||
|
|
||||||
|
if (old.cardTemplate?.image?.enabled) {
|
||||||
|
cells.push({
|
||||||
|
id: "img",
|
||||||
|
row: nextRow,
|
||||||
|
col: 1,
|
||||||
|
rowSpan: bodyRowSpan,
|
||||||
|
colSpan: 1,
|
||||||
|
type: "image",
|
||||||
|
columnName: old.cardTemplate.image.imageColumn || "",
|
||||||
|
defaultImage: old.cardTemplate.image.defaultImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 본문 필드들 (이미지 오른쪽)
|
||||||
|
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
|
||||||
|
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
|
||||||
|
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
|
||||||
|
|
||||||
|
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
|
||||||
|
cells.push({
|
||||||
|
id: `f-${i}`,
|
||||||
|
row: nextRow + i,
|
||||||
|
col: fieldStartCol,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan,
|
||||||
|
type: "field",
|
||||||
|
columnName: field.columnName,
|
||||||
|
label: field.label,
|
||||||
|
valueType: field.valueType,
|
||||||
|
formulaLeft: field.formulaLeft,
|
||||||
|
formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"],
|
||||||
|
formulaRight: field.formulaRight,
|
||||||
|
formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"],
|
||||||
|
unit: field.unit,
|
||||||
|
textColor: field.textColor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 수량 입력 + 담기 버튼 (오른쪽 열)
|
||||||
|
const rightCol = 3;
|
||||||
|
if (old.inputField?.enabled) {
|
||||||
|
cells.push({
|
||||||
|
id: "input",
|
||||||
|
row: nextRow,
|
||||||
|
col: rightCol,
|
||||||
|
rowSpan: Math.ceil(bodyRowSpan / 2),
|
||||||
|
colSpan: 1,
|
||||||
|
type: "number-input",
|
||||||
|
inputUnit: old.inputField.unit,
|
||||||
|
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (old.cartAction) {
|
||||||
|
cells.push({
|
||||||
|
id: "cart",
|
||||||
|
row: nextRow + Math.ceil(bodyRowSpan / 2),
|
||||||
|
col: rightCol,
|
||||||
|
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
|
||||||
|
colSpan: 1,
|
||||||
|
type: "cart-button",
|
||||||
|
cartLabel: old.cartAction.label,
|
||||||
|
cartCancelLabel: old.cartAction.cancelLabel,
|
||||||
|
cartIconType: old.cartAction.iconType,
|
||||||
|
cartIconValue: old.cartAction.iconValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 포장 요약 (마지막 행, full-width)
|
||||||
|
if (old.packageConfig?.enabled) {
|
||||||
|
const summaryRow = nextRow + bodyRowSpan;
|
||||||
|
cells.push({
|
||||||
|
id: "pkg",
|
||||||
|
row: summaryRow,
|
||||||
|
col: 1,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: 3,
|
||||||
|
type: "package-summary",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드 크기 계산
|
||||||
|
const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1;
|
||||||
|
const maxCol = 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource: old.dataSource,
|
||||||
|
cardGrid: {
|
||||||
|
rows: maxRow,
|
||||||
|
cols: maxCol,
|
||||||
|
colWidths: old.cardTemplate?.image?.enabled
|
||||||
|
? ["1fr", "2fr", "1fr"]
|
||||||
|
: ["1fr", "2fr", "1fr"],
|
||||||
|
gap: 2,
|
||||||
|
showCellBorder: false,
|
||||||
|
cells,
|
||||||
|
},
|
||||||
|
scrollDirection: old.scrollDirection,
|
||||||
|
cardSize: old.cardSize,
|
||||||
|
gridColumns: old.gridColumns,
|
||||||
|
gridRows: old.gridRows,
|
||||||
|
cardGap: 8,
|
||||||
|
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
|
||||||
|
cardClickAction: "none",
|
||||||
|
responsiveDisplay: old.responsiveDisplay,
|
||||||
|
inputField: old.inputField,
|
||||||
|
packageConfig: old.packageConfig,
|
||||||
|
cartAction: old.cartAction,
|
||||||
|
cartListMode: old.cartListMode,
|
||||||
|
saveMapping: old.saveMapping,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -256,6 +256,12 @@ export function PopCardListComponent({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe]);
|
}, [componentId, subscribe]);
|
||||||
|
|
||||||
|
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!componentId || loading) return;
|
||||||
|
publish(`__comp_output__${componentId}__all_rows`, rows);
|
||||||
|
}, [componentId, rows, loading, publish]);
|
||||||
|
|
||||||
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
|
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
|
||||||
const cartRef = useRef(cart);
|
const cartRef = useRef(cart);
|
||||||
cartRef.current = cart;
|
cartRef.current = cart;
|
||||||
|
|
|
||||||
|
|
@ -2039,16 +2039,29 @@ function FilterSettingsSection({
|
||||||
{filters.map((filter, index) => (
|
{filters.map((filter, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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
|
<Select
|
||||||
value={filter.column || ""}
|
value={filter.column || ""}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) =>
|
||||||
updateFilter(index, { ...filter, column: val })
|
updateFilter(index, { ...filter, column: val })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs flex-1">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="컬럼" />
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
|
|
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={filter.operator}
|
value={filter.operator}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) =>
|
||||||
updateFilter(index, {
|
updateFilter(index, {
|
||||||
...filter,
|
...filter,
|
||||||
operator: val as FilterOperator,
|
operator: val as FilterOperator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-16 text-xs">
|
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{operators.map((op) => (
|
{operators.map((op) => (
|
||||||
<SelectItem key={op.value} value={op.value}>
|
<SelectItem key={op.value} value={op.value}>
|
||||||
{op.label}
|
{op.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Input
|
||||||
<Input
|
value={filter.value}
|
||||||
value={filter.value}
|
onChange={(e) =>
|
||||||
onChange={(e) =>
|
updateFilter(index, { ...filter, value: e.target.value })
|
||||||
updateFilter(index, { ...filter, value: e.target.value })
|
}
|
||||||
}
|
placeholder="값 입력"
|
||||||
placeholder="값"
|
className="h-8 flex-1 text-xs"
|
||||||
className="h-7 flex-1 text-xs"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filters.map((filter, index) => (
|
{filters.map((filter, index) => (
|
||||||
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
|
<div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
|
||||||
<div className="flex-1">
|
<div className="flex items-center justify-between">
|
||||||
<GroupedColumnSelect
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
columnGroups={columnGroups}
|
조건 {index + 1}
|
||||||
value={filter.column || undefined}
|
</span>
|
||||||
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
|
<Button
|
||||||
placeholder="컬럼 선택"
|
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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({
|
||||||
connectionMeta: {
|
connectionMeta: {
|
||||||
sendable: [
|
sendable: [
|
||||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||||
|
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
|
||||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||||
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export interface ColumnInfo {
|
||||||
type: string;
|
type: string;
|
||||||
udtName: string;
|
udtName: string;
|
||||||
isPrimaryKey?: boolean;
|
isPrimaryKey?: boolean;
|
||||||
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SQL 값 이스케이프 =====
|
// ===== SQL 값 이스케이프 =====
|
||||||
|
|
@ -330,6 +331,7 @@ export async function fetchTableColumns(
|
||||||
type: col.dataType || col.data_type || col.type || "unknown",
|
type: col.dataType || col.data_type || col.type || "unknown",
|
||||||
udtName: col.dbType || col.udt_name || col.udtName || "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",
|
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;
|
return unsub;
|
||||||
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
}, [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 응답
|
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentId) return;
|
if (!componentId) return;
|
||||||
|
|
@ -220,7 +246,7 @@ export function PopFieldComponent({
|
||||||
? {
|
? {
|
||||||
targetTable: cfg.saveConfig.tableName,
|
targetTable: cfg.saveConfig.tableName,
|
||||||
columnMapping: Object.fromEntries(
|
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 || [])
|
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
|
||||||
.filter((m) => m.numberingRuleId)
|
.filter((m) => m.numberingRuleId)
|
||||||
|
|
@ -228,6 +254,7 @@ export function PopFieldComponent({
|
||||||
numberingRuleId: m.numberingRuleId!,
|
numberingRuleId: m.numberingRuleId!,
|
||||||
targetColumn: m.targetColumn,
|
targetColumn: m.targetColumn,
|
||||||
showResultModal: m.showResultModal,
|
showResultModal: m.showResultModal,
|
||||||
|
shareAcrossItems: m.shareAcrossItems ?? false,
|
||||||
})),
|
})),
|
||||||
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
|
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
|
||||||
.filter((m) => m.targetColumn)
|
.filter((m) => m.targetColumn)
|
||||||
|
|
@ -247,7 +274,7 @@ export function PopFieldComponent({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
|
}, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]);
|
||||||
|
|
||||||
// 필드 값 변경 핸들러
|
// 필드 값 변경 핸들러
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -398,8 +398,19 @@ function SaveTabContent({
|
||||||
syncAndUpdateSaveMappings((prev) =>
|
syncAndUpdateSaveMappings((prev) =>
|
||||||
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
|
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>
|
<Label className="text-[10px]">결과 모달</Label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
{m.shareAcrossItems && (
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
저장되는 모든 행에 동일한 번호를 부여합니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -1414,7 +1437,7 @@ function SectionEditor({
|
||||||
const newField: PopFieldItem = {
|
const newField: PopFieldItem = {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
fieldName: fieldId,
|
fieldName: "",
|
||||||
labelText: "",
|
labelText: "",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
|
||||||
numberingRuleId?: string;
|
numberingRuleId?: string;
|
||||||
showInForm: boolean;
|
showInForm: boolean;
|
||||||
showResultModal: boolean;
|
showResultModal: boolean;
|
||||||
|
shareAcrossItems?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PopFieldSaveConfig {
|
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,21 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { usePopEvent } from "@/hooks/pop";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import type {
|
import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
|
DateSelectionMode,
|
||||||
ModalSelectConfig,
|
ModalSelectConfig,
|
||||||
ModalSearchMode,
|
ModalSearchMode,
|
||||||
ModalFilterTab,
|
ModalFilterTab,
|
||||||
|
|
@ -62,9 +71,21 @@ export function PopSearchComponent({
|
||||||
const [modalDisplayText, setModalDisplayText] = useState("");
|
const [modalDisplayText, setModalDisplayText] = useState("");
|
||||||
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
|
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
|
||||||
|
|
||||||
const fieldKey = config.fieldName || componentId || "search";
|
|
||||||
const normalizedType = normalizeInputType(config.inputType as string);
|
const normalizedType = normalizeInputType(config.inputType as string);
|
||||||
const isModalType = normalizedType === "modal";
|
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(
|
const emitFilterChanged = useCallback(
|
||||||
(newValue: unknown) => {
|
(newValue: unknown) => {
|
||||||
|
|
@ -72,15 +93,18 @@ export function PopSearchComponent({
|
||||||
setSharedData(`search_${fieldKey}`, newValue);
|
setSharedData(`search_${fieldKey}`, newValue);
|
||||||
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
|
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
|
||||||
publish(`__comp_output__${componentId}__filter_value`, {
|
publish(`__comp_output__${componentId}__filter_value`, {
|
||||||
fieldName: fieldKey,
|
fieldName: fieldKey,
|
||||||
|
filterColumns,
|
||||||
value: newValue,
|
value: newValue,
|
||||||
|
filterMode: resolveFilterMode(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
publish("filter_changed", { [fieldKey]: newValue });
|
publish("filter_changed", { [fieldKey]: newValue });
|
||||||
},
|
},
|
||||||
[fieldKey, publish, setSharedData, componentId]
|
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -88,15 +112,40 @@ export function PopSearchComponent({
|
||||||
const unsub = subscribe(
|
const unsub = subscribe(
|
||||||
`__comp_input__${componentId}__set_value`,
|
`__comp_input__${componentId}__set_value`,
|
||||||
(payload: unknown) => {
|
(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
|
const incoming = typeof data === "object" && data && "value" in data
|
||||||
? (data as { value: unknown }).value
|
? (data as { value: unknown }).value
|
||||||
: data;
|
: data;
|
||||||
|
if (isModalType && incoming != null) {
|
||||||
|
setModalDisplayText(String(incoming));
|
||||||
|
}
|
||||||
emitFilterChanged(incoming);
|
emitFilterChanged(incoming);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
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(() => {
|
const handleModalOpen = useCallback(() => {
|
||||||
if (!config.modalConfig) return;
|
if (!config.modalConfig) return;
|
||||||
|
|
@ -116,29 +165,30 @@ export function PopSearchComponent({
|
||||||
[config.modalConfig, emitFilterChanged]
|
[config.modalConfig, emitFilterChanged]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleModalClear = useCallback(() => {
|
||||||
|
setModalDisplayText("");
|
||||||
|
emitFilterChanged("");
|
||||||
|
}, [emitFilterChanged]);
|
||||||
|
|
||||||
const showLabel = config.labelVisible !== false && !!config.labelText;
|
const showLabel = config.labelVisible !== false && !!config.labelText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{showLabel && (
|
{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}
|
{config.labelText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
|
||||||
<SearchInputRenderer
|
<SearchInputRenderer
|
||||||
config={config}
|
config={config}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={emitFilterChanged}
|
onChange={emitFilterChanged}
|
||||||
modalDisplayText={modalDisplayText}
|
modalDisplayText={modalDisplayText}
|
||||||
onModalOpen={handleModalOpen}
|
onModalOpen={handleModalOpen}
|
||||||
|
onModalClear={handleModalClear}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -165,9 +215,10 @@ interface InputRendererProps {
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
modalDisplayText?: string;
|
modalDisplayText?: string;
|
||||||
onModalOpen?: () => void;
|
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);
|
const normalized = normalizeInputType(config.inputType as string);
|
||||||
switch (normalized) {
|
switch (normalized) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -175,12 +226,24 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
|
||||||
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||||
case "select":
|
case "select":
|
||||||
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
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":
|
case "date-preset":
|
||||||
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
||||||
case "toggle":
|
case "toggle":
|
||||||
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
|
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
|
||||||
case "modal":
|
case "modal":
|
||||||
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
|
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
|
||||||
|
case "status-chip":
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
|
||||||
|
pop-status-bar 컴포넌트를 사용하세요
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <PlaceholderInput inputType={config.inputType} />;
|
return <PlaceholderInput inputType={config.inputType} />;
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +278,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
||||||
const isNumber = config.inputType === "number";
|
const isNumber = config.inputType === "number";
|
||||||
|
|
||||||
return (
|
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" />
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type={isNumber ? "number" : "text"}
|
type={isNumber ? "number" : "text"}
|
||||||
|
|
@ -224,12 +287,283 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
||||||
className="h-8 pl-7 text-xs"
|
className="h-full min-h-8 pl-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 서브타입
|
// select 서브타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -237,7 +571,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
|
||||||
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
||||||
return (
|
return (
|
||||||
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
|
<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 || "선택"} />
|
<SelectValue placeholder={config.placeholder || "선택"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -266,7 +600,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex h-full flex-wrap items-center gap-1">
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(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]}
|
{DATE_PRESET_LABELS[preset]}
|
||||||
|
|
@ -282,7 +616,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
|
||||||
|
|
||||||
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex h-full items-center gap-2">
|
||||||
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
||||||
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -293,17 +627,32 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
|
||||||
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
|
// 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 (
|
return (
|
||||||
<div
|
<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"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
|
<span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +663,7 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
|
||||||
|
|
||||||
function PlaceholderInput({ inputType }: { inputType: string }) {
|
function PlaceholderInput({ inputType }: { inputType: string }) {
|
||||||
return (
|
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>
|
<span className="text-[10px] text-muted-foreground">{inputType} (후속 구현 예정)</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -382,6 +731,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
|
||||||
columnLabels,
|
columnLabels,
|
||||||
displayStyle = "table",
|
displayStyle = "table",
|
||||||
displayField,
|
displayField,
|
||||||
|
distinct,
|
||||||
} = modalConfig;
|
} = modalConfig;
|
||||||
|
|
||||||
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
|
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
|
||||||
|
|
@ -393,13 +743,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
|
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 {
|
} catch {
|
||||||
setAllRows([]);
|
setAllRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [tableName]);
|
}, [tableName, distinct, displayField]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
|
|
@ -30,6 +30,9 @@ import {
|
||||||
import type {
|
import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
SearchInputType,
|
SearchInputType,
|
||||||
|
SearchFilterMode,
|
||||||
|
DateSelectionMode,
|
||||||
|
CalendarDisplayMode,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
ModalSelectConfig,
|
ModalSelectConfig,
|
||||||
ModalDisplayStyle,
|
ModalDisplayStyle,
|
||||||
|
|
@ -38,6 +41,7 @@ import type {
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
SEARCH_INPUT_TYPE_LABELS,
|
SEARCH_INPUT_TYPE_LABELS,
|
||||||
|
SEARCH_FILTER_MODE_LABELS,
|
||||||
DATE_PRESET_LABELS,
|
DATE_PRESET_LABELS,
|
||||||
MODAL_DISPLAY_STYLE_LABELS,
|
MODAL_DISPLAY_STYLE_LABELS,
|
||||||
MODAL_SEARCH_MODE_LABELS,
|
MODAL_SEARCH_MODE_LABELS,
|
||||||
|
|
@ -57,7 +61,6 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
||||||
placeholder: "검색어 입력",
|
placeholder: "검색어 입력",
|
||||||
debounceMs: 500,
|
debounceMs: 500,
|
||||||
triggerOnEnter: true,
|
triggerOnEnter: true,
|
||||||
labelPosition: "top",
|
|
||||||
labelText: "",
|
labelText: "",
|
||||||
labelVisible: true,
|
labelVisible: true,
|
||||||
};
|
};
|
||||||
|
|
@ -69,9 +72,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: PopSearchConfig | undefined;
|
config: PopSearchConfig | undefined;
|
||||||
onUpdate: (config: PopSearchConfig) => void;
|
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 [step, setStep] = useState(0);
|
||||||
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
||||||
const cfg: PopSearchConfig = {
|
const cfg: PopSearchConfig = {
|
||||||
|
|
@ -110,7 +116,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
|
{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">
|
<div className="flex justify-between pt-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -145,6 +151,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||||
interface StepProps {
|
interface StepProps {
|
||||||
cfg: PopSearchConfig;
|
cfg: PopSearchConfig;
|
||||||
update: (partial: Partial<PopSearchConfig>) => void;
|
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) {
|
function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
|
|
@ -189,33 +198,17 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cfg.labelVisible !== false && (
|
{cfg.labelVisible !== false && (
|
||||||
<>
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<Label className="text-[10px]">라벨 텍스트</Label>
|
||||||
<Label className="text-[10px]">라벨 텍스트</Label>
|
<Input
|
||||||
<Input
|
value={cfg.labelText || ""}
|
||||||
value={cfg.labelText || ""}
|
onChange={(e) => update({ labelText: e.target.value })}
|
||||||
onChange={(e) => update({ labelText: e.target.value })}
|
placeholder="예: 거래처명"
|
||||||
placeholder="예: 거래처명"
|
className="h-8 text-xs"
|
||||||
className="h-8 text-xs"
|
/>
|
||||||
/>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -224,18 +217,29 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
// STEP 2: 타입별 상세 설정
|
// STEP 2: 타입별 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function StepDetailSettings({ cfg, update }: StepProps) {
|
function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||||
const normalized = normalizeInputType(cfg.inputType as string);
|
const normalized = normalizeInputType(cfg.inputType as string);
|
||||||
switch (normalized) {
|
switch (normalized) {
|
||||||
case "text":
|
case "text":
|
||||||
case "number":
|
case "number":
|
||||||
return <TextDetailSettings cfg={cfg} update={update} />;
|
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||||
case "select":
|
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":
|
case "date-preset":
|
||||||
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
||||||
case "modal":
|
case "modal":
|
||||||
return <ModalDetailSettings cfg={cfg} update={update} />;
|
return <ModalDetailSettings cfg={cfg} update={update} />;
|
||||||
|
case "status-chip":
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
상태 칩은 pop-status-bar 컴포넌트로 분리되었습니다.
|
||||||
|
새로운 "상태 바" 컴포넌트를 사용해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case "toggle":
|
case "toggle":
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
|
@ -255,11 +259,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 상세 설정
|
// text/number 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function TextDetailSettings({ cfg, update }: StepProps) {
|
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -285,6 +556,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter 키로 즉시 발행</Label>
|
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter 키로 즉시 발행</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +566,7 @@ function TextDetailSettings({ cfg, update }: StepProps) {
|
||||||
// select 상세 설정
|
// select 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function SelectDetailSettings({ cfg, update }: StepProps) {
|
function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
||||||
const options = cfg.options || [];
|
const options = cfg.options || [];
|
||||||
|
|
||||||
const addOption = () => {
|
const addOption = () => {
|
||||||
|
|
@ -329,6 +602,90 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
옵션 추가
|
옵션 추가
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +694,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||||
// date-preset 상세 설정
|
// 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 ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
|
||||||
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
||||||
|
|
||||||
|
|
@ -366,6 +723,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||||
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -647,6 +1006,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">검색창에 보일 값</Label>
|
<Label className="text-[10px]">검색창에 보일 값</Label>
|
||||||
|
|
@ -694,8 +1068,11 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||||
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
|
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// ===== pop-search 전용 타입 =====
|
// ===== pop-search 전용 타입 =====
|
||||||
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
|
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
|
||||||
|
|
||||||
/** 검색 필드 입력 타입 (9종) */
|
/** 검색 필드 입력 타입 (10종) */
|
||||||
export type SearchInputType =
|
export type SearchInputType =
|
||||||
| "text"
|
| "text"
|
||||||
| "number"
|
| "number"
|
||||||
|
|
@ -11,7 +11,8 @@ export type SearchInputType =
|
||||||
| "multi-select"
|
| "multi-select"
|
||||||
| "combo"
|
| "combo"
|
||||||
| "modal"
|
| "modal"
|
||||||
| "toggle";
|
| "toggle"
|
||||||
|
| "status-chip";
|
||||||
|
|
||||||
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
|
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
|
||||||
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
|
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
|
||||||
|
|
@ -22,6 +23,12 @@ export function normalizeInputType(t: string): SearchInputType {
|
||||||
return t as 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";
|
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
|
||||||
|
|
||||||
|
|
@ -46,6 +53,9 @@ export type ModalDisplayStyle = "table" | "icon";
|
||||||
/** 모달 검색 방식 */
|
/** 모달 검색 방식 */
|
||||||
export type ModalSearchMode = "contains" | "starts-with" | "equals";
|
export type ModalSearchMode = "contains" | "starts-with" | "equals";
|
||||||
|
|
||||||
|
/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */
|
||||||
|
export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range";
|
||||||
|
|
||||||
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
|
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
|
||||||
export type ModalFilterTab = "korean" | "alphabet";
|
export type ModalFilterTab = "korean" | "alphabet";
|
||||||
|
|
||||||
|
|
@ -64,6 +74,22 @@ export interface ModalSelectConfig {
|
||||||
|
|
||||||
displayField: string;
|
displayField: string;
|
||||||
valueField: string;
|
valueField: string;
|
||||||
|
|
||||||
|
/** displayField 기준 중복 제거 */
|
||||||
|
distinct?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
|
||||||
|
export type StatusChipStyle = "tab" | "pill";
|
||||||
|
|
||||||
|
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
|
||||||
|
export interface StatusChipConfig {
|
||||||
|
showCount?: boolean;
|
||||||
|
countColumn?: string;
|
||||||
|
allowAll?: boolean;
|
||||||
|
allLabel?: string;
|
||||||
|
chipStyle?: StatusChipStyle;
|
||||||
|
useSubCount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pop-search 전체 설정 */
|
/** pop-search 전체 설정 */
|
||||||
|
|
@ -81,18 +107,28 @@ export interface PopSearchConfig {
|
||||||
options?: SelectOption[];
|
options?: SelectOption[];
|
||||||
optionsDataSource?: SelectDataSource;
|
optionsDataSource?: SelectDataSource;
|
||||||
|
|
||||||
|
// date 전용
|
||||||
|
dateSelectionMode?: DateSelectionMode;
|
||||||
|
calendarDisplay?: CalendarDisplayMode;
|
||||||
|
|
||||||
// date-preset 전용
|
// date-preset 전용
|
||||||
datePresets?: DatePresetOption[];
|
datePresets?: DatePresetOption[];
|
||||||
|
|
||||||
// modal 전용
|
// modal 전용
|
||||||
modalConfig?: ModalSelectConfig;
|
modalConfig?: ModalSelectConfig;
|
||||||
|
|
||||||
|
// status-chip 전용
|
||||||
|
statusChipConfig?: StatusChipConfig;
|
||||||
|
|
||||||
// 라벨
|
// 라벨
|
||||||
labelText?: string;
|
labelText?: string;
|
||||||
labelVisible?: boolean;
|
labelVisible?: boolean;
|
||||||
|
|
||||||
// 스타일
|
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
|
||||||
labelPosition?: "top" | "left";
|
filterMode?: SearchFilterMode;
|
||||||
|
|
||||||
|
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
|
||||||
|
filterColumns?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
|
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
|
||||||
|
|
@ -102,7 +138,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
|
||||||
placeholder: "검색어 입력",
|
placeholder: "검색어 입력",
|
||||||
debounceMs: 500,
|
debounceMs: 500,
|
||||||
triggerOnEnter: true,
|
triggerOnEnter: true,
|
||||||
labelPosition: "top",
|
|
||||||
labelText: "",
|
labelText: "",
|
||||||
labelVisible: true,
|
labelVisible: true,
|
||||||
};
|
};
|
||||||
|
|
@ -126,6 +161,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
|
||||||
combo: "자동완성",
|
combo: "자동완성",
|
||||||
modal: "모달",
|
modal: "모달",
|
||||||
toggle: "토글",
|
toggle: "토글",
|
||||||
|
"status-chip": "상태 칩 (대시보드)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 상태 칩 스타일 라벨 (설정 패널용) */
|
||||||
|
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
|
||||||
|
tab: "탭 (큰 숫자)",
|
||||||
|
pill: "알약 (작은 뱃지)",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 모달 보여주기 방식 라벨 */
|
/** 모달 보여주기 방식 라벨 */
|
||||||
|
|
@ -147,6 +189,14 @@ export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
|
||||||
alphabet: "ABC",
|
alphabet: "ABC",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 검색 필터 방식 라벨 (설정 패널용) */
|
||||||
|
export const SEARCH_FILTER_MODE_LABELS: Record<SearchFilterMode, string> = {
|
||||||
|
contains: "포함",
|
||||||
|
equals: "일치",
|
||||||
|
starts_with: "시작",
|
||||||
|
range: "범위",
|
||||||
|
};
|
||||||
|
|
||||||
/** 한글 초성 추출 */
|
/** 한글 초성 추출 */
|
||||||
const KOREAN_CONSONANTS = [
|
const KOREAN_CONSONANTS = [
|
||||||
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
|
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,23 @@ export function ColumnCombobox({
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return columns;
|
if (!search) return columns;
|
||||||
const q = search.toLowerCase();
|
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]);
|
}, [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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -50,7 +64,7 @@ export function ColumnCombobox({
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="mt-1 h-8 w-full justify-between text-xs"
|
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" />
|
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -61,7 +75,7 @@ export function ColumnCombobox({
|
||||||
>
|
>
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="컬럼명 검색..."
|
placeholder="컬럼명 또는 한글명 검색..."
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
|
|
@ -88,8 +102,15 @@ export function ColumnCombobox({
|
||||||
value === col.name ? "opacity-100" : "opacity-0"
|
value === col.name ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col">
|
||||||
<span>{col.name}</span>
|
<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">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{col.type}
|
{col.type}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePopEvent } from "@/hooks/pop";
|
||||||
|
import type { StatusBarConfig, StatusChipOption } from "./types";
|
||||||
|
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
|
||||||
|
|
||||||
|
interface PopStatusBarComponentProps {
|
||||||
|
config: StatusBarConfig;
|
||||||
|
label?: string;
|
||||||
|
screenId?: string;
|
||||||
|
componentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopStatusBarComponent({
|
||||||
|
config: rawConfig,
|
||||||
|
label,
|
||||||
|
screenId,
|
||||||
|
componentId,
|
||||||
|
}: PopStatusBarComponentProps) {
|
||||||
|
const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
|
||||||
|
const { publish, subscribe } = usePopEvent(screenId || "");
|
||||||
|
|
||||||
|
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||||
|
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// all_rows 이벤트 구독
|
||||||
|
useEffect(() => {
|
||||||
|
if (!componentId) return;
|
||||||
|
const unsub = subscribe(
|
||||||
|
`__comp_input__${componentId}__all_rows`,
|
||||||
|
(payload: unknown) => {
|
||||||
|
const data = payload as { value?: unknown } | unknown;
|
||||||
|
const inner =
|
||||||
|
typeof data === "object" && data && "value" in data
|
||||||
|
? (data as { value: unknown }).value
|
||||||
|
: data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof inner === "object" &&
|
||||||
|
inner &&
|
||||||
|
!Array.isArray(inner) &&
|
||||||
|
"rows" in inner
|
||||||
|
) {
|
||||||
|
const envelope = inner as {
|
||||||
|
rows?: unknown;
|
||||||
|
subStatusColumn?: string | null;
|
||||||
|
};
|
||||||
|
if (Array.isArray(envelope.rows))
|
||||||
|
setAllRows(envelope.rows as Record<string, unknown>[]);
|
||||||
|
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
|
||||||
|
} else if (Array.isArray(inner)) {
|
||||||
|
setAllRows(inner as Record<string, unknown>[]);
|
||||||
|
setAutoSubStatusColumn(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, [componentId, subscribe]);
|
||||||
|
|
||||||
|
// 외부에서 값 설정 이벤트 구독
|
||||||
|
useEffect(() => {
|
||||||
|
if (!componentId) return;
|
||||||
|
const unsub = subscribe(
|
||||||
|
`__comp_input__${componentId}__set_value`,
|
||||||
|
(payload: unknown) => {
|
||||||
|
const data = payload as { value?: unknown } | unknown;
|
||||||
|
const incoming =
|
||||||
|
typeof data === "object" && data && "value" in data
|
||||||
|
? (data as { value: unknown }).value
|
||||||
|
: data;
|
||||||
|
setSelectedValue(String(incoming ?? ""));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, [componentId, subscribe]);
|
||||||
|
|
||||||
|
const emitFilter = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
setSelectedValue(newValue);
|
||||||
|
if (!componentId) return;
|
||||||
|
|
||||||
|
const baseColumn = config.filterColumn || config.countColumn || "";
|
||||||
|
const subActive = config.useSubCount && !!autoSubStatusColumn;
|
||||||
|
const filterColumns = subActive
|
||||||
|
? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))]
|
||||||
|
: [baseColumn].filter(Boolean);
|
||||||
|
|
||||||
|
publish(`__comp_output__${componentId}__filter_value`, {
|
||||||
|
fieldName: baseColumn,
|
||||||
|
filterColumns,
|
||||||
|
value: newValue,
|
||||||
|
filterMode: "equals",
|
||||||
|
_source: "status-bar",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn]
|
||||||
|
);
|
||||||
|
|
||||||
|
const chipCfg = config;
|
||||||
|
const showCount = chipCfg.showCount !== false;
|
||||||
|
const baseCountColumn = chipCfg.countColumn || "";
|
||||||
|
const useSubCount = chipCfg.useSubCount || false;
|
||||||
|
const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false;
|
||||||
|
const allowAll = chipCfg.allowAll !== false;
|
||||||
|
const allLabel = chipCfg.allLabel || "전체";
|
||||||
|
const chipStyle = chipCfg.chipStyle || "tab";
|
||||||
|
const options: StatusChipOption[] = chipCfg.options || [];
|
||||||
|
|
||||||
|
// 하위 필터(공정) 활성 여부
|
||||||
|
const subFilterActive = useSubCount && !!autoSubStatusColumn;
|
||||||
|
|
||||||
|
// hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김
|
||||||
|
const shouldHide = hideUntilSubFilter && !subFilterActive;
|
||||||
|
|
||||||
|
const effectiveCountColumn =
|
||||||
|
subFilterActive ? autoSubStatusColumn : baseCountColumn;
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
if (!showCount || !effectiveCountColumn || allRows.length === 0)
|
||||||
|
return new Map<string, number>();
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const row of allRows) {
|
||||||
|
if (row == null || typeof row !== "object") continue;
|
||||||
|
const v = String(row[effectiveCountColumn] ?? "");
|
||||||
|
map.set(v, (map.get(v) || 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [allRows, effectiveCountColumn, showCount]);
|
||||||
|
|
||||||
|
const totalCount = allRows.length;
|
||||||
|
|
||||||
|
const chipItems = useMemo(() => {
|
||||||
|
const items: { value: string; label: string; count: number }[] = [];
|
||||||
|
if (allowAll) {
|
||||||
|
items.push({ value: "", label: allLabel, count: totalCount });
|
||||||
|
}
|
||||||
|
for (const opt of options) {
|
||||||
|
items.push({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
count: counts.get(opt.value) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [options, counts, totalCount, allowAll, allLabel]);
|
||||||
|
|
||||||
|
const showLabel = !!label;
|
||||||
|
|
||||||
|
if (shouldHide) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
|
{chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chipStyle === "pill") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
|
||||||
|
{showLabel && (
|
||||||
|
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||||
|
{chipItems.map((item) => {
|
||||||
|
const isActive = selectedValue === item.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => emitFilter(item.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{showCount && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-0.5 min-w-[18px] rounded-full px-1 py-0.5 text-center text-[10px] font-bold leading-none",
|
||||||
|
isActive
|
||||||
|
? "bg-primary-foreground/20 text-primary-foreground"
|
||||||
|
: "bg-background text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab 스타일 (기본)
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
|
||||||
|
{showLabel && (
|
||||||
|
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
|
||||||
|
{chipItems.map((item) => {
|
||||||
|
const isActive = selectedValue === item.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => emitFilter(item.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-[60px] flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: "bg-muted/60 text-muted-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showCount && (
|
||||||
|
<span className="text-lg font-bold leading-tight">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-medium leading-tight">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,489 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import type { ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||||
|
import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types";
|
||||||
|
import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types";
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: StatusBarConfig | undefined;
|
||||||
|
onUpdate: (config: StatusBarConfig) => void;
|
||||||
|
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
||||||
|
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||||
|
componentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopStatusBarConfigPanel({
|
||||||
|
config: rawConfig,
|
||||||
|
onUpdate,
|
||||||
|
allComponents,
|
||||||
|
connections,
|
||||||
|
componentId,
|
||||||
|
}: ConfigPanelProps) {
|
||||||
|
const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
|
||||||
|
|
||||||
|
const update = (partial: Partial<StatusBarConfig>) => {
|
||||||
|
onUpdate({ ...cfg, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = cfg.options || [];
|
||||||
|
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
update({ options: options.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOption = (
|
||||||
|
index: number,
|
||||||
|
field: keyof StatusChipOption,
|
||||||
|
val: string
|
||||||
|
) => {
|
||||||
|
update({
|
||||||
|
options: options.map((opt, i) =>
|
||||||
|
i === index ? { ...opt, [field]: val } : opt
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결된 카드 컴포넌트의 테이블 컬럼 가져오기
|
||||||
|
const connectedTableName = useMemo(() => {
|
||||||
|
if (!componentId || !connections || !allComponents) return null;
|
||||||
|
const targetIds = connections
|
||||||
|
.filter((c) => c.sourceComponent === componentId)
|
||||||
|
.map((c) => c.targetComponent);
|
||||||
|
const sourceIds = connections
|
||||||
|
.filter((c) => c.targetComponent === componentId)
|
||||||
|
.map((c) => c.sourceComponent);
|
||||||
|
const peerIds = [...new Set([...targetIds, ...sourceIds])];
|
||||||
|
|
||||||
|
for (const pid of peerIds) {
|
||||||
|
const comp = allComponents.find((c) => c.id === pid);
|
||||||
|
if (!comp?.config) continue;
|
||||||
|
const compCfg = comp.config as Record<string, unknown>;
|
||||||
|
const ds = compCfg.dataSource as { tableName?: string } | undefined;
|
||||||
|
if (ds?.tableName) return ds.tableName;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [componentId, connections, allComponents]);
|
||||||
|
|
||||||
|
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
|
||||||
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 집계 컬럼의 고유값 (옵션 선택용)
|
||||||
|
const [distinctValues, setDistinctValues] = useState<string[]>([]);
|
||||||
|
const [distinctLoading, setDistinctLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectedTableName) {
|
||||||
|
setTargetColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setColumnsLoading(true);
|
||||||
|
getTableColumns(connectedTableName)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
setTargetColumns(res.data.columns);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setColumnsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [connectedTableName]);
|
||||||
|
|
||||||
|
const fetchDistinctValues = useCallback(async (tableName: string, column: string) => {
|
||||||
|
setDistinctLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 });
|
||||||
|
const vals = new Set<string>();
|
||||||
|
for (const row of res.data) {
|
||||||
|
const v = row[column];
|
||||||
|
if (v != null && String(v).trim() !== "") {
|
||||||
|
vals.add(String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = [...vals].sort();
|
||||||
|
setDistinctValues(sorted);
|
||||||
|
return sorted;
|
||||||
|
} catch {
|
||||||
|
setDistinctValues([]);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setDistinctLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 집계 컬럼 변경 시 고유값 새로 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const col = cfg.countColumn;
|
||||||
|
if (!connectedTableName || !col) {
|
||||||
|
setDistinctValues([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchDistinctValues(connectedTableName, col);
|
||||||
|
}, [connectedTableName, cfg.countColumn, fetchDistinctValues]);
|
||||||
|
|
||||||
|
const handleAutoFill = useCallback(async () => {
|
||||||
|
if (!connectedTableName || !cfg.countColumn) return;
|
||||||
|
const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn);
|
||||||
|
if (vals.length === 0) return;
|
||||||
|
const newOptions: StatusChipOption[] = vals.map((v) => {
|
||||||
|
const existing = options.find((o) => o.value === v);
|
||||||
|
return { value: v, label: existing?.label || v };
|
||||||
|
});
|
||||||
|
update({ options: newOptions });
|
||||||
|
}, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]);
|
||||||
|
|
||||||
|
const addOptionFromValue = (value: string) => {
|
||||||
|
if (options.some((o) => o.value === value)) return;
|
||||||
|
update({
|
||||||
|
options: [...options, { value, label: value }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* --- 칩 옵션 목록 --- */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">상태 칩 옵션 목록</Label>
|
||||||
|
{connectedTableName && cfg.countColumn && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[9px]"
|
||||||
|
onClick={handleAutoFill}
|
||||||
|
disabled={distinctLoading}
|
||||||
|
>
|
||||||
|
{distinctLoading ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
DB에서 자동 채우기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cfg.useSubCount && (
|
||||||
|
<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">
|
||||||
|
하위 필터 자동 전환이 켜져 있으면 런타임에 가상 컬럼으로
|
||||||
|
집계됩니다. DB 값과 다를 수 있으니 직접 입력을 권장합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{options.length === 0 && (
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
{connectedTableName && cfg.countColumn
|
||||||
|
? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요."
|
||||||
|
: "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{options.map((opt, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
value={opt.value}
|
||||||
|
onChange={(e) => updateOption(i, "value", e.target.value)}
|
||||||
|
placeholder="DB 값"
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={opt.label}
|
||||||
|
onChange={(e) => updateOption(i, "label", e.target.value)}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(i)}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 고유값에서 추가 */}
|
||||||
|
{distinctValues.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[9px] text-muted-foreground">
|
||||||
|
값 추가 (DB에서 가져온 고유값)
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{distinctValues
|
||||||
|
.filter((dv) => !options.some((o) => o.value === dv))
|
||||||
|
.map((dv) => (
|
||||||
|
<button
|
||||||
|
key={dv}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addOptionFromValue(dv)}
|
||||||
|
className="flex h-6 items-center gap-1 rounded-full border border-dashed px-2 text-[9px] text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-2.5 w-2.5" />
|
||||||
|
{dv}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{distinctValues.every((dv) => options.some((o) => o.value === dv)) && (
|
||||||
|
<p className="text-[9px] text-muted-foreground">모든 값이 추가되었습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수동 추가 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
update({
|
||||||
|
options: [
|
||||||
|
...options,
|
||||||
|
{ value: "", label: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
옵션 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 전체 보기 칩 --- */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="allowAll"
|
||||||
|
checked={cfg.allowAll !== false}
|
||||||
|
onCheckedChange={(checked) => update({ allowAll: Boolean(checked) })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="allowAll" className="text-[10px]">
|
||||||
|
"전체" 보기 칩 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="pl-5 text-[9px] text-muted-foreground">
|
||||||
|
필터 해제용 칩을 옵션 목록 맨 앞에 자동 추가합니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cfg.allowAll !== false && (
|
||||||
|
<div className="space-y-1 pl-5">
|
||||||
|
<Label className="text-[9px] text-muted-foreground">
|
||||||
|
표시 라벨
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={cfg.allLabel || ""}
|
||||||
|
onChange={(e) => update({ allLabel: e.target.value })}
|
||||||
|
placeholder="전체"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 건수 표시 --- */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showCount"
|
||||||
|
checked={cfg.showCount !== false}
|
||||||
|
onCheckedChange={(checked) => update({ showCount: Boolean(checked) })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showCount" className="text-[10px]">
|
||||||
|
건수 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cfg.showCount !== false && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">집계 컬럼</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 ? (
|
||||||
|
<Select
|
||||||
|
value={cfg.countColumn || ""}
|
||||||
|
onValueChange={(v) => update({ countColumn: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="집계 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetColumns.map((col) => (
|
||||||
|
<SelectItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={col.columnName}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({col.columnName})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={cfg.countColumn || ""}
|
||||||
|
onChange={(e) => update({ countColumn: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cfg.showCount !== false && (
|
||||||
|
<div className="space-y-2 rounded bg-muted/30 p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="useSubCount"
|
||||||
|
checked={cfg.useSubCount || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update({ useSubCount: Boolean(checked) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="useSubCount" className="text-[10px]">
|
||||||
|
하위 필터 적용 시 집계 컬럼 자동 전환
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{cfg.useSubCount && (
|
||||||
|
<>
|
||||||
|
<p className="pl-5 text-[9px] text-muted-foreground">
|
||||||
|
연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동
|
||||||
|
전환됩니다
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 pl-5">
|
||||||
|
<Checkbox
|
||||||
|
id="hideUntilSubFilter"
|
||||||
|
checked={cfg.hideUntilSubFilter || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update({ hideUntilSubFilter: Boolean(checked) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hideUntilSubFilter" className="text-[10px]">
|
||||||
|
하위 필터 선택 전까지 칩 숨김
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{cfg.hideUntilSubFilter && (
|
||||||
|
<div className="space-y-1 pl-5">
|
||||||
|
<Label className="text-[9px] text-muted-foreground">
|
||||||
|
숨김 상태 안내 문구
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={cfg.hiddenMessage || ""}
|
||||||
|
onChange={(e) => update({ hiddenMessage: e.target.value })}
|
||||||
|
placeholder="조건을 선택하면 상태별 현황이 표시됩니다"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- 칩 스타일 --- */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">칩 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={cfg.chipStyle || "tab"}
|
||||||
|
onValueChange={(v) => update({ chipStyle: v as StatusChipStyle })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(STATUS_CHIP_STYLE_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 className="space-y-1 border-t pt-3">
|
||||||
|
<Label className="text-[10px]">필터 대상 컬럼</Label>
|
||||||
|
{!connectedTableName && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{connectedTableName && (
|
||||||
|
<>
|
||||||
|
{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 ? (
|
||||||
|
<Select
|
||||||
|
value={cfg.filterColumn || cfg.countColumn || ""}
|
||||||
|
onValueChange={(v) => update({ filterColumn: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="필터 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetColumns.map((col) => (
|
||||||
|
<SelectItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={col.columnName}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({col.columnName})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={cfg.filterColumn || ""}
|
||||||
|
onChange={(e) => update({ filterColumn: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
선택한 상태 칩 값으로 카드를 필터링할 컬럼 (비어있으면 집계
|
||||||
|
컬럼과 동일)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||||
|
import { PopStatusBarComponent } from "./PopStatusBarComponent";
|
||||||
|
import { PopStatusBarConfigPanel } from "./PopStatusBarConfig";
|
||||||
|
import type { StatusBarConfig } from "./types";
|
||||||
|
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
|
||||||
|
|
||||||
|
function PopStatusBarPreviewComponent({
|
||||||
|
config,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
config?: StatusBarConfig;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const cfg = config || DEFAULT_STATUS_BAR_CONFIG;
|
||||||
|
const options = cfg.options || [];
|
||||||
|
const displayLabel = label || "상태 바";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<span className="text-[9px] text-muted-foreground">
|
||||||
|
옵션 없음
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
options.slice(0, 4).map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
className="flex flex-col items-center rounded bg-muted/60 px-2 py-0.5"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold leading-tight">0</span>
|
||||||
|
<span className="text-[8px] leading-tight text-muted-foreground">
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopComponentRegistry.registerComponent({
|
||||||
|
id: "pop-status-bar",
|
||||||
|
name: "상태 바",
|
||||||
|
description: "상태별 건수 대시보드 + 필터",
|
||||||
|
category: "display",
|
||||||
|
icon: "BarChart3",
|
||||||
|
component: PopStatusBarComponent,
|
||||||
|
configPanel: PopStatusBarConfigPanel,
|
||||||
|
preview: PopStatusBarPreviewComponent,
|
||||||
|
defaultProps: DEFAULT_STATUS_BAR_CONFIG,
|
||||||
|
connectionMeta: {
|
||||||
|
sendable: [
|
||||||
|
{
|
||||||
|
key: "filter_value",
|
||||||
|
label: "필터 값",
|
||||||
|
type: "filter_value",
|
||||||
|
category: "filter",
|
||||||
|
description: "선택한 상태 칩 값을 카드에 필터로 전달",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
receivable: [
|
||||||
|
{
|
||||||
|
key: "all_rows",
|
||||||
|
label: "전체 데이터",
|
||||||
|
type: "all_rows",
|
||||||
|
category: "data",
|
||||||
|
description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "set_value",
|
||||||
|
label: "값 설정",
|
||||||
|
type: "filter_value",
|
||||||
|
category: "filter",
|
||||||
|
description: "외부에서 선택 값 설정",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
touchOptimized: true,
|
||||||
|
supportedDevices: ["mobile", "tablet"],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
// ===== pop-status-bar 전용 타입 =====
|
||||||
|
// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행.
|
||||||
|
|
||||||
|
/** 상태 칩 표시 스타일 */
|
||||||
|
export type StatusChipStyle = "tab" | "pill";
|
||||||
|
|
||||||
|
/** 개별 옵션 */
|
||||||
|
export interface StatusChipOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** status-bar 전용 설정 */
|
||||||
|
export interface StatusBarConfig {
|
||||||
|
showCount?: boolean;
|
||||||
|
countColumn?: string;
|
||||||
|
allowAll?: boolean;
|
||||||
|
allLabel?: string;
|
||||||
|
chipStyle?: StatusChipStyle;
|
||||||
|
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
|
||||||
|
useSubCount?: boolean;
|
||||||
|
/** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */
|
||||||
|
hideUntilSubFilter?: boolean;
|
||||||
|
/** 칩 숨김 상태일 때 표시할 안내 문구 */
|
||||||
|
hiddenMessage?: string;
|
||||||
|
|
||||||
|
options?: StatusChipOption[];
|
||||||
|
|
||||||
|
/** 필터 대상 컬럼명 (기본: countColumn) */
|
||||||
|
filterColumn?: string;
|
||||||
|
/** 추가 필터 대상 컬럼 (하위 테이블 등) */
|
||||||
|
filterColumns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기본 설정값 */
|
||||||
|
export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = {
|
||||||
|
showCount: true,
|
||||||
|
allowAll: true,
|
||||||
|
allLabel: "전체",
|
||||||
|
chipStyle: "tab",
|
||||||
|
options: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 칩 스타일 라벨 (설정 패널용) */
|
||||||
|
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
|
||||||
|
tab: "탭 (큰 숫자)",
|
||||||
|
pill: "알약 (작은 뱃지)",
|
||||||
|
};
|
||||||
|
|
@ -193,10 +193,9 @@ export function PopStringListComponent({
|
||||||
row: RowData,
|
row: RowData,
|
||||||
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
|
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const searchValue = String(filter.value).toLowerCase();
|
|
||||||
if (!searchValue) return true;
|
|
||||||
|
|
||||||
const fc = filter.filterConfig;
|
const fc = filter.filterConfig;
|
||||||
|
const mode = fc?.filterMode || "contains";
|
||||||
|
|
||||||
const columns: string[] =
|
const columns: string[] =
|
||||||
fc?.targetColumns?.length
|
fc?.targetColumns?.length
|
||||||
? fc.targetColumns
|
? fc.targetColumns
|
||||||
|
|
@ -208,17 +207,46 @@ export function PopStringListComponent({
|
||||||
|
|
||||||
if (columns.length === 0) return true;
|
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 matchCell = (cellValue: string) => {
|
||||||
|
const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "equals":
|
case "equals":
|
||||||
return cellValue === searchValue;
|
return target === searchValue;
|
||||||
case "starts_with":
|
case "starts_with":
|
||||||
return cellValue.startsWith(searchValue);
|
return target.startsWith(searchValue);
|
||||||
case "contains":
|
case "contains":
|
||||||
default:
|
default:
|
||||||
return cellValue.includes(searchValue);
|
return target.includes(searchValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -722,3 +722,264 @@ export interface PopCardListConfig {
|
||||||
cartListMode?: CartListModeConfig;
|
cartListMode?: CartListModeConfig;
|
||||||
saveMapping?: CardListSaveMapping;
|
saveMapping?: CardListSaveMapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// pop-card-list-v2 전용 타입 (슬롯 기반 카드)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button";
|
||||||
|
|
||||||
|
export type CardCellType =
|
||||||
|
| "text"
|
||||||
|
| "field"
|
||||||
|
| "image"
|
||||||
|
| "badge"
|
||||||
|
| "button"
|
||||||
|
| "number-input"
|
||||||
|
| "cart-button"
|
||||||
|
| "package-summary"
|
||||||
|
| "status-badge"
|
||||||
|
| "timeline"
|
||||||
|
| "action-buttons"
|
||||||
|
| "footer-status";
|
||||||
|
|
||||||
|
// timeline 셀에서 사용하는 하위 단계 데이터
|
||||||
|
export interface TimelineProcessStep {
|
||||||
|
seqNo: number;
|
||||||
|
processName: string;
|
||||||
|
status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값)
|
||||||
|
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
|
||||||
|
isCurrent: boolean;
|
||||||
|
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
|
||||||
|
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
|
||||||
|
export interface TimelineDataSource {
|
||||||
|
processTable: string; // 하위 데이터 테이블명 (예: work_order_process)
|
||||||
|
foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id)
|
||||||
|
seqColumn: string; // 순서 컬럼 (예: seq_no)
|
||||||
|
nameColumn: string; // 표시명 컬럼 (예: process_name)
|
||||||
|
statusColumn: string; // 상태 컬럼 (예: status)
|
||||||
|
// 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시)
|
||||||
|
// 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환
|
||||||
|
statusMappings?: StatusValueMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimelineStatusSemantic = "pending" | "active" | "done";
|
||||||
|
|
||||||
|
export interface StatusValueMapping {
|
||||||
|
dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값)
|
||||||
|
label: string; // 화면에 보이는 이름
|
||||||
|
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
|
||||||
|
isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardCellDefinitionV2 {
|
||||||
|
id: string;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
rowSpan: number;
|
||||||
|
colSpan: number;
|
||||||
|
type: CardCellType;
|
||||||
|
|
||||||
|
// 공통
|
||||||
|
columnName?: string;
|
||||||
|
label?: string;
|
||||||
|
labelPosition?: "top" | "left";
|
||||||
|
fontSize?: "xs" | "sm" | "md" | "lg";
|
||||||
|
fontWeight?: "normal" | "medium" | "bold";
|
||||||
|
textColor?: string;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
verticalAlign?: "top" | "middle" | "bottom";
|
||||||
|
|
||||||
|
// field 타입 전용 (CardFieldBinding 흡수)
|
||||||
|
valueType?: "column" | "formula";
|
||||||
|
formulaLeft?: string;
|
||||||
|
formulaOperator?: "+" | "-" | "*" | "/";
|
||||||
|
formulaRight?: string;
|
||||||
|
formulaRightType?: "input" | "column";
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
// image 타입 전용
|
||||||
|
defaultImage?: string;
|
||||||
|
|
||||||
|
// button 타입 전용
|
||||||
|
buttonAction?: ButtonMainAction;
|
||||||
|
buttonVariant?: ButtonVariant;
|
||||||
|
buttonConfirm?: ConfirmConfig;
|
||||||
|
|
||||||
|
// number-input 타입 전용
|
||||||
|
inputUnit?: string;
|
||||||
|
limitColumn?: string;
|
||||||
|
autoInitMax?: boolean;
|
||||||
|
|
||||||
|
// cart-button 타입 전용
|
||||||
|
cartLabel?: string;
|
||||||
|
cartCancelLabel?: string;
|
||||||
|
cartIconType?: "lucide" | "emoji";
|
||||||
|
cartIconValue?: string;
|
||||||
|
|
||||||
|
// status-badge 타입 전용
|
||||||
|
statusColumn?: string;
|
||||||
|
statusMap?: Array<{ value: string; label: string; color: string }>;
|
||||||
|
|
||||||
|
// timeline 타입 전용: 공정 데이터 소스 설정
|
||||||
|
timelineSource?: TimelineDataSource;
|
||||||
|
processColumn?: string;
|
||||||
|
processStatusColumn?: string;
|
||||||
|
currentHighlight?: boolean;
|
||||||
|
visibleCount?: number;
|
||||||
|
timelinePriority?: "before" | "after";
|
||||||
|
showDetailModal?: boolean;
|
||||||
|
|
||||||
|
// action-buttons 타입 전용 (신규: 버튼 중심 구조)
|
||||||
|
actionButtons?: ActionButtonDef[];
|
||||||
|
// action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환)
|
||||||
|
actionRules?: Array<{
|
||||||
|
whenStatus: string;
|
||||||
|
buttons: Array<ActionButtonConfig>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// footer-status 타입 전용
|
||||||
|
footerLabel?: string;
|
||||||
|
footerStatusColumn?: string;
|
||||||
|
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
|
||||||
|
showTopBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonUpdate {
|
||||||
|
column: string;
|
||||||
|
value?: string;
|
||||||
|
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 액션 버튼 클릭 시 동작 모드
|
||||||
|
export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode";
|
||||||
|
|
||||||
|
// 액션 버튼 개별 설정
|
||||||
|
export interface ActionButtonConfig {
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
taskPreset: string;
|
||||||
|
confirm?: ConfirmConfig;
|
||||||
|
targetTable?: string;
|
||||||
|
confirmMessage?: string;
|
||||||
|
allowMultiSelect?: boolean;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
clickMode?: ActionButtonClickMode;
|
||||||
|
selectModeConfig?: SelectModeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 모드 설정
|
||||||
|
export interface SelectModeConfig {
|
||||||
|
filterStatus?: string;
|
||||||
|
buttons: Array<SelectModeButtonConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 모드 하단 버튼 설정
|
||||||
|
export interface SelectModeButtonConfig {
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
clickMode: "status-change" | "modal-open" | "cancel-select";
|
||||||
|
targetTable?: string;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
confirmMessage?: string;
|
||||||
|
modalScreenId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 버튼 중심 구조 (신규) =====
|
||||||
|
|
||||||
|
export interface ActionButtonShowCondition {
|
||||||
|
type: "timeline-status" | "column-value" | "always";
|
||||||
|
value?: string;
|
||||||
|
column?: string;
|
||||||
|
unmatchBehavior?: "hidden" | "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonClickAction {
|
||||||
|
type: "immediate" | "select-mode" | "modal-open";
|
||||||
|
targetTable?: string;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
confirmMessage?: string;
|
||||||
|
selectModeButtons?: SelectModeButtonConfig[];
|
||||||
|
modalScreenId?: string;
|
||||||
|
// 외부 테이블 조인 설정 (DB 직접 선택 시)
|
||||||
|
joinConfig?: {
|
||||||
|
sourceColumn: string; // 메인 테이블의 FK 컬럼
|
||||||
|
targetColumn: string; // 외부 테이블의 매칭 컬럼
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonDef {
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
showCondition?: ActionButtonShowCondition;
|
||||||
|
/** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */
|
||||||
|
clickAction: ActionButtonClickAction;
|
||||||
|
clickActions?: ActionButtonClickAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardGridConfigV2 {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
colWidths: string[];
|
||||||
|
rowHeights?: string[];
|
||||||
|
gap: number;
|
||||||
|
showCellBorder: boolean;
|
||||||
|
cells: CardCellDefinitionV2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- V2 카드 선택 동작 -----
|
||||||
|
|
||||||
|
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
|
||||||
|
|
||||||
|
export interface V2CardClickModalConfig {
|
||||||
|
screenId: string;
|
||||||
|
modalTitle?: string;
|
||||||
|
condition?: {
|
||||||
|
type: "timeline-status" | "column-value" | "always";
|
||||||
|
value?: string;
|
||||||
|
column?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- V2 오버플로우 설정 -----
|
||||||
|
|
||||||
|
export interface V2OverflowConfig {
|
||||||
|
mode: "loadMore" | "pagination";
|
||||||
|
visibleCount: number;
|
||||||
|
loadMoreCount?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- pop-card-list-v2 전체 설정 -----
|
||||||
|
|
||||||
|
export interface PopCardListV2Config {
|
||||||
|
dataSource: CardListDataSource;
|
||||||
|
cardGrid: CardGridConfigV2;
|
||||||
|
selectedColumns?: string[];
|
||||||
|
gridColumns?: number;
|
||||||
|
gridRows?: number;
|
||||||
|
scrollDirection?: CardScrollDirection;
|
||||||
|
/** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */
|
||||||
|
cardSize?: CardSize;
|
||||||
|
cardGap?: number;
|
||||||
|
overflow?: V2OverflowConfig;
|
||||||
|
cardClickAction?: V2CardClickAction;
|
||||||
|
cardClickModalConfig?: V2CardClickModalConfig;
|
||||||
|
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
|
||||||
|
hideUntilFiltered?: boolean;
|
||||||
|
responsiveDisplay?: CardResponsiveConfig;
|
||||||
|
inputField?: CardInputFieldConfig;
|
||||||
|
packageConfig?: CardPackageConfig;
|
||||||
|
cartAction?: CardCartActionConfig;
|
||||||
|
cartListMode?: CartListModeConfig;
|
||||||
|
saveMapping?: CardListSaveMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
|
||||||
|
export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
|
||||||
|
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
|
||||||
|
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
|
||||||
|
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -306,6 +307,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -339,6 +341,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -3054,6 +3057,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/react-reconciler": "^0.32.0",
|
"@types/react-reconciler": "^0.32.0",
|
||||||
|
|
@ -3707,6 +3711,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.6"
|
"@tanstack/query-core": "5.90.6"
|
||||||
},
|
},
|
||||||
|
|
@ -3774,6 +3779,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
|
@ -4087,6 +4093,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
|
@ -6587,6 +6594,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -6597,6 +6605,7 @@
|
||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6639,6 +6648,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
|
@ -6721,6 +6731,7 @@
|
||||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.2",
|
"@typescript-eslint/scope-manager": "8.46.2",
|
||||||
"@typescript-eslint/types": "8.46.2",
|
"@typescript-eslint/types": "8.46.2",
|
||||||
|
|
@ -7353,6 +7364,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -8503,7 +8515,8 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
"node_modules/d3": {
|
||||||
"version": "7.9.0",
|
"version": "7.9.0",
|
||||||
|
|
@ -8825,6 +8838,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -9584,6 +9598,7 @@
|
||||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -9672,6 +9687,7 @@
|
||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
|
|
@ -9773,6 +9789,7 @@
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|
@ -10944,6 +10961,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
|
|
@ -11724,7 +11742,8 @@
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
|
|
@ -13063,6 +13082,7 @@
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -13356,6 +13376,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13385,6 +13406,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^1.0.0",
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
|
@ -13433,6 +13455,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
|
|
@ -13636,6 +13659,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13705,6 +13729,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
|
|
@ -13755,6 +13780,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -13787,7 +13813,8 @@
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
|
|
@ -14095,6 +14122,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -14117,7 +14145,8 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/recharts/node_modules/redux-thunk": {
|
"node_modules/recharts/node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -15147,7 +15176,8 @@
|
||||||
"version": "0.180.0",
|
"version": "0.180.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/three-mesh-bvh": {
|
"node_modules/three-mesh-bvh": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
|
|
@ -15235,6 +15265,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -15583,6 +15614,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface LoginResponse {
|
||||||
token?: string;
|
token?: string;
|
||||||
userInfo?: any;
|
userInfo?: any;
|
||||||
firstMenuPath?: string | null;
|
firstMenuPath?: string | null;
|
||||||
|
popLandingPath?: string | null;
|
||||||
};
|
};
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue