Merge origin/main into ksh - resolve conflicts
This commit is contained in:
commit
2f66fe1913
|
|
@ -3,7 +3,8 @@
|
||||||
* 마스터 선택 시 여러 필드 자동 입력 기능
|
* 마스터 선택 시 여러 필드 자동 입력 기능
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -14,7 +15,10 @@ import logger from "../utils/logger";
|
||||||
/**
|
/**
|
||||||
* 자동 입력 그룹 목록 조회
|
* 자동 입력 그룹 목록 조회
|
||||||
*/
|
*/
|
||||||
export const getAutoFillGroups = async (req: Request, res: Response) => {
|
export const getAutoFillGroups = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const { isActive } = req.query;
|
const { isActive } = req.query;
|
||||||
|
|
@ -47,7 +51,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
const result = await query(sql, params);
|
const result = await query(sql, params);
|
||||||
|
|
||||||
logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode });
|
logger.info("자동 입력 그룹 목록 조회", {
|
||||||
|
count: result.length,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -66,7 +73,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||||
*/
|
*/
|
||||||
export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
|
export const getAutoFillGroupDetail = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -98,7 +108,10 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
|
||||||
WHERE group_code = $1 AND company_code = $2
|
WHERE group_code = $1 AND company_code = $2
|
||||||
ORDER BY sort_order, mapping_id
|
ORDER BY sort_order, mapping_id
|
||||||
`;
|
`;
|
||||||
const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]);
|
const mappingResult = await query(mappingSql, [
|
||||||
|
groupCode,
|
||||||
|
groupResult.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
|
@ -122,7 +135,9 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 그룹 코드 자동 생성 함수
|
* 그룹 코드 자동 생성 함수
|
||||||
*/
|
*/
|
||||||
const generateAutoFillGroupCode = async (companyCode: string): Promise<string> => {
|
const generateAutoFillGroupCode = async (
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string> => {
|
||||||
const prefix = "AF";
|
const prefix = "AF";
|
||||||
const result = await queryOne(
|
const result = await queryOne(
|
||||||
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
||||||
|
|
@ -136,7 +151,10 @@ const generateAutoFillGroupCode = async (companyCode: string): Promise<string> =
|
||||||
/**
|
/**
|
||||||
* 자동 입력 그룹 생성
|
* 자동 입력 그룹 생성
|
||||||
*/
|
*/
|
||||||
export const createAutoFillGroup = async (req: Request, res: Response) => {
|
export const createAutoFillGroup = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const userId = req.user?.userId || "system";
|
const userId = req.user?.userId || "system";
|
||||||
|
|
@ -153,7 +171,8 @@ export const createAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
if (!groupName || !masterTable || !masterValueColumn) {
|
if (!groupName || !masterTable || !masterValueColumn) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,7 +243,10 @@ export const createAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 자동 입력 그룹 수정
|
* 자동 입력 그룹 수정
|
||||||
*/
|
*/
|
||||||
export const updateAutoFillGroup = async (req: Request, res: Response) => {
|
export const updateAutoFillGroup = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -333,7 +355,10 @@ export const updateAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 자동 입력 그룹 삭제
|
* 자동 입력 그룹 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteAutoFillGroup = async (req: Request, res: Response) => {
|
export const deleteAutoFillGroup = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -382,7 +407,10 @@ export const deleteAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
* 마스터 옵션 목록 조회
|
* 마스터 옵션 목록 조회
|
||||||
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
||||||
*/
|
*/
|
||||||
export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
|
export const getAutoFillMasterOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -436,7 +464,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
const optionsResult = await query(optionsSql, optionsParams);
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length });
|
logger.info("자동 입력 마스터 옵션 조회", {
|
||||||
|
groupCode,
|
||||||
|
count: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -456,7 +487,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
|
||||||
* 자동 입력 데이터 조회
|
* 자동 입력 데이터 조회
|
||||||
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||||
*/
|
*/
|
||||||
export const getAutoFillData = async (req: Request, res: Response) => {
|
export const getAutoFillData = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const { masterValue } = req.query;
|
const { masterValue } = req.query;
|
||||||
|
|
@ -535,9 +569,10 @@ export const getAutoFillData = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
const sourceValue = dataResult?.[mapping.source_column];
|
const sourceValue = dataResult?.[mapping.source_column];
|
||||||
const finalValue = sourceValue !== null && sourceValue !== undefined
|
const finalValue =
|
||||||
? sourceValue
|
sourceValue !== null && sourceValue !== undefined
|
||||||
: mapping.default_value;
|
? sourceValue
|
||||||
|
: mapping.default_value;
|
||||||
|
|
||||||
autoFillData[mapping.target_field] = finalValue;
|
autoFillData[mapping.target_field] = finalValue;
|
||||||
mappingInfo.push({
|
mappingInfo.push({
|
||||||
|
|
@ -549,7 +584,11 @@ export const getAutoFillData = async (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length });
|
logger.info("자동 입력 데이터 조회", {
|
||||||
|
groupCode,
|
||||||
|
masterValue,
|
||||||
|
fieldCount: mappingInfo.length,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -565,4 +604,3 @@ export const getAutoFillData = async (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -14,7 +15,10 @@ import logger from "../utils/logger";
|
||||||
/**
|
/**
|
||||||
* 조건부 연쇄 규칙 목록 조회
|
* 조건부 연쇄 규칙 목록 조회
|
||||||
*/
|
*/
|
||||||
export const getConditions = async (req: Request, res: Response) => {
|
export const getConditions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const { isActive, relationCode, relationType } = req.query;
|
const { isActive, relationCode, relationType } = req.query;
|
||||||
|
|
@ -54,7 +58,10 @@ export const getConditions = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
const result = await query(sql, params);
|
const result = await query(sql, params);
|
||||||
|
|
||||||
logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode });
|
logger.info("조건부 연쇄 규칙 목록 조회", {
|
||||||
|
count: result.length,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -62,7 +69,7 @@ export const getConditions = async (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||||
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
});
|
});
|
||||||
|
|
@ -77,7 +84,10 @@ export const getConditions = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 조건부 연쇄 규칙 상세 조회
|
* 조건부 연쇄 규칙 상세 조회
|
||||||
*/
|
*/
|
||||||
export const getConditionDetail = async (req: Request, res: Response) => {
|
export const getConditionDetail = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { conditionId } = req.params;
|
const { conditionId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -118,7 +128,10 @@ export const getConditionDetail = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 조건부 연쇄 규칙 생성
|
* 조건부 연쇄 규칙 생성
|
||||||
*/
|
*/
|
||||||
export const createCondition = async (req: Request, res: Response) => {
|
export const createCondition = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const {
|
const {
|
||||||
|
|
@ -134,10 +147,18 @@ export const createCondition = async (req: Request, res: Response) => {
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) {
|
if (
|
||||||
|
!relationCode ||
|
||||||
|
!conditionName ||
|
||||||
|
!conditionField ||
|
||||||
|
!conditionValue ||
|
||||||
|
!filterColumn ||
|
||||||
|
!filterValues
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,7 +185,11 @@ export const createCondition = async (req: Request, res: Response) => {
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode });
|
logger.info("조건부 연쇄 규칙 생성", {
|
||||||
|
conditionId: result?.condition_id,
|
||||||
|
relationCode,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -184,7 +209,10 @@ export const createCondition = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 조건부 연쇄 규칙 수정
|
* 조건부 연쇄 규칙 수정
|
||||||
*/
|
*/
|
||||||
export const updateCondition = async (req: Request, res: Response) => {
|
export const updateCondition = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { conditionId } = req.params;
|
const { conditionId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -264,7 +292,10 @@ export const updateCondition = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 조건부 연쇄 규칙 삭제
|
* 조건부 연쇄 규칙 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteCondition = async (req: Request, res: Response) => {
|
export const deleteCondition = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { conditionId } = req.params;
|
const { conditionId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -312,7 +343,10 @@ export const deleteCondition = async (req: Request, res: Response) => {
|
||||||
* 조건에 따른 필터링된 옵션 조회
|
* 조건에 따른 필터링된 옵션 조회
|
||||||
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||||
*/
|
*/
|
||||||
export const getFilteredOptions = async (req: Request, res: Response) => {
|
export const getFilteredOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { relationCode } = req.params;
|
const { relationCode } = req.params;
|
||||||
const { conditionFieldValue, parentValue } = req.query;
|
const { conditionFieldValue, parentValue } = req.query;
|
||||||
|
|
@ -390,8 +424,12 @@ export const getFilteredOptions = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
// 조건부 필터 적용
|
// 조건부 필터 적용
|
||||||
if (matchedCondition) {
|
if (matchedCondition) {
|
||||||
const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim());
|
const filterValues = matchedCondition.filter_values
|
||||||
const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(",");
|
.split(",")
|
||||||
|
.map((v: string) => v.trim());
|
||||||
|
const placeholders = filterValues
|
||||||
|
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
|
||||||
|
.join(",");
|
||||||
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||||
optionsParams.push(...filterValues);
|
optionsParams.push(...filterValues);
|
||||||
optionsParamIndex += filterValues.length;
|
optionsParamIndex += filterValues.length;
|
||||||
|
|
@ -522,4 +560,3 @@ function evaluateCondition(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -14,7 +15,10 @@ import logger from "../utils/logger";
|
||||||
/**
|
/**
|
||||||
* 계층 그룹 목록 조회
|
* 계층 그룹 목록 조회
|
||||||
*/
|
*/
|
||||||
export const getHierarchyGroups = async (req: Request, res: Response) => {
|
export const getHierarchyGroups = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const { isActive, hierarchyType } = req.query;
|
const { isActive, hierarchyType } = req.query;
|
||||||
|
|
@ -66,7 +70,10 @@ export const getHierarchyGroups = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 계층 그룹 상세 조회 (레벨 포함)
|
* 계층 그룹 상세 조회 (레벨 포함)
|
||||||
*/
|
*/
|
||||||
export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
|
export const getHierarchyGroupDetail = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -124,7 +131,9 @@ export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 계층 그룹 코드 자동 생성 함수
|
* 계층 그룹 코드 자동 생성 함수
|
||||||
*/
|
*/
|
||||||
const generateHierarchyGroupCode = async (companyCode: string): Promise<string> => {
|
const generateHierarchyGroupCode = async (
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string> => {
|
||||||
const prefix = "HG";
|
const prefix = "HG";
|
||||||
const result = await queryOne(
|
const result = await queryOne(
|
||||||
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
||||||
|
|
@ -138,7 +147,10 @@ const generateHierarchyGroupCode = async (companyCode: string): Promise<string>
|
||||||
/**
|
/**
|
||||||
* 계층 그룹 생성
|
* 계층 그룹 생성
|
||||||
*/
|
*/
|
||||||
export const createHierarchyGroup = async (req: Request, res: Response) => {
|
export const createHierarchyGroup = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const userId = req.user?.userId || "system";
|
const userId = req.user?.userId || "system";
|
||||||
|
|
@ -280,7 +292,10 @@ export const createHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 계층 그룹 수정
|
* 계층 그룹 수정
|
||||||
*/
|
*/
|
||||||
export const updateHierarchyGroup = async (req: Request, res: Response) => {
|
export const updateHierarchyGroup = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -364,7 +379,10 @@ export const updateHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 계층 그룹 삭제
|
* 계층 그룹 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteHierarchyGroup = async (req: Request, res: Response) => {
|
export const deleteHierarchyGroup = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -423,7 +441,7 @@ export const deleteHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 레벨 추가
|
* 레벨 추가
|
||||||
*/
|
*/
|
||||||
export const addLevel = async (req: Request, res: Response) => {
|
export const addLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode } = req.params;
|
const { groupCode } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -506,7 +524,7 @@ export const addLevel = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 레벨 수정
|
* 레벨 수정
|
||||||
*/
|
*/
|
||||||
export const updateLevel = async (req: Request, res: Response) => {
|
export const updateLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { levelId } = req.params;
|
const { levelId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -600,7 +618,7 @@ export const updateLevel = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 레벨 삭제
|
* 레벨 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteLevel = async (req: Request, res: Response) => {
|
export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { levelId } = req.params;
|
const { levelId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -647,7 +665,10 @@ export const deleteLevel = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 특정 레벨의 옵션 조회
|
* 특정 레벨의 옵션 조회
|
||||||
*/
|
*/
|
||||||
export const getLevelOptions = async (req: Request, res: Response) => {
|
export const getLevelOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { groupCode, levelOrder } = req.params;
|
const { groupCode, levelOrder } = req.params;
|
||||||
const { parentValue } = req.query;
|
const { parentValue } = req.query;
|
||||||
|
|
@ -749,4 +770,3 @@ export const getLevelOptions = async (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -14,7 +15,10 @@ import logger from "../utils/logger";
|
||||||
/**
|
/**
|
||||||
* 상호 배제 규칙 목록 조회
|
* 상호 배제 규칙 목록 조회
|
||||||
*/
|
*/
|
||||||
export const getExclusions = async (req: Request, res: Response) => {
|
export const getExclusions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const { isActive } = req.query;
|
const { isActive } = req.query;
|
||||||
|
|
@ -42,7 +46,10 @@ export const getExclusions = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
const result = await query(sql, params);
|
const result = await query(sql, params);
|
||||||
|
|
||||||
logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode });
|
logger.info("상호 배제 규칙 목록 조회", {
|
||||||
|
count: result.length,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -61,7 +68,10 @@ export const getExclusions = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 상호 배제 규칙 상세 조회
|
* 상호 배제 규칙 상세 조회
|
||||||
*/
|
*/
|
||||||
export const getExclusionDetail = async (req: Request, res: Response) => {
|
export const getExclusionDetail = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { exclusionId } = req.params;
|
const { exclusionId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -116,7 +126,10 @@ const generateExclusionCode = async (companyCode: string): Promise<string> => {
|
||||||
/**
|
/**
|
||||||
* 상호 배제 규칙 생성
|
* 상호 배제 규칙 생성
|
||||||
*/
|
*/
|
||||||
export const createExclusion = async (req: Request, res: Response) => {
|
export const createExclusion = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const {
|
const {
|
||||||
|
|
@ -133,7 +146,8 @@ export const createExclusion = async (req: Request, res: Response) => {
|
||||||
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +209,10 @@ export const createExclusion = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 상호 배제 규칙 수정
|
* 상호 배제 규칙 수정
|
||||||
*/
|
*/
|
||||||
export const updateExclusion = async (req: Request, res: Response) => {
|
export const updateExclusion = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { exclusionId } = req.params;
|
const { exclusionId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -274,7 +291,10 @@ export const updateExclusion = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 상호 배제 규칙 삭제
|
* 상호 배제 규칙 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteExclusion = async (req: Request, res: Response) => {
|
export const deleteExclusion = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { exclusionId } = req.params;
|
const { exclusionId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -322,7 +342,10 @@ export const deleteExclusion = async (req: Request, res: Response) => {
|
||||||
* 상호 배제 검증
|
* 상호 배제 검증
|
||||||
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
||||||
*/
|
*/
|
||||||
export const validateExclusion = async (req: Request, res: Response) => {
|
export const validateExclusion = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { exclusionCode } = req.params;
|
const { exclusionCode } = req.params;
|
||||||
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
||||||
|
|
@ -347,7 +370,9 @@ export const validateExclusion = async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드명 파싱
|
// 필드명 파싱
|
||||||
const fields = exclusion.field_names.split(",").map((f: string) => f.trim());
|
const fields = exclusion.field_names
|
||||||
|
.split(",")
|
||||||
|
.map((f: string) => f.trim());
|
||||||
|
|
||||||
// 필드 값 수집
|
// 필드 값 수집
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
|
|
@ -418,7 +443,10 @@ export const validateExclusion = async (req: Request, res: Response) => {
|
||||||
* 필드에 대한 배제 옵션 조회
|
* 필드에 대한 배제 옵션 조회
|
||||||
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
||||||
*/
|
*/
|
||||||
export const getExcludedOptions = async (req: Request, res: Response) => {
|
export const getExcludedOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { exclusionCode } = req.params;
|
const { exclusionCode } = req.params;
|
||||||
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
||||||
|
|
@ -470,9 +498,14 @@ export const getExcludedOptions = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
// 이미 선택된 값 제외
|
// 이미 선택된 값 제외
|
||||||
if (selectedValues) {
|
if (selectedValues) {
|
||||||
const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v);
|
const excludeValues = (selectedValues as string)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v);
|
||||||
if (excludeValues.length > 0) {
|
if (excludeValues.length > 0) {
|
||||||
const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(",");
|
const placeholders = excludeValues
|
||||||
|
.map((_, i) => `$${optionsParamIndex + i}`)
|
||||||
|
.join(",");
|
||||||
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
||||||
optionsParams.push(...excludeValues);
|
optionsParams.push(...excludeValues);
|
||||||
}
|
}
|
||||||
|
|
@ -502,4 +535,3 @@ export const getExcludedOptions = async (req: Request, res: Response) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
|
@ -7,7 +8,10 @@ const pool = getPool();
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계 목록 조회
|
* 연쇄 관계 목록 조회
|
||||||
*/
|
*/
|
||||||
export const getCascadingRelations = async (req: Request, res: Response) => {
|
export const getCascadingRelations = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const { isActive } = req.query;
|
const { isActive } = req.query;
|
||||||
|
|
@ -86,7 +90,10 @@ export const getCascadingRelations = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계 상세 조회
|
* 연쇄 관계 상세 조회
|
||||||
*/
|
*/
|
||||||
export const getCascadingRelationById = async (req: Request, res: Response) => {
|
export const getCascadingRelationById = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -155,7 +162,7 @@ export const getCascadingRelationById = async (req: Request, res: Response) => {
|
||||||
* 연쇄 관계 코드로 조회
|
* 연쇄 관계 코드로 조회
|
||||||
*/
|
*/
|
||||||
export const getCascadingRelationByCode = async (
|
export const getCascadingRelationByCode = async (
|
||||||
req: Request,
|
req: AuthenticatedRequest,
|
||||||
res: Response
|
res: Response
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -223,7 +230,10 @@ export const getCascadingRelationByCode = async (
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계 생성
|
* 연쇄 관계 생성
|
||||||
*/
|
*/
|
||||||
export const createCascadingRelation = async (req: Request, res: Response) => {
|
export const createCascadingRelation = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const userId = req.user?.userId || "system";
|
const userId = req.user?.userId || "system";
|
||||||
|
|
@ -350,7 +360,10 @@ export const createCascadingRelation = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계 수정
|
* 연쇄 관계 수정
|
||||||
*/
|
*/
|
||||||
export const updateCascadingRelation = async (req: Request, res: Response) => {
|
export const updateCascadingRelation = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -474,7 +487,10 @@ export const updateCascadingRelation = async (req: Request, res: Response) => {
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계 삭제
|
* 연쇄 관계 삭제
|
||||||
*/
|
*/
|
||||||
export const deleteCascadingRelation = async (req: Request, res: Response) => {
|
export const deleteCascadingRelation = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -536,7 +552,10 @@ export const deleteCascadingRelation = async (req: Request, res: Response) => {
|
||||||
* 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
* 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||||
* parent_table에서 전체 옵션을 조회합니다.
|
* parent_table에서 전체 옵션을 조회합니다.
|
||||||
*/
|
*/
|
||||||
export const getParentOptions = async (req: Request, res: Response) => {
|
export const getParentOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
@ -644,7 +663,10 @@ export const getParentOptions = async (req: Request, res: Response) => {
|
||||||
* 연쇄 관계로 자식 옵션 조회
|
* 연쇄 관계로 자식 옵션 조회
|
||||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||||
*/
|
*/
|
||||||
export const getCascadingOptions = async (req: Request, res: Response) => {
|
export const getCascadingOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
const { parentValue } = req.query;
|
const { parentValue } = req.query;
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,25 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { pool, queryOne } from "../database/db";
|
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
|
||||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
|
||||||
|
|
||||||
// 외부 DB 커넥터를 가져오는 헬퍼 함수
|
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
|
||||||
export async function getExternalDbConnector(connectionId: number) {
|
export async function getExternalDbConnector(connectionId: number) {
|
||||||
// 외부 DB 연결 정보 조회
|
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||||
const connection = await queryOne<any>(
|
|
||||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
|
||||||
[connectionId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connection) {
|
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
|
||||||
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
|
return {
|
||||||
}
|
executeQuery: async (sql: string, params?: any[]) => {
|
||||||
|
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||||
// 패스워드 복호화
|
return { rows: result };
|
||||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
},
|
||||||
|
|
||||||
// DB 연결 설정
|
|
||||||
const config = {
|
|
||||||
host: connection.host,
|
|
||||||
port: connection.port,
|
|
||||||
user: connection.username,
|
|
||||||
password: decryptedPassword,
|
|
||||||
database: connection.database_name,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// DB 커넥터 생성
|
|
||||||
return await DatabaseConnectorFactory.createConnector(
|
|
||||||
connection.db_type || "mariadb",
|
|
||||||
config,
|
|
||||||
connectionId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 계층 구조 데이터 조회 (범용)
|
// 동적 계층 구조 데이터 조회 (범용)
|
||||||
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
|
export const getHierarchyData = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||||
|
|
||||||
|
|
@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
const config = JSON.parse(hierarchyConfig);
|
const config = JSON.parse(hierarchyConfig);
|
||||||
|
|
||||||
const result: any = {
|
const result: any = {
|
||||||
|
|
@ -69,7 +53,7 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
for (const level of config.levels) {
|
for (const level of config.levels) {
|
||||||
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
||||||
const levelResult = await connector.executeQuery(levelQuery);
|
const levelResult = await connector.executeQuery(levelQuery);
|
||||||
|
|
||||||
result.levels.push({
|
result.levels.push({
|
||||||
level: level.level,
|
level: level.level,
|
||||||
name: level.name,
|
name: level.name,
|
||||||
|
|
@ -94,7 +78,10 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
logger.info("동적 계층 구조 데이터 조회", {
|
logger.info("동적 계층 구조 데이터 조회", {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
warehouseCount: result.warehouse?.length || 0,
|
warehouseCount: result.warehouse?.length || 0,
|
||||||
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
|
levelCounts: result.levels.map((l: any) => ({
|
||||||
|
level: l.level,
|
||||||
|
count: l.data.length,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
};
|
};
|
||||||
|
|
||||||
// 특정 레벨의 하위 데이터 조회
|
// 특정 레벨의 하위 데이터 조회
|
||||||
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
|
export const getChildrenData = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
|
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
|
if (
|
||||||
|
!externalDbConnectionId ||
|
||||||
|
!hierarchyConfig ||
|
||||||
|
!parentLevel ||
|
||||||
|
!parentKey
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
const config = JSON.parse(hierarchyConfig);
|
const config = JSON.parse(hierarchyConfig);
|
||||||
|
|
||||||
// 다음 레벨 찾기
|
// 다음 레벨 찾기
|
||||||
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
|
const nextLevel = config.levels?.find(
|
||||||
|
(l: any) => l.level === parentLevel + 1
|
||||||
|
);
|
||||||
|
|
||||||
if (!nextLevel) {
|
if (!nextLevel) {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise<Resp
|
||||||
};
|
};
|
||||||
|
|
||||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
export const getWarehouses = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, tableName } = req.query;
|
const { externalDbConnectionId, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
// 테이블명을 사용하여 모든 컬럼 조회
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||||
|
|
@ -215,7 +220,10 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
|
||||||
};
|
};
|
||||||
|
|
||||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
export const getAreas = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
|
|
@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
|
||||||
};
|
};
|
||||||
|
|
||||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
export const getLocations = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
|
|
@ -301,28 +316,38 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||||
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
export const getMaterials = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
locaKey,
|
locaKey,
|
||||||
tableName,
|
tableName,
|
||||||
keyColumn,
|
keyColumn,
|
||||||
locationKeyColumn,
|
locationKeyColumn,
|
||||||
layerColumn
|
layerColumn,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
|
if (
|
||||||
|
!externalDbConnectionId ||
|
||||||
|
!locaKey ||
|
||||||
|
!tableName ||
|
||||||
|
!locationKeyColumn
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
// 동적 쿼리 생성
|
// 동적 쿼리 생성
|
||||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
|
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||||
|
|
@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||||
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
export const getMaterialCounts = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||||
|
|
||||||
|
|
@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,64 @@ export const uploadFiles = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||||
|
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
|
||||||
|
|
||||||
|
// 🔍 디버깅: 레코드 모드 조건 확인
|
||||||
|
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
|
||||||
|
isRecordMode,
|
||||||
|
linkedTable,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
finalTargetObjid,
|
||||||
|
"req.body.isRecordMode": req.body.isRecordMode,
|
||||||
|
"req.body.linkedTable": req.body.linkedTable,
|
||||||
|
"req.body.recordId": req.body.recordId,
|
||||||
|
"req.body.columnName": req.body.columnName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRecordMode && linkedTable && recordId && columnName) {
|
||||||
|
try {
|
||||||
|
// 해당 레코드의 모든 첨부파일 조회
|
||||||
|
const allFiles = await query<any>(
|
||||||
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||||
|
FROM attach_file_info
|
||||||
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||||
|
ORDER BY regdate DESC`,
|
||||||
|
[finalTargetObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// attachments JSONB 형태로 변환
|
||||||
|
const attachmentsJson = allFiles.map((f: any) => ({
|
||||||
|
objid: f.objid.toString(),
|
||||||
|
realFileName: f.real_file_name,
|
||||||
|
fileSize: Number(f.file_size),
|
||||||
|
fileExt: f.file_ext,
|
||||||
|
filePath: f.file_path,
|
||||||
|
regdate: f.regdate?.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 테이블의 attachments 컬럼 업데이트
|
||||||
|
// 🔒 멀티테넌시: company_code 필터 추가
|
||||||
|
await query(
|
||||||
|
`UPDATE ${linkedTable}
|
||||||
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3`,
|
||||||
|
[JSON.stringify(attachmentsJson), recordId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
|
||||||
|
tableName: linkedTable,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
fileCount: attachmentsJson.length,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
|
||||||
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${files.length}개 파일 업로드 완료`,
|
message: `${files.length}개 파일 업로드 완료`,
|
||||||
|
|
@ -405,6 +463,56 @@ export const deleteFile = async (
|
||||||
["DELETED", parseInt(objid)]
|
["DELETED", parseInt(objid)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||||
|
const targetObjid = fileRecord.target_objid;
|
||||||
|
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
||||||
|
// targetObjid 파싱: tableName:recordId:columnName 형식
|
||||||
|
const parts = targetObjid.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const [tableName, recordId, columnName] = parts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 해당 레코드의 남은 첨부파일 조회
|
||||||
|
const remainingFiles = await query<any>(
|
||||||
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||||
|
FROM attach_file_info
|
||||||
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||||
|
ORDER BY regdate DESC`,
|
||||||
|
[targetObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// attachments JSONB 형태로 변환
|
||||||
|
const attachmentsJson = remainingFiles.map((f: any) => ({
|
||||||
|
objid: f.objid.toString(),
|
||||||
|
realFileName: f.real_file_name,
|
||||||
|
fileSize: Number(f.file_size),
|
||||||
|
fileExt: f.file_ext,
|
||||||
|
filePath: f.file_path,
|
||||||
|
regdate: f.regdate?.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 테이블의 attachments 컬럼 업데이트
|
||||||
|
// 🔒 멀티테넌시: company_code 필터 추가
|
||||||
|
await query(
|
||||||
|
`UPDATE ${tableName}
|
||||||
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3`,
|
||||||
|
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
remainingFiles: attachmentsJson.length,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
||||||
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "파일이 삭제되었습니다.",
|
message: "파일이 삭제되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,4 @@ router.get("/data/:groupCode", getAutoFillData);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,4 @@ router.get("/options/:exclusionCode", getExcludedOptions);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,21 @@ export class AdminService {
|
||||||
|
|
||||||
// menuType에 따른 WHERE 조건 생성
|
// menuType에 따른 WHERE 조건 생성
|
||||||
const menuTypeCondition =
|
const menuTypeCondition =
|
||||||
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
|
menuType !== undefined
|
||||||
|
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
|
||||||
|
: "1 = 1";
|
||||||
|
|
||||||
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
||||||
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
||||||
const includeInactive = paramMap.includeInactive === true;
|
const includeInactive = paramMap.includeInactive === true;
|
||||||
const isManagementScreen = includeInactive || menuType === undefined;
|
const isManagementScreen = includeInactive || menuType === undefined;
|
||||||
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
||||||
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
|
const statusCondition = isManagementScreen
|
||||||
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
|
? "1 = 1"
|
||||||
|
: "MENU.STATUS = 'active'";
|
||||||
|
const subStatusCondition = isManagementScreen
|
||||||
|
? "1 = 1"
|
||||||
|
: "MENU_SUB.STATUS = 'active'";
|
||||||
|
|
||||||
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
||||||
let authFilter = "";
|
let authFilter = "";
|
||||||
|
|
@ -35,7 +41,11 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
|
if (
|
||||||
|
menuType !== undefined &&
|
||||||
|
userType !== "SUPER_ADMIN" &&
|
||||||
|
!isManagementScreen
|
||||||
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
||||||
const userRoleGroups = await query<any>(
|
const userRoleGroups = await query<any>(
|
||||||
`
|
`
|
||||||
|
|
@ -56,45 +66,45 @@ export class AdminService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userType === "COMPANY_ADMIN") {
|
if (userType === "COMPANY_ADMIN") {
|
||||||
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
|
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||||
if (userRoleGroups.length > 0) {
|
if (userRoleGroups.length > 0) {
|
||||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||||
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
|
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
|
||||||
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
|
authFilter = `
|
||||||
|
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM rel_menu_auth rma
|
||||||
|
WHERE rma.menu_objid = MENU.OBJID
|
||||||
|
AND rma.auth_objid = ANY($${paramIndex + 1})
|
||||||
|
AND rma.read_yn = 'Y'
|
||||||
|
)
|
||||||
|
`;
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
const companyParamIndex = paramIndex;
|
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
// 하위 메뉴도 권한 체크
|
||||||
unionFilter = `
|
unionFilter = `
|
||||||
AND (
|
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
|
||||||
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
|
AND EXISTS (
|
||||||
OR (
|
SELECT 1
|
||||||
MENU_SUB.COMPANY_CODE = '*'
|
FROM rel_menu_auth rma
|
||||||
AND EXISTS (
|
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||||
SELECT 1
|
AND rma.auth_objid = ANY($${paramIndex})
|
||||||
FROM rel_menu_auth rma
|
AND rma.read_yn = 'Y'
|
||||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
|
||||||
AND rma.auth_objid = ANY($${paramIndex})
|
|
||||||
AND rma.read_yn = 'Y'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
queryParams.push(roleObjids);
|
queryParams.push(roleObjids);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
|
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
|
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
|
||||||
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
logger.warn(
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
|
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
||||||
queryParams.push(userCompanyCode);
|
|
||||||
paramIndex++;
|
|
||||||
logger.info(
|
|
||||||
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
|
|
||||||
);
|
);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자: 권한 그룹 필수
|
// 일반 사용자: 권한 그룹 필수
|
||||||
|
|
@ -131,7 +141,11 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
|
} else if (
|
||||||
|
menuType !== undefined &&
|
||||||
|
userType === "SUPER_ADMIN" &&
|
||||||
|
!isManagementScreen
|
||||||
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||||
|
|
@ -167,7 +181,7 @@ export class AdminService {
|
||||||
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
|
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
|
||||||
if (unionFilter === "") {
|
if (unionFilter === "") {
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
|
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
|
||||||
|
|
|
||||||
|
|
@ -903,6 +903,9 @@ export class DynamicFormService {
|
||||||
return `${key} = $${index + 1}::numeric`;
|
return `${key} = $${index + 1}::numeric`;
|
||||||
} else if (dataType === "boolean") {
|
} else if (dataType === "boolean") {
|
||||||
return `${key} = $${index + 1}::boolean`;
|
return `${key} = $${index + 1}::boolean`;
|
||||||
|
} else if (dataType === "jsonb" || dataType === "json") {
|
||||||
|
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
|
||||||
|
return `${key} = $${index + 1}::jsonb`;
|
||||||
} else {
|
} else {
|
||||||
// 문자열 타입은 캐스팅 불필요
|
// 문자열 타입은 캐스팅 불필요
|
||||||
return `${key} = $${index + 1}`;
|
return `${key} = $${index + 1}`;
|
||||||
|
|
@ -910,7 +913,21 @@ export class DynamicFormService {
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const values: any[] = Object.values(changedFields);
|
// 🆕 JSONB 타입 값은 JSON 문자열로 변환
|
||||||
|
const values: any[] = Object.keys(changedFields).map((key) => {
|
||||||
|
const value = changedFields[key];
|
||||||
|
const dataType = columnTypes[key];
|
||||||
|
|
||||||
|
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
|
||||||
|
if (
|
||||||
|
(dataType === "jsonb" || dataType === "json") &&
|
||||||
|
(Array.isArray(value) ||
|
||||||
|
(typeof value === "object" && value !== null))
|
||||||
|
) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
values.push(id); // WHERE 조건용 ID 추가
|
values.push(id); // WHERE 조건용 ID 추가
|
||||||
|
|
||||||
// 🔑 Primary Key 타입에 맞게 캐스팅
|
// 🔑 Primary Key 타입에 맞게 캐스팅
|
||||||
|
|
@ -1575,6 +1592,7 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제어관리 실행 (화면에 설정된 경우)
|
* 제어관리 실행 (화면에 설정된 경우)
|
||||||
|
* 다중 제어를 순서대로 순차 실행 지원
|
||||||
*/
|
*/
|
||||||
private async executeDataflowControlIfConfigured(
|
private async executeDataflowControlIfConfigured(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
@ -1616,105 +1634,67 @@ export class DynamicFormService {
|
||||||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||||||
hasDiagramId:
|
hasDiagramId:
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||||||
|
hasFlowControls:
|
||||||
|
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
properties?.componentType === "button-primary" &&
|
||||||
properties?.componentConfig?.action?.type === "save" &&
|
properties?.componentConfig?.action?.type === "save" &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true &&
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
|
|
||||||
) {
|
) {
|
||||||
controlConfigFound = true;
|
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||||
const diagramId =
|
|
||||||
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
|
|
||||||
const relationshipId =
|
|
||||||
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
|
|
||||||
|
|
||||||
console.log(`🎯 제어관리 설정 발견:`, {
|
// 다중 제어 설정 확인 (flowControls 배열)
|
||||||
componentId: layout.component_id,
|
const flowControls = dataflowConfig?.flowControls || [];
|
||||||
diagramId,
|
|
||||||
relationshipId,
|
|
||||||
triggerType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
|
||||||
let controlResult: any;
|
if (flowControls.length > 0) {
|
||||||
|
controlConfigFound = true;
|
||||||
|
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`);
|
||||||
|
|
||||||
if (!relationshipId) {
|
// 순서대로 정렬
|
||||||
// 노드 플로우 실행
|
const sortedControls = [...flowControls].sort(
|
||||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
(a: any, b: any) => (a.order || 0) - (b.order || 0)
|
||||||
const { NodeFlowExecutionService } = await import(
|
|
||||||
"./nodeFlowExecutionService"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
// 다중 제어 순차 실행
|
||||||
|
await this.executeMultipleFlowControls(
|
||||||
|
sortedControls,
|
||||||
|
savedData,
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
triggerType,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
} else if (dataflowConfig?.selectedDiagramId) {
|
||||||
|
// 기존 단일 제어 실행 (하위 호환성)
|
||||||
|
controlConfigFound = true;
|
||||||
|
const diagramId = dataflowConfig.selectedDiagramId;
|
||||||
|
const relationshipId = dataflowConfig.selectedRelationshipId;
|
||||||
|
|
||||||
|
console.log(`🎯 단일 제어관리 설정 발견:`, {
|
||||||
|
componentId: layout.component_id,
|
||||||
diagramId,
|
diagramId,
|
||||||
{
|
relationshipId,
|
||||||
sourceData: [savedData],
|
triggerType,
|
||||||
dataSourceType: "formData",
|
});
|
||||||
buttonId: "save-button",
|
|
||||||
screenId: screenId,
|
|
||||||
userId: userId,
|
|
||||||
companyCode: companyCode,
|
|
||||||
formData: savedData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
controlResult = {
|
await this.executeSingleFlowControl(
|
||||||
success: executionResult.success,
|
diagramId,
|
||||||
message: executionResult.message,
|
relationshipId,
|
||||||
executedActions: executionResult.nodes?.map((node) => ({
|
savedData,
|
||||||
nodeId: node.nodeId,
|
screenId,
|
||||||
status: node.status,
|
tableName,
|
||||||
duration: node.duration,
|
triggerType,
|
||||||
})),
|
userId,
|
||||||
errors: executionResult.nodes
|
companyCode
|
||||||
?.filter((node) => node.status === "failed")
|
|
||||||
.map((node) => node.error || "실행 실패"),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 관계 기반 제어관리 실행
|
|
||||||
console.log(
|
|
||||||
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
|
||||||
);
|
);
|
||||||
controlResult =
|
|
||||||
await this.dataflowControlService.executeDataflowControl(
|
|
||||||
diagramId,
|
|
||||||
relationshipId,
|
|
||||||
triggerType,
|
|
||||||
savedData,
|
|
||||||
tableName,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
// 첫 번째 설정된 버튼의 제어관리만 실행
|
||||||
|
|
||||||
if (controlResult.success) {
|
|
||||||
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
|
|
||||||
if (
|
|
||||||
controlResult.executedActions &&
|
|
||||||
controlResult.executedActions.length > 0
|
|
||||||
) {
|
|
||||||
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
|
|
||||||
if (controlResult.errors && controlResult.errors.length > 0) {
|
|
||||||
console.warn(
|
|
||||||
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
|
||||||
controlResult.errors
|
|
||||||
);
|
|
||||||
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
|
|
||||||
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
|
||||||
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
|
|
||||||
}
|
|
||||||
|
|
||||||
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1728,6 +1708,218 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 제어 순차 실행
|
||||||
|
*/
|
||||||
|
private async executeMultipleFlowControls(
|
||||||
|
flowControls: Array<{
|
||||||
|
id: string;
|
||||||
|
flowId: number;
|
||||||
|
flowName: string;
|
||||||
|
executionTiming: string;
|
||||||
|
order: number;
|
||||||
|
}>,
|
||||||
|
savedData: Record<string, any>,
|
||||||
|
screenId: number,
|
||||||
|
tableName: string,
|
||||||
|
triggerType: "insert" | "update" | "delete",
|
||||||
|
userId: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`);
|
||||||
|
|
||||||
|
const { NodeFlowExecutionService } = await import(
|
||||||
|
"./nodeFlowExecutionService"
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
order: number;
|
||||||
|
flowId: number;
|
||||||
|
flowName: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
duration: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < flowControls.length; i++) {
|
||||||
|
const control = flowControls[i];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 유효하지 않은 flowId 스킵
|
||||||
|
if (!control.flowId || control.flowId <= 0) {
|
||||||
|
console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`);
|
||||||
|
results.push({
|
||||||
|
order: control.order,
|
||||||
|
flowId: control.flowId,
|
||||||
|
flowName: control.flowName,
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 flowId",
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
control.flowId,
|
||||||
|
{
|
||||||
|
sourceData: [savedData],
|
||||||
|
dataSourceType: "formData",
|
||||||
|
buttonId: "save-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: savedData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
order: control.order,
|
||||||
|
flowId: control.flowId,
|
||||||
|
flowName: control.flowName,
|
||||||
|
success: executionResult.success,
|
||||||
|
message: executionResult.message,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (executionResult.success) {
|
||||||
|
console.log(
|
||||||
|
`✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}`
|
||||||
|
);
|
||||||
|
// 이전 제어 실패 시 다음 제어 실행 중단
|
||||||
|
console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.error(
|
||||||
|
`❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
order: control.order,
|
||||||
|
flowId: control.flowId,
|
||||||
|
flowName: control.flowName,
|
||||||
|
success: false,
|
||||||
|
message: error.message || "실행 오류",
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 오류 발생 시 다음 제어 실행 중단
|
||||||
|
console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행 결과 요약
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failCount = results.filter((r) => !r.success).length;
|
||||||
|
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||||
|
|
||||||
|
console.log(`\n📊 다중 제어 실행 완료:`, {
|
||||||
|
total: flowControls.length,
|
||||||
|
executed: results.length,
|
||||||
|
success: successCount,
|
||||||
|
failed: failCount,
|
||||||
|
totalDuration: `${totalDuration}ms`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 제어 실행 (기존 로직, 하위 호환성)
|
||||||
|
*/
|
||||||
|
private async executeSingleFlowControl(
|
||||||
|
diagramId: number,
|
||||||
|
relationshipId: string | null,
|
||||||
|
savedData: Record<string, any>,
|
||||||
|
screenId: number,
|
||||||
|
tableName: string,
|
||||||
|
triggerType: "insert" | "update" | "delete",
|
||||||
|
userId: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
let controlResult: any;
|
||||||
|
|
||||||
|
if (!relationshipId) {
|
||||||
|
// 노드 플로우 실행
|
||||||
|
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||||
|
const { NodeFlowExecutionService } = await import(
|
||||||
|
"./nodeFlowExecutionService"
|
||||||
|
);
|
||||||
|
|
||||||
|
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
diagramId,
|
||||||
|
{
|
||||||
|
sourceData: [savedData],
|
||||||
|
dataSourceType: "formData",
|
||||||
|
buttonId: "save-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: savedData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
controlResult = {
|
||||||
|
success: executionResult.success,
|
||||||
|
message: executionResult.message,
|
||||||
|
executedActions: executionResult.nodes?.map((node) => ({
|
||||||
|
nodeId: node.nodeId,
|
||||||
|
status: node.status,
|
||||||
|
duration: node.duration,
|
||||||
|
})),
|
||||||
|
errors: executionResult.nodes
|
||||||
|
?.filter((node) => node.status === "failed")
|
||||||
|
.map((node) => node.error || "실행 실패"),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 관계 기반 제어관리 실행
|
||||||
|
console.log(
|
||||||
|
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
||||||
|
);
|
||||||
|
controlResult = await this.dataflowControlService.executeDataflowControl(
|
||||||
|
diagramId,
|
||||||
|
relationshipId,
|
||||||
|
triggerType,
|
||||||
|
savedData,
|
||||||
|
tableName,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||||||
|
|
||||||
|
if (controlResult.success) {
|
||||||
|
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
|
||||||
|
if (
|
||||||
|
controlResult.executedActions &&
|
||||||
|
controlResult.executedActions.length > 0
|
||||||
|
) {
|
||||||
|
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controlResult.errors && controlResult.errors.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
||||||
|
controlResult.errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 테이블의 특정 필드 값만 업데이트
|
* 특정 테이블의 특정 필드 값만 업데이트
|
||||||
* (다른 테이블의 레코드 업데이트 지원)
|
* (다른 테이블의 레코드 업데이트 지원)
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ export class EntityJoinService {
|
||||||
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
|
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
|
||||||
logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`);
|
logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`);
|
||||||
|
|
||||||
// 참조 테이블의 모든 컬럼 이름 가져오기
|
// 참조 테이블의 모든 컬럼 이름 가져오기
|
||||||
const tableColumnsResult = await query<{ column_name: string }>(
|
const tableColumnsResult = await query<{ column_name: string }>(
|
||||||
|
|
@ -148,10 +148,34 @@ export class EntityJoinService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tableColumnsResult.length > 0) {
|
if (tableColumnsResult.length > 0) {
|
||||||
displayColumns = tableColumnsResult.map((col) => col.column_name);
|
const allColumns = tableColumnsResult.map((col) => col.column_name);
|
||||||
|
|
||||||
|
// 🆕 표시용 컬럼 자동 감지 (우선순위 순서)
|
||||||
|
// 1. *_name 컬럼 (item_name, customer_name 등)
|
||||||
|
// 2. name 컬럼
|
||||||
|
// 3. label 컬럼
|
||||||
|
// 4. title 컬럼
|
||||||
|
// 5. 참조 컬럼 (referenceColumn)
|
||||||
|
const nameColumn = allColumns.find(
|
||||||
|
(col) => col.endsWith("_name") && col !== "company_name"
|
||||||
|
);
|
||||||
|
const simpleNameColumn = allColumns.find((col) => col === "name");
|
||||||
|
const labelColumn = allColumns.find(
|
||||||
|
(col) => col === "label" || col.endsWith("_label")
|
||||||
|
);
|
||||||
|
const titleColumn = allColumns.find((col) => col === "title");
|
||||||
|
|
||||||
|
// 우선순위에 따라 표시 컬럼 선택
|
||||||
|
const displayColumn =
|
||||||
|
nameColumn ||
|
||||||
|
simpleNameColumn ||
|
||||||
|
labelColumn ||
|
||||||
|
titleColumn ||
|
||||||
|
referenceColumn;
|
||||||
|
displayColumns = [displayColumn];
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
|
`✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
|
||||||
displayColumns.join(", ")
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 테이블 컬럼을 못 찾으면 기본값 사용
|
// 테이블 컬럼을 못 찾으면 기본값 사용
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
lastUsedAt: Date;
|
lastUsedAt: Date;
|
||||||
activeConnections = 0;
|
activeConnections = 0;
|
||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
|
private isPoolClosed = false;
|
||||||
|
|
||||||
constructor(config: ExternalDbConnection) {
|
constructor(config: ExternalDbConnection) {
|
||||||
this.connectionId = config.id!;
|
this.connectionId = config.id!;
|
||||||
|
|
@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
connectTimeout: (config.connection_timeout || 30) * 1000,
|
connectTimeout: (config.connection_timeout || 30) * 1000,
|
||||||
|
// 연결 유지 및 자동 재연결 설정
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
|
||||||
ssl:
|
ssl:
|
||||||
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -153,11 +157,33 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
|
|
||||||
async query(sql: string, params?: any[]): Promise<any> {
|
async query(sql: string, params?: any[]): Promise<any> {
|
||||||
this.lastUsedAt = new Date();
|
this.lastUsedAt = new Date();
|
||||||
|
|
||||||
|
// 연결 풀이 닫힌 상태인지 확인
|
||||||
|
if (this.isPoolClosed) {
|
||||||
|
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const [rows] = await this.pool.execute(sql, params);
|
const [rows] = await this.pool.execute(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
// 연결 닫힘 오류 감지
|
||||||
|
if (
|
||||||
|
error.message.includes("closed state") ||
|
||||||
|
error.code === "PROTOCOL_CONNECTION_LOST" ||
|
||||||
|
error.code === "ECONNRESET"
|
||||||
|
) {
|
||||||
|
this.isPoolClosed = true;
|
||||||
|
logger.warn(
|
||||||
|
`[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
this.isPoolClosed = true;
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
||||||
|
|
@ -165,6 +191,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
isHealthy(): boolean {
|
isHealthy(): boolean {
|
||||||
|
// 연결 풀이 닫혔으면 비정상
|
||||||
|
if (this.isPoolClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return this.activeConnections < this.maxConnections;
|
return this.activeConnections < this.maxConnections;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,9 +260,11 @@ export class ExternalDbConnectionPoolService {
|
||||||
): Promise<ConnectionPoolWrapper> {
|
): Promise<ConnectionPoolWrapper> {
|
||||||
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
||||||
|
|
||||||
// DB 연결 정보 조회
|
// DB 연결 정보 조회 (실제 비밀번호 포함)
|
||||||
const connectionResult =
|
const connectionResult =
|
||||||
await ExternalDbConnectionService.getConnectionById(connectionId);
|
await ExternalDbConnectionService.getConnectionByIdWithPassword(
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
if (!connectionResult.success || !connectionResult.data) {
|
if (!connectionResult.success || !connectionResult.data) {
|
||||||
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
||||||
|
|
@ -296,16 +328,19 @@ export class ExternalDbConnectionPoolService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿼리 실행 (자동으로 연결 풀 관리)
|
* 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직)
|
||||||
*/
|
*/
|
||||||
async executeQuery(
|
async executeQuery(
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
sql: string,
|
sql: string,
|
||||||
params?: any[]
|
params?: any[],
|
||||||
|
retryCount = 0
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const pool = await this.getPool(connectionId);
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const pool = await this.getPool(connectionId);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
||||||
);
|
);
|
||||||
|
|
@ -314,7 +349,29 @@ export class ExternalDbConnectionPoolService {
|
||||||
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
// 연결 끊김 오류인 경우 재시도
|
||||||
|
const isConnectionError =
|
||||||
|
error.message?.includes("closed state") ||
|
||||||
|
error.message?.includes("연결 풀이 닫힌 상태") ||
|
||||||
|
error.code === "PROTOCOL_CONNECTION_LOST" ||
|
||||||
|
error.code === "ECONNRESET" ||
|
||||||
|
error.code === "ETIMEDOUT";
|
||||||
|
|
||||||
|
if (isConnectionError && retryCount < MAX_RETRIES) {
|
||||||
|
logger.warn(
|
||||||
|
`🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 풀 제거 후 새로 생성
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
|
||||||
|
// 잠시 대기 후 재시도
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
return this.executeQuery(connectionId, sql, params, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3596,7 +3596,7 @@ export class NodeFlowExecutionService {
|
||||||
// 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정
|
// 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정
|
||||||
let accountId = nodeAccountId || smtpConfigId;
|
let accountId = nodeAccountId || smtpConfigId;
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
const accounts = await mailAccountFileService.getAccounts();
|
const accounts = await mailAccountFileService.getAllAccounts();
|
||||||
const activeAccount = accounts.find(
|
const activeAccount = accounts.find(
|
||||||
(acc: any) => acc.status === "active"
|
(acc: any) => acc.status === "active"
|
||||||
);
|
);
|
||||||
|
|
@ -4216,7 +4216,7 @@ export class NodeFlowExecutionService {
|
||||||
return this.evaluateFunction(func, sourceRow, targetRow, resultValues);
|
return this.evaluateFunction(func, sourceRow, targetRow, resultValues);
|
||||||
|
|
||||||
case "condition":
|
case "condition":
|
||||||
return this.evaluateCondition(
|
return this.evaluateCaseCondition(
|
||||||
condition,
|
condition,
|
||||||
sourceRow,
|
sourceRow,
|
||||||
targetRow,
|
targetRow,
|
||||||
|
|
@ -4393,7 +4393,7 @@ export class NodeFlowExecutionService {
|
||||||
/**
|
/**
|
||||||
* 조건 평가 (CASE WHEN ... THEN ... ELSE)
|
* 조건 평가 (CASE WHEN ... THEN ... ELSE)
|
||||||
*/
|
*/
|
||||||
private static evaluateCondition(
|
private static evaluateCaseCondition(
|
||||||
condition: any,
|
condition: any,
|
||||||
sourceRow: any,
|
sourceRow: any,
|
||||||
targetRow: any,
|
targetRow: any,
|
||||||
|
|
|
||||||
|
|
@ -798,7 +798,12 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
|
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
|
||||||
await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode);
|
await this.syncScreenLayoutsInputType(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
inputType,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
||||||
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
||||||
|
|
@ -928,7 +933,11 @@ export class TableManagementService {
|
||||||
`UPDATE screen_layouts
|
`UPDATE screen_layouts
|
||||||
SET properties = $1, component_type = $2
|
SET properties = $1, component_type = $2
|
||||||
WHERE layout_id = $3`,
|
WHERE layout_id = $3`,
|
||||||
[JSON.stringify(updatedProperties), newComponentType, layout.layout_id]
|
[
|
||||||
|
JSON.stringify(updatedProperties),
|
||||||
|
newComponentType,
|
||||||
|
layout.layout_id,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -1299,18 +1308,30 @@ export class TableManagementService {
|
||||||
try {
|
try {
|
||||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||||
if (typeof value === "string" && value.includes("|")) {
|
if (typeof value === "string" && value.includes("|")) {
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
const columnInfo = await this.getColumnWebTypeInfo(
|
||||||
|
tableName,
|
||||||
|
columnName
|
||||||
|
);
|
||||||
|
|
||||||
// 날짜 타입이면 날짜 범위로 처리
|
// 날짜 타입이면 날짜 범위로 처리
|
||||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
if (
|
||||||
|
columnInfo &&
|
||||||
|
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
|
||||||
|
) {
|
||||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그 외 타입이면 다중선택(IN 조건)으로 처리
|
// 그 외 타입이면 다중선택(IN 조건)으로 처리
|
||||||
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
|
const multiValues = value
|
||||||
|
.split("|")
|
||||||
|
.filter((v: string) => v.trim() !== "");
|
||||||
if (multiValues.length > 0) {
|
if (multiValues.length > 0) {
|
||||||
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
|
const placeholders = multiValues
|
||||||
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
|
.map((_: string, idx: number) => `$${paramIndex + idx}`)
|
||||||
|
.join(", ");
|
||||||
|
logger.info(
|
||||||
|
`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text IN (${placeholders})`,
|
whereClause: `${columnName}::text IN (${placeholders})`,
|
||||||
values: multiValues,
|
values: multiValues,
|
||||||
|
|
@ -1320,10 +1341,20 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔧 날짜 범위 객체 {from, to} 체크
|
// 🔧 날짜 범위 객체 {from, to} 체크
|
||||||
if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) {
|
if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
("from" in value || "to" in value)
|
||||||
|
) {
|
||||||
// 날짜 범위 객체는 그대로 전달
|
// 날짜 범위 객체는 그대로 전달
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
const columnInfo = await this.getColumnWebTypeInfo(
|
||||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
tableName,
|
||||||
|
columnName
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
columnInfo &&
|
||||||
|
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
|
||||||
|
) {
|
||||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1356,9 +1387,10 @@ export class TableManagementService {
|
||||||
|
|
||||||
// 컬럼 타입 정보 조회
|
// 컬럼 타입 정보 조회
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||||
logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
|
logger.info(
|
||||||
`webType=${columnInfo?.webType || 'NULL'}`,
|
`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
|
||||||
`inputType=${columnInfo?.inputType || 'NULL'}`,
|
`webType=${columnInfo?.webType || "NULL"}`,
|
||||||
|
`inputType=${columnInfo?.inputType || "NULL"}`,
|
||||||
`actualValue=${JSON.stringify(actualValue)}`,
|
`actualValue=${JSON.stringify(actualValue)}`,
|
||||||
`operator=${operator}`
|
`operator=${operator}`
|
||||||
);
|
);
|
||||||
|
|
@ -1464,16 +1496,20 @@ export class TableManagementService {
|
||||||
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
|
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
|
||||||
if (typeof value === "string" && value.includes("|")) {
|
if (typeof value === "string" && value.includes("|")) {
|
||||||
const [fromStr, toStr] = value.split("|");
|
const [fromStr, toStr] = value.split("|");
|
||||||
|
|
||||||
if (fromStr && fromStr.trim() !== "") {
|
if (fromStr && fromStr.trim() !== "") {
|
||||||
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
||||||
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
|
conditions.push(
|
||||||
|
`${columnName}::date >= $${paramIndex + paramCount}::date`
|
||||||
|
);
|
||||||
values.push(fromStr.trim());
|
values.push(fromStr.trim());
|
||||||
paramCount++;
|
paramCount++;
|
||||||
}
|
}
|
||||||
if (toStr && toStr.trim() !== "") {
|
if (toStr && toStr.trim() !== "") {
|
||||||
// 종료일은 해당 날짜의 23:59:59까지 포함
|
// 종료일은 해당 날짜의 23:59:59까지 포함
|
||||||
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
|
conditions.push(
|
||||||
|
`${columnName}::date <= $${paramIndex + paramCount}::date`
|
||||||
|
);
|
||||||
values.push(toStr.trim());
|
values.push(toStr.trim());
|
||||||
paramCount++;
|
paramCount++;
|
||||||
}
|
}
|
||||||
|
|
@ -1482,17 +1518,21 @@ export class TableManagementService {
|
||||||
else if (typeof value === "object" && value !== null) {
|
else if (typeof value === "object" && value !== null) {
|
||||||
if (value.from) {
|
if (value.from) {
|
||||||
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
||||||
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
|
conditions.push(
|
||||||
|
`${columnName}::date >= $${paramIndex + paramCount}::date`
|
||||||
|
);
|
||||||
values.push(value.from);
|
values.push(value.from);
|
||||||
paramCount++;
|
paramCount++;
|
||||||
}
|
}
|
||||||
if (value.to) {
|
if (value.to) {
|
||||||
// 종료일은 해당 날짜의 23:59:59까지 포함
|
// 종료일은 해당 날짜의 23:59:59까지 포함
|
||||||
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
|
conditions.push(
|
||||||
|
`${columnName}::date <= $${paramIndex + paramCount}::date`
|
||||||
|
);
|
||||||
values.push(value.to);
|
values.push(value.to);
|
||||||
paramCount++;
|
paramCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 단일 날짜 검색
|
// 단일 날짜 검색
|
||||||
else if (typeof value === "string" && value.trim() !== "") {
|
else if (typeof value === "string" && value.trim() !== "") {
|
||||||
conditions.push(`${columnName}::date = $${paramIndex}::date`);
|
conditions.push(`${columnName}::date = $${paramIndex}::date`);
|
||||||
|
|
@ -1658,9 +1698,11 @@ export class TableManagementService {
|
||||||
paramCount: 0,
|
paramCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// IN 절로 여러 값 검색
|
// IN 절로 여러 값 검색
|
||||||
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
const placeholders = value
|
||||||
|
.map((_, idx) => `$${paramIndex + idx}`)
|
||||||
|
.join(", ");
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName} IN (${placeholders})`,
|
whereClause: `${columnName} IN (${placeholders})`,
|
||||||
values: value,
|
values: value,
|
||||||
|
|
@ -1776,20 +1818,25 @@ export class TableManagementService {
|
||||||
[tableName, columnName]
|
[tableName, columnName]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, {
|
logger.info(
|
||||||
found: !!result,
|
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
|
||||||
web_type: result?.web_type,
|
{
|
||||||
input_type: result?.input_type,
|
found: !!result,
|
||||||
});
|
web_type: result?.web_type,
|
||||||
|
input_type: result?.input_type,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`);
|
logger.warn(
|
||||||
|
`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// web_type이 없으면 input_type을 사용 (레거시 호환)
|
// web_type이 없으면 input_type을 사용 (레거시 호환)
|
||||||
const webType = result.web_type || result.input_type || "";
|
const webType = result.web_type || result.input_type || "";
|
||||||
|
|
||||||
const columnInfo = {
|
const columnInfo = {
|
||||||
webType: webType,
|
webType: webType,
|
||||||
inputType: result.input_type || "",
|
inputType: result.input_type || "",
|
||||||
|
|
@ -1799,7 +1846,9 @@ export class TableManagementService {
|
||||||
displayColumn: result.display_column || undefined,
|
displayColumn: result.display_column || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`);
|
logger.info(
|
||||||
|
`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`
|
||||||
|
);
|
||||||
return columnInfo;
|
return columnInfo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -1913,6 +1962,15 @@ export class TableManagementService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외
|
||||||
|
// Entity 조인 조회에서만 처리됨
|
||||||
|
if (column.includes(".")) {
|
||||||
|
logger.info(
|
||||||
|
`🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
||||||
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||||
|
|
||||||
|
|
@ -2741,7 +2799,11 @@ export class TableManagementService {
|
||||||
WHERE "${referenceColumn}" IS NOT NULL`;
|
WHERE "${referenceColumn}" IS NOT NULL`;
|
||||||
|
|
||||||
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
|
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
|
||||||
if (filterColumn && filterValue !== undefined && filterValue !== null) {
|
if (
|
||||||
|
filterColumn &&
|
||||||
|
filterValue !== undefined &&
|
||||||
|
filterValue !== null
|
||||||
|
) {
|
||||||
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
|
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2934,16 +2996,22 @@ export class TableManagementService {
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식
|
||||||
|
const hasJoinTableSearch =
|
||||||
|
options.search &&
|
||||||
|
Object.keys(options.search).some((key) => key.includes("."));
|
||||||
|
|
||||||
const hasEntitySearch =
|
const hasEntitySearch =
|
||||||
options.search &&
|
options.search &&
|
||||||
Object.keys(options.search).some((key) =>
|
(Object.keys(options.search).some((key) =>
|
||||||
allEntityColumns.includes(key)
|
allEntityColumns.includes(key)
|
||||||
);
|
) ||
|
||||||
|
hasJoinTableSearch);
|
||||||
|
|
||||||
if (hasEntitySearch) {
|
if (hasEntitySearch) {
|
||||||
const entitySearchKeys = options.search
|
const entitySearchKeys = options.search
|
||||||
? Object.keys(options.search).filter((key) =>
|
? Object.keys(options.search).filter(
|
||||||
allEntityColumns.includes(key)
|
(key) => allEntityColumns.includes(key) || key.includes(".")
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -2988,47 +3056,113 @@ export class TableManagementService {
|
||||||
|
|
||||||
if (options.search) {
|
if (options.search) {
|
||||||
for (const [key, value] of Object.entries(options.search)) {
|
for (const [key, value] of Object.entries(options.search)) {
|
||||||
|
// 검색값 추출 (객체 형태일 수 있음)
|
||||||
|
let searchValue = value;
|
||||||
|
if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"value" in value
|
||||||
|
) {
|
||||||
|
searchValue = value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 값이면 스킵
|
||||||
|
if (
|
||||||
|
searchValue === "__ALL__" ||
|
||||||
|
searchValue === "" ||
|
||||||
|
searchValue === null ||
|
||||||
|
searchValue === undefined
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeValue = String(searchValue).replace(/'/g, "''");
|
||||||
|
|
||||||
|
// 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name)
|
||||||
|
if (key.includes(".")) {
|
||||||
|
const [refTable, refColumn] = key.split(".");
|
||||||
|
|
||||||
|
// aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식)
|
||||||
|
let foundAlias: string | undefined;
|
||||||
|
for (const [aliasKey, alias] of aliasMap.entries()) {
|
||||||
|
if (aliasKey.startsWith(`${refTable}:`)) {
|
||||||
|
foundAlias = alias;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundAlias) {
|
||||||
|
whereConditions.push(
|
||||||
|
`${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'`
|
||||||
|
);
|
||||||
|
entitySearchColumns.push(`${key} (${refTable}.${refColumn})`);
|
||||||
|
logger.info(
|
||||||
|
`🎯 조인 테이블 검색: ${key} → ${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const joinConfig = joinConfigs.find(
|
const joinConfig = joinConfigs.find(
|
||||||
(config) => config.aliasColumn === key
|
(config) => config.aliasColumn === key
|
||||||
);
|
);
|
||||||
|
|
||||||
if (joinConfig) {
|
if (joinConfig) {
|
||||||
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
||||||
const alias = aliasMap.get(joinConfig.referenceTable);
|
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
|
||||||
|
const alias = aliasMap.get(aliasKey);
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'`
|
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
|
||||||
);
|
);
|
||||||
entitySearchColumns.push(
|
entitySearchColumns.push(
|
||||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||||
);
|
);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})`
|
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
|
||||||
);
|
);
|
||||||
} else if (key === "writer_dept_code") {
|
} else if (key === "writer_dept_code") {
|
||||||
// writer_dept_code: user_info.dept_code에서 검색
|
// writer_dept_code: user_info.dept_code에서 검색
|
||||||
const userAlias = aliasMap.get("user_info");
|
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
||||||
whereConditions.push(
|
k.startsWith("user_info:")
|
||||||
`${userAlias}.dept_code ILIKE '%${value}%'`
|
|
||||||
);
|
|
||||||
entitySearchColumns.push(`${key} (user_info.dept_code)`);
|
|
||||||
logger.info(
|
|
||||||
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})`
|
|
||||||
);
|
);
|
||||||
|
const userAlias = userAliasKey
|
||||||
|
? aliasMap.get(userAliasKey)
|
||||||
|
: undefined;
|
||||||
|
if (userAlias) {
|
||||||
|
whereConditions.push(
|
||||||
|
`${userAlias}.dept_code ILIKE '%${safeValue}%'`
|
||||||
|
);
|
||||||
|
entitySearchColumns.push(`${key} (user_info.dept_code)`);
|
||||||
|
logger.info(
|
||||||
|
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})`
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (key === "company_code_status") {
|
} else if (key === "company_code_status") {
|
||||||
// company_code_status: company_info.status에서 검색
|
// company_code_status: company_info.status에서 검색
|
||||||
const companyAlias = aliasMap.get("company_info");
|
const companyAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
||||||
whereConditions.push(
|
k.startsWith("company_info:")
|
||||||
`${companyAlias}.status ILIKE '%${value}%'`
|
|
||||||
);
|
|
||||||
entitySearchColumns.push(`${key} (company_info.status)`);
|
|
||||||
logger.info(
|
|
||||||
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})`
|
|
||||||
);
|
);
|
||||||
|
const companyAlias = companyAliasKey
|
||||||
|
? aliasMap.get(companyAliasKey)
|
||||||
|
: undefined;
|
||||||
|
if (companyAlias) {
|
||||||
|
whereConditions.push(
|
||||||
|
`${companyAlias}.status ILIKE '%${safeValue}%'`
|
||||||
|
);
|
||||||
|
entitySearchColumns.push(`${key} (company_info.status)`);
|
||||||
|
logger.info(
|
||||||
|
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})`
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
||||||
whereConditions.push(`main.${key} ILIKE '%${value}%'`);
|
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'`
|
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3168,6 +3302,59 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name)
|
||||||
|
if (columnName.includes(".")) {
|
||||||
|
const [refTable, refColumn] = columnName.split(".");
|
||||||
|
|
||||||
|
// 검색값 추출
|
||||||
|
let searchValue = value;
|
||||||
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
|
searchValue = value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
searchValue === "__ALL__" ||
|
||||||
|
searchValue === "" ||
|
||||||
|
searchValue === null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 column_labels에서 해당 엔티티 설정 찾기
|
||||||
|
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
|
||||||
|
const entityColumnResult = await query<{
|
||||||
|
column_name: string;
|
||||||
|
reference_table: string;
|
||||||
|
reference_column: string;
|
||||||
|
}>(
|
||||||
|
`SELECT column_name, reference_table, reference_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type = 'entity'
|
||||||
|
AND reference_table = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[tableName, refTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entityColumnResult.length > 0) {
|
||||||
|
// 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자)
|
||||||
|
const joinAlias = refTable.substring(0, 3);
|
||||||
|
|
||||||
|
// 조인 테이블 컬럼으로 검색 조건 생성
|
||||||
|
const safeValue = String(searchValue).replace(/'/g, "''");
|
||||||
|
const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`;
|
||||||
|
|
||||||
|
logger.info(`🔍 조인 테이블 검색 조건: ${condition}`);
|
||||||
|
conditions.push(condition);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 고급 검색 조건 구성
|
// 고급 검색 조건 구성
|
||||||
const searchCondition = await this.buildAdvancedSearchCondition(
|
const searchCondition = await this.buildAdvancedSearchCondition(
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -4282,7 +4469,10 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
return result.length > 0;
|
return result.length > 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error);
|
logger.error(
|
||||||
|
`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -582,3 +582,4 @@ const result = await executeNodeFlow(flowId, {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -355,3 +355,4 @@
|
||||||
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
|
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
|
||||||
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
||||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
||||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
||||||
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -307,10 +308,7 @@ function ScreenViewPage() {
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
||||||
ref={containerRef}
|
|
||||||
className="bg-background h-full w-full overflow-auto p-3"
|
|
||||||
>
|
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||||
|
|
@ -358,7 +356,6 @@ function ScreenViewPage() {
|
||||||
return isButton;
|
return isButton;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
topLevelComponents.forEach((component) => {
|
topLevelComponents.forEach((component) => {
|
||||||
const isButton =
|
const isButton =
|
||||||
(component.type === "component" &&
|
(component.type === "component" &&
|
||||||
|
|
@ -799,7 +796,9 @@ function ScreenViewPageWrapper() {
|
||||||
return (
|
return (
|
||||||
<TableSearchWidgetHeightProvider>
|
<TableSearchWidgetHeightProvider>
|
||||||
<ScreenContextProvider>
|
<ScreenContextProvider>
|
||||||
<ScreenViewPage />
|
<SplitPanelProvider>
|
||||||
|
<ScreenViewPage />
|
||||||
|
</SplitPanelProvider>
|
||||||
</ScreenContextProvider>
|
</ScreenContextProvider>
|
||||||
</TableSearchWidgetHeightProvider>
|
</TableSearchWidgetHeightProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { DualListBox } from "@/components/common/DualListBox";
|
import { DualListBox } from "@/components/common/DualListBox";
|
||||||
import { MenuPermissionsTable } from "./MenuPermissionsTable";
|
import { MenuPermissionsTable } from "./MenuPermissionsTable";
|
||||||
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
|
|
||||||
interface RoleDetailManagementProps {
|
interface RoleDetailManagementProps {
|
||||||
roleId: string;
|
roleId: string;
|
||||||
|
|
@ -25,6 +26,7 @@ interface RoleDetailManagementProps {
|
||||||
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { refreshMenus } = useMenu();
|
||||||
|
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
|
@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alert("멤버가 성공적으로 저장되었습니다.");
|
alert("멤버가 성공적으로 저장되었습니다.");
|
||||||
loadMembers(); // 새로고침
|
loadMembers(); // 새로고침
|
||||||
|
|
||||||
|
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
|
||||||
|
await refreshMenus();
|
||||||
} else {
|
} else {
|
||||||
alert(response.message || "멤버 저장에 실패했습니다.");
|
alert(response.message || "멤버 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingMembers(false);
|
setIsSavingMembers(false);
|
||||||
}
|
}
|
||||||
}, [roleGroup, selectedUsers, loadMembers]);
|
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
|
||||||
|
|
||||||
// 메뉴 권한 저장 핸들러
|
// 메뉴 권한 저장 핸들러
|
||||||
const handleSavePermissions = useCallback(async () => {
|
const handleSavePermissions = useCallback(async () => {
|
||||||
|
|
@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
||||||
loadMenuPermissions(); // 새로고침
|
loadMenuPermissions(); // 새로고침
|
||||||
|
|
||||||
|
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
|
||||||
|
await refreshMenus();
|
||||||
} else {
|
} else {
|
||||||
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingPermissions(false);
|
setIsSavingPermissions(false);
|
||||||
}
|
}
|
||||||
}, [roleGroup, menuPermissions, loadMenuPermissions]);
|
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
|
||||||
// 추가 데이터 조회 설정
|
// 추가 데이터 조회 설정
|
||||||
additionalQuery?: {
|
additionalQuery?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
|
||||||
tableName: string; // 조회할 테이블명 (예: vehicles)
|
tableName: string; // 조회할 테이블명 (예: vehicles)
|
||||||
matchColumn: string; // 매칭할 컬럼 (예: id)
|
matchColumn: string; // 매칭할 컬럼 (예: id)
|
||||||
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
||||||
|
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
|
||||||
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
||||||
displayColumns?: DisplayColumnConfig[];
|
displayColumns?: DisplayColumnConfig[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
checked={popupConfig.additionalQuery?.enabled || false}
|
checked={popupConfig.additionalQuery?.enabled || false}
|
||||||
onCheckedChange={(enabled) =>
|
onCheckedChange={(enabled) =>
|
||||||
updatePopupConfig({
|
updatePopupConfig({
|
||||||
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
|
additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label="추가 데이터 조회 활성화"
|
aria-label="추가 데이터 조회 활성화"
|
||||||
|
|
@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
|
|
||||||
{popupConfig.additionalQuery?.enabled && (
|
{popupConfig.additionalQuery?.enabled && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/* 조회 모드 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">테이블명</Label>
|
<Label className="text-xs">조회 모드</Label>
|
||||||
<Input
|
<Select
|
||||||
value={popupConfig.additionalQuery?.tableName || ""}
|
value={popupConfig.additionalQuery?.queryMode || "table"}
|
||||||
onChange={(e) =>
|
onValueChange={(value: "table" | "custom") =>
|
||||||
updatePopupConfig({
|
updatePopupConfig({
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="vehicles"
|
>
|
||||||
className="mt-1 h-8 text-xs"
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
/>
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
<div>
|
<SelectContent>
|
||||||
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
<SelectItem value="table">테이블 조회</SelectItem>
|
||||||
<Input
|
<SelectItem value="custom">커스텀 쿼리</SelectItem>
|
||||||
value={popupConfig.additionalQuery?.matchColumn || ""}
|
</SelectContent>
|
||||||
onChange={(e) =>
|
</Select>
|
||||||
updatePopupConfig({
|
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="id"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
|
||||||
<Input
|
|
||||||
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updatePopupConfig({
|
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="비워두면 매칭 컬럼과 동일"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
|
{/* 테이블 조회 모드 */}
|
||||||
|
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.tableName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="vehicles"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.matchColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="id"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="비워두면 매칭 컬럼과 동일"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 쿼리 모드 */}
|
||||||
|
{popupConfig.additionalQuery?.queryMode === "custom" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="id"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">쿼리에서 사용할 파라미터 컬럼</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">커스텀 쿼리</Label>
|
||||||
|
<textarea
|
||||||
|
value={popupConfig.additionalQuery?.customQuery || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={`SELECT
|
||||||
|
v.vehicle_number AS "차량번호",
|
||||||
|
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
|
||||||
|
FROM vehicles v
|
||||||
|
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
|
||||||
|
WHERE v.id = {id}
|
||||||
|
GROUP BY v.id;`}
|
||||||
|
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{"{id}"}, {"{vehicle_number}"} 등 클릭한 행의 컬럼값을 파라미터로 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">표시할 컬럼 선택</Label>
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
|
||||||
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
{popupConfig.additionalQuery?.queryMode !== "custom" && (
|
||||||
<span className="truncate">
|
<>
|
||||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
<Popover>
|
||||||
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
<PopoverTrigger asChild>
|
||||||
: "전체 표시 (클릭하여 선택)"}
|
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
||||||
</span>
|
<span className="truncate">
|
||||||
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
||||||
</Button>
|
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
||||||
</PopoverTrigger>
|
: "전체 표시 (클릭하여 선택)"}
|
||||||
<PopoverContent className="w-72 p-2" align="start">
|
</span>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<span className="text-xs font-medium">컬럼 선택</span>
|
</Button>
|
||||||
<Button
|
</PopoverTrigger>
|
||||||
variant="ghost"
|
<PopoverContent className="w-72 p-2" align="start">
|
||||||
size="sm"
|
<div className="mb-2 flex items-center justify-between">
|
||||||
className="h-6 text-xs"
|
<span className="text-xs font-medium">컬럼 선택</span>
|
||||||
onClick={() =>
|
<Button
|
||||||
updatePopupConfig({
|
variant="ghost"
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
size="sm"
|
||||||
})
|
className="h-6 text-xs"
|
||||||
}
|
onClick={() =>
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
||||||
{/* 쿼리 결과 컬럼 목록 */}
|
|
||||||
{queryResult?.columns.map((col) => {
|
|
||||||
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
|
||||||
const existingConfig = currentColumns.find((c) =>
|
|
||||||
typeof c === 'object' ? c.column === col : c === col
|
|
||||||
);
|
|
||||||
const isSelected = !!existingConfig;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col}
|
|
||||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
|
||||||
onClick={() => {
|
|
||||||
const newColumns = isSelected
|
|
||||||
? currentColumns.filter((c) =>
|
|
||||||
typeof c === 'object' ? c.column !== col : c !== col
|
|
||||||
)
|
|
||||||
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
|
||||||
updatePopupConfig({
|
updatePopupConfig({
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<Checkbox checked={isSelected} className="h-3 w-3" />
|
초기화
|
||||||
<span className="text-xs">{col}</span>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
})}
|
{/* 쿼리 결과 컬럼 목록 */}
|
||||||
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
{queryResult?.columns.map((col) => {
|
||||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
||||||
쿼리를 먼저 실행해주세요
|
const existingConfig = currentColumns.find((c) =>
|
||||||
</p>
|
typeof c === 'object' ? c.column === col : c === col
|
||||||
|
);
|
||||||
|
const isSelected = !!existingConfig;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = isSelected
|
||||||
|
? currentColumns.filter((c) =>
|
||||||
|
typeof c === 'object' ? c.column !== col : c !== col
|
||||||
|
)
|
||||||
|
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} className="h-3 w-3" />
|
||||||
|
<span className="text-xs">{col}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
쿼리를 먼저 실행해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 쿼리 모드: 직접 입력 방식 */}
|
||||||
|
{popupConfig.additionalQuery?.queryMode === "custom" && (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
커스텀 쿼리의 결과 컬럼이 자동으로 표시됩니다.
|
||||||
|
쿼리에서 AS "라벨명" 형태로 alias를 지정하면 해당 라벨로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
컬럼 추가 (선택사항)
|
||||||
|
</Button>
|
||||||
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</>
|
||||||
</Popover>
|
)}
|
||||||
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
|
||||||
|
|
||||||
{/* 선택된 컬럼 라벨 편집 */}
|
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
|
||||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
{popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<Label className="text-xs">컬럼 라벨 설정</Label>
|
<Label className="text-xs">컬럼 라벨 설정</Label>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
|
||||||
|
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Label className="text-xs">표시할 컬럼 직접 입력</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">커스텀 쿼리 결과의 컬럼명을 직접 입력하세요</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={column}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||||
|
newColumns[index] = { column: e.target.value, label: label || e.target.value };
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="컬럼명 (쿼리 결과)"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||||
|
newColumns[index] = { column, label: e.target.value };
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
|
||||||
|
(_, i) => i !== index
|
||||||
|
);
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -64,22 +64,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
|
|
||||||
// 추가 데이터 조회 설정이 있으면 실행
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
if (additionalQuery?.enabled) {
|
||||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
const queryMode = additionalQuery.queryMode || "table";
|
||||||
const matchValue = row[sourceColumn];
|
|
||||||
|
// 커스텀 쿼리 모드
|
||||||
if (matchValue !== undefined && matchValue !== null) {
|
if (queryMode === "custom" && additionalQuery.customQuery) {
|
||||||
setDetailPopupLoading(true);
|
setDetailPopupLoading(true);
|
||||||
try {
|
try {
|
||||||
const query = `
|
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
||||||
SELECT *
|
let query = additionalQuery.customQuery;
|
||||||
FROM ${additionalQuery.tableName}
|
// console.log("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작");
|
||||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
// console.log("🔍 [ListWidget] 클릭한 행 데이터:", row);
|
||||||
LIMIT 1;
|
// console.log("🔍 [ListWidget] 행 컬럼 목록:", Object.keys(row));
|
||||||
`;
|
|
||||||
|
Object.keys(row).forEach((key) => {
|
||||||
|
const value = row[key];
|
||||||
|
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
||||||
|
// SQL 인젝션 방지를 위해 값 이스케이프
|
||||||
|
const safeValue = typeof value === "string"
|
||||||
|
? value.replace(/'/g, "''")
|
||||||
|
: value;
|
||||||
|
query = query.replace(placeholder, String(safeValue ?? ""));
|
||||||
|
// console.log(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("🔍 [ListWidget] 최종 쿼리:", query);
|
||||||
|
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(query);
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
// console.log("🔍 [ListWidget] 쿼리 결과:", result);
|
||||||
|
|
||||||
if (result.success && result.rows.length > 0) {
|
if (result.success && result.rows.length > 0) {
|
||||||
setAdditionalDetailData(result.rows[0]);
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
|
@ -87,12 +100,43 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("추가 데이터 로드 실패:", error);
|
console.error("커스텀 쿼리 실행 실패:", error);
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
} finally {
|
} finally {
|
||||||
setDetailPopupLoading(false);
|
setDetailPopupLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 테이블 조회 모드
|
||||||
|
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("추가 데이터 로드 실패:", error);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config.rowDetailPopup],
|
[config.rowDetailPopup],
|
||||||
|
|
@ -104,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "date":
|
case "date":
|
||||||
return new Date(value).toLocaleDateString("ko-KR");
|
try {
|
||||||
|
const dateVal = new Date(value);
|
||||||
|
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return new Date(value).toLocaleString("ko-KR");
|
try {
|
||||||
|
const dateVal = new Date(value);
|
||||||
|
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
case "number":
|
case "number":
|
||||||
return Number(value).toLocaleString("ko-KR");
|
return Number(value).toLocaleString("ko-KR");
|
||||||
case "currency":
|
case "currency":
|
||||||
|
|
@ -190,22 +244,34 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
const groups: FieldGroup[] = [];
|
const groups: FieldGroup[] = [];
|
||||||
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
||||||
|
|
||||||
|
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
||||||
|
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
||||||
|
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
||||||
|
? { ...row, ...additional } // additional이 row를 덮어씀
|
||||||
|
: row;
|
||||||
|
|
||||||
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
let basicFields: { column: string; label: string }[] = [];
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
if (displayColumns && displayColumns.length > 0) {
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
// DisplayColumnConfig 형식 지원
|
// DisplayColumnConfig 형식 지원
|
||||||
|
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
||||||
basicFields = displayColumns
|
basicFields = displayColumns
|
||||||
.map((colConfig) => {
|
.map((colConfig) => {
|
||||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
return { column, label };
|
return { column, label };
|
||||||
})
|
})
|
||||||
.filter((item) => item.column in row);
|
.filter((item) => item.column in mergedData);
|
||||||
} else {
|
} else {
|
||||||
// 전체 컬럼
|
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
||||||
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
||||||
|
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
|
||||||
|
} else {
|
||||||
|
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
@ -220,8 +286,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
||||||
if (additional && Object.keys(additional).length > 0) {
|
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
||||||
// 운행 정보
|
// 운행 정보
|
||||||
if (additional.last_trip_start || additional.last_trip_end) {
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,19 @@
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Grid3x3,
|
||||||
|
Move,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
Check,
|
||||||
|
ParkingCircle,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -78,7 +90,7 @@ const DebouncedInput = ({
|
||||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
if (onCommit && debounce === 0) {
|
if (onCommit && debounce === 0) {
|
||||||
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
|
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
|
||||||
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
|
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
|
||||||
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
||||||
}
|
}
|
||||||
|
|
@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
|
|
||||||
// 레이아웃 데이터 로드
|
// 레이아웃 데이터 로드
|
||||||
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
|
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// 레이아웃 로드 함수
|
||||||
const loadLayout = async () => {
|
const loadLayout = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await getLayoutById(layoutId);
|
const response = await getLayoutById(layoutId);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const { layout, objects } = response.data;
|
const { layout, objects } = response.data;
|
||||||
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
|
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
|
||||||
|
|
||||||
// 외부 DB 연결 ID 복원
|
// 외부 DB 연결 ID 복원
|
||||||
if (layout.external_db_connection_id) {
|
if (layout.external_db_connection_id) {
|
||||||
setSelectedDbConnection(layout.external_db_connection_id);
|
setSelectedDbConnection(layout.external_db_connection_id);
|
||||||
}
|
|
||||||
|
|
||||||
// 계층 구조 설정 로드
|
|
||||||
if (layout.hierarchy_config) {
|
|
||||||
try {
|
|
||||||
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
|
|
||||||
const config =
|
|
||||||
typeof layout.hierarchy_config === "string"
|
|
||||||
? JSON.parse(layout.hierarchy_config)
|
|
||||||
: layout.hierarchy_config;
|
|
||||||
setHierarchyConfig(config);
|
|
||||||
|
|
||||||
// 선택된 테이블 정보도 복원
|
|
||||||
const newSelectedTables: any = {
|
|
||||||
warehouse: config.warehouse?.tableName || "",
|
|
||||||
area: "",
|
|
||||||
location: "",
|
|
||||||
material: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.levels && config.levels.length > 0) {
|
|
||||||
// 레벨 1 = Area
|
|
||||||
if (config.levels[0]?.tableName) {
|
|
||||||
newSelectedTables.area = config.levels[0].tableName;
|
|
||||||
}
|
|
||||||
// 레벨 2 = Location
|
|
||||||
if (config.levels[1]?.tableName) {
|
|
||||||
newSelectedTables.location = config.levels[1].tableName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자재 테이블 정보
|
|
||||||
if (config.material?.tableName) {
|
|
||||||
newSelectedTables.material = config.material.tableName;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedTables(newSelectedTables);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("계층 구조 설정 파싱 실패:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 객체 데이터 변환 (DB -> PlacedObject)
|
|
||||||
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
|
|
||||||
id: obj.id,
|
|
||||||
type: obj.object_type,
|
|
||||||
name: obj.object_name,
|
|
||||||
position: {
|
|
||||||
x: parseFloat(obj.position_x),
|
|
||||||
y: parseFloat(obj.position_y),
|
|
||||||
z: parseFloat(obj.position_z),
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
x: parseFloat(obj.size_x),
|
|
||||||
y: parseFloat(obj.size_y),
|
|
||||||
z: parseFloat(obj.size_z),
|
|
||||||
},
|
|
||||||
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
|
||||||
color: obj.color,
|
|
||||||
areaKey: obj.area_key,
|
|
||||||
locaKey: obj.loca_key,
|
|
||||||
locType: obj.loc_type,
|
|
||||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
|
||||||
materialPreview:
|
|
||||||
obj.loc_type === "STP" || !obj.material_preview_height
|
|
||||||
? undefined
|
|
||||||
: { height: parseFloat(obj.material_preview_height) },
|
|
||||||
parentId: obj.parent_id,
|
|
||||||
displayOrder: obj.display_order,
|
|
||||||
locked: obj.locked,
|
|
||||||
visible: obj.visible !== false,
|
|
||||||
hierarchyLevel: obj.hierarchy_level || 1,
|
|
||||||
parentKey: obj.parent_key,
|
|
||||||
externalKey: obj.external_key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setPlacedObjects(loadedObjects);
|
|
||||||
|
|
||||||
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
|
|
||||||
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
|
|
||||||
setNextObjectId(minId - 1);
|
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "레이아웃 불러오기 완료",
|
|
||||||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
|
||||||
const dbConnectionId = layout.external_db_connection_id;
|
|
||||||
const hierarchyConfigParsed =
|
|
||||||
typeof layout.hierarchy_config === "string"
|
|
||||||
? JSON.parse(layout.hierarchy_config)
|
|
||||||
: layout.hierarchy_config;
|
|
||||||
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
|
||||||
|
|
||||||
const locationObjects = loadedObjects.filter(
|
|
||||||
(obj) =>
|
|
||||||
(obj.type === "location-bed" ||
|
|
||||||
obj.type === "location-stp" ||
|
|
||||||
obj.type === "location-temp" ||
|
|
||||||
obj.type === "location-dest") &&
|
|
||||||
obj.locaKey,
|
|
||||||
);
|
|
||||||
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
|
||||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
|
||||||
setTimeout(() => {
|
|
||||||
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || "레이아웃 조회 실패");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("레이아웃 로드 실패:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "오류",
|
|
||||||
description: errorMessage,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 계층 구조 설정 로드
|
||||||
|
if (layout.hierarchy_config) {
|
||||||
|
try {
|
||||||
|
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
|
||||||
|
const config =
|
||||||
|
typeof layout.hierarchy_config === "string"
|
||||||
|
? JSON.parse(layout.hierarchy_config)
|
||||||
|
: layout.hierarchy_config;
|
||||||
|
setHierarchyConfig(config);
|
||||||
|
|
||||||
|
// 선택된 테이블 정보도 복원
|
||||||
|
const newSelectedTables: any = {
|
||||||
|
warehouse: config.warehouse?.tableName || "",
|
||||||
|
area: "",
|
||||||
|
location: "",
|
||||||
|
material: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.levels && config.levels.length > 0) {
|
||||||
|
// 레벨 1 = Area
|
||||||
|
if (config.levels[0]?.tableName) {
|
||||||
|
newSelectedTables.area = config.levels[0].tableName;
|
||||||
|
}
|
||||||
|
// 레벨 2 = Location
|
||||||
|
if (config.levels[1]?.tableName) {
|
||||||
|
newSelectedTables.location = config.levels[1].tableName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자재 테이블 정보
|
||||||
|
if (config.material?.tableName) {
|
||||||
|
newSelectedTables.material = config.material.tableName;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTables(newSelectedTables);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("계층 구조 설정 파싱 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 객체 데이터 변환 (DB -> PlacedObject)
|
||||||
|
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
|
||||||
|
id: obj.id,
|
||||||
|
type: obj.object_type,
|
||||||
|
name: obj.object_name,
|
||||||
|
position: {
|
||||||
|
x: parseFloat(obj.position_x),
|
||||||
|
y: parseFloat(obj.position_y),
|
||||||
|
z: parseFloat(obj.position_z),
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
x: parseFloat(obj.size_x),
|
||||||
|
y: parseFloat(obj.size_y),
|
||||||
|
z: parseFloat(obj.size_z),
|
||||||
|
},
|
||||||
|
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
||||||
|
color: obj.color,
|
||||||
|
areaKey: obj.area_key,
|
||||||
|
locaKey: obj.loca_key,
|
||||||
|
locType: obj.loc_type,
|
||||||
|
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||||
|
materialPreview:
|
||||||
|
obj.loc_type === "STP" || !obj.material_preview_height
|
||||||
|
? undefined
|
||||||
|
: { height: parseFloat(obj.material_preview_height) },
|
||||||
|
parentId: obj.parent_id,
|
||||||
|
displayOrder: obj.display_order,
|
||||||
|
locked: obj.locked,
|
||||||
|
visible: obj.visible !== false,
|
||||||
|
hierarchyLevel: obj.hierarchy_level || 1,
|
||||||
|
parentKey: obj.parent_key,
|
||||||
|
externalKey: obj.external_key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPlacedObjects(loadedObjects);
|
||||||
|
|
||||||
|
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
|
||||||
|
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
|
||||||
|
setNextObjectId(minId - 1);
|
||||||
|
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "레이아웃 불러오기 완료",
|
||||||
|
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
||||||
|
const dbConnectionId = layout.external_db_connection_id;
|
||||||
|
const hierarchyConfigParsed =
|
||||||
|
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
|
||||||
|
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
||||||
|
|
||||||
|
const locationObjects = loadedObjects.filter(
|
||||||
|
(obj) =>
|
||||||
|
(obj.type === "location-bed" ||
|
||||||
|
obj.type === "location-stp" ||
|
||||||
|
obj.type === "location-temp" ||
|
||||||
|
obj.type === "location-dest") &&
|
||||||
|
obj.locaKey,
|
||||||
|
);
|
||||||
|
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||||
|
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||||
|
setTimeout(() => {
|
||||||
|
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || "레이아웃 조회 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "오류",
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 위젯 새로고침 핸들러
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?",
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setSelectedObject(null);
|
||||||
|
setMaterials([]);
|
||||||
|
await loadLayout();
|
||||||
|
setIsRefreshing(false);
|
||||||
|
toast({
|
||||||
|
title: "새로고침 완료",
|
||||||
|
description: "데이터가 갱신되었습니다.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
loadLayout();
|
loadLayout();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [layoutId]); // toast 제거
|
}, [layoutId]);
|
||||||
|
|
||||||
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
|
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
};
|
};
|
||||||
|
|
||||||
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
||||||
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
|
const loadMaterialCountsForLocations = async (
|
||||||
|
locaKeys: string[],
|
||||||
|
dbConnectionId?: number,
|
||||||
|
materialTableName?: string,
|
||||||
|
) => {
|
||||||
const connectionId = dbConnectionId || selectedDbConnection;
|
const connectionId = dbConnectionId || selectedDbConnection;
|
||||||
const tableName = materialTableName || selectedTables.material;
|
const tableName = materialTableName || selectedTables.material;
|
||||||
if (!connectionId || locaKeys.length === 0) return;
|
if (!connectionId || locaKeys.length === 0) return;
|
||||||
|
|
@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
try {
|
try {
|
||||||
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
|
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
|
||||||
console.log("📊 자재 개수 API 응답:", response);
|
console.log("📊 자재 개수 API 응답:", response);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||||
setPlacedObjects((prev) =>
|
setPlacedObjects((prev) =>
|
||||||
|
|
@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
}
|
}
|
||||||
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
|
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
|
||||||
const materialCount = response.data?.find(
|
const materialCount = response.data?.find(
|
||||||
(mc: any) =>
|
(mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
|
||||||
mc.LOCAKEY === obj.locaKey ||
|
|
||||||
mc.location_key === obj.locaKey ||
|
|
||||||
mc.locakey === obj.locaKey
|
|
||||||
);
|
);
|
||||||
if (materialCount) {
|
if (materialCount) {
|
||||||
// count 또는 material_count 필드 사용
|
// count 또는 material_count 필드 사용
|
||||||
|
|
@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasUnsavedChanges && <span className="text-warning text-sm font-medium">미저장 변경사항 있음</span>}
|
{hasUnsavedChanges && <span className="text-warning text-sm font-medium">미저장 변경사항 있음</span>}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing || isLoading}
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
|
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
|
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1620,27 +1663,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
|
||||||
value={selectedTemplateId}
|
|
||||||
onValueChange={(val) => setSelectedTemplateId(val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
|
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{mappingTemplates.length === 0 ? (
|
{mappingTemplates.length === 0 ? (
|
||||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
<div className="text-muted-foreground px-2 py-1 text-xs">사용 가능한 템플릿이 없습니다</div>
|
||||||
사용 가능한 템플릿이 없습니다
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
mappingTemplates.map((tpl) => (
|
mappingTemplates.map((tpl) => (
|
||||||
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
|
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{tpl.name}</span>
|
<span>{tpl.name}</span>
|
||||||
{tpl.description && (
|
{tpl.description && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">{tpl.description}</span>
|
||||||
{tpl.description}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
}}
|
}}
|
||||||
onLoadColumns={async (tableName: string) => {
|
onLoadColumns={async (tableName: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await ExternalDbConnectionAPI.getTableColumns(
|
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
|
||||||
selectedDbConnection,
|
|
||||||
tableName,
|
|
||||||
);
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
|
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
|
||||||
return response.data.map((col: any) => ({
|
return response.data.map((col: any) => ({
|
||||||
column_name:
|
column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
|
||||||
typeof col === "string"
|
|
||||||
? col
|
|
||||||
: col.column_name || col.COLUMN_NAME || String(col),
|
|
||||||
data_type: col.data_type || col.DATA_TYPE,
|
data_type: col.data_type || col.DATA_TYPE,
|
||||||
description: col.description || col.COLUMN_COMMENT || undefined,
|
description: col.description || col.COLUMN_COMMENT || undefined,
|
||||||
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
|
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
|
||||||
|
|
@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
onClick={handleSaveTemplate}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
|
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
|
||||||
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 { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
// 검색 및 필터
|
// 검색 및 필터
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [filterType, setFilterType] = useState<string>("all");
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
// 레이아웃 데이터 로드
|
// 레이아웃 데이터 로드 함수
|
||||||
useEffect(() => {
|
const loadLayout = async () => {
|
||||||
const loadLayout = async () => {
|
try {
|
||||||
try {
|
setIsLoading(true);
|
||||||
setIsLoading(true);
|
const response = await getLayoutById(layoutId);
|
||||||
const response = await getLayoutById(layoutId);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const { layout, objects } = response.data;
|
const { layout, objects } = response.data;
|
||||||
|
|
||||||
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
||||||
setLayoutName(layout.layout_name || layout.layoutName);
|
setLayoutName(layout.layout_name || layout.layoutName);
|
||||||
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
||||||
setExternalDbConnectionId(dbConnectionId);
|
setExternalDbConnectionId(dbConnectionId);
|
||||||
|
|
||||||
// hierarchy_config 저장
|
// hierarchy_config 저장
|
||||||
let hierarchyConfigData: any = null;
|
let hierarchyConfigData: any = null;
|
||||||
if (layout.hierarchy_config) {
|
if (layout.hierarchy_config) {
|
||||||
hierarchyConfigData =
|
hierarchyConfigData =
|
||||||
typeof layout.hierarchy_config === "string"
|
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
|
||||||
? JSON.parse(layout.hierarchy_config)
|
setHierarchyConfig(hierarchyConfigData);
|
||||||
: layout.hierarchy_config;
|
}
|
||||||
setHierarchyConfig(hierarchyConfigData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 객체 데이터 변환
|
// 객체 데이터 변환
|
||||||
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
|
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
|
||||||
const objectType = obj.object_type;
|
const objectType = obj.object_type;
|
||||||
return {
|
return {
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
type: objectType,
|
type: objectType,
|
||||||
name: obj.object_name,
|
name: obj.object_name,
|
||||||
position: {
|
position: {
|
||||||
x: parseFloat(obj.position_x),
|
x: parseFloat(obj.position_x),
|
||||||
y: parseFloat(obj.position_y),
|
y: parseFloat(obj.position_y),
|
||||||
z: parseFloat(obj.position_z),
|
z: parseFloat(obj.position_z),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
x: parseFloat(obj.size_x),
|
x: parseFloat(obj.size_x),
|
||||||
y: parseFloat(obj.size_y),
|
y: parseFloat(obj.size_y),
|
||||||
z: parseFloat(obj.size_z),
|
z: parseFloat(obj.size_z),
|
||||||
},
|
},
|
||||||
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
||||||
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
|
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
|
||||||
areaKey: obj.area_key,
|
areaKey: obj.area_key,
|
||||||
locaKey: obj.loca_key,
|
locaKey: obj.loca_key,
|
||||||
locType: obj.loc_type,
|
locType: obj.loc_type,
|
||||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||||
materialPreview:
|
materialPreview:
|
||||||
obj.loc_type === "STP" || !obj.material_preview_height
|
obj.loc_type === "STP" || !obj.material_preview_height
|
||||||
? undefined
|
? undefined
|
||||||
: { height: parseFloat(obj.material_preview_height) },
|
: { height: parseFloat(obj.material_preview_height) },
|
||||||
parentId: obj.parent_id,
|
parentId: obj.parent_id,
|
||||||
displayOrder: obj.display_order,
|
displayOrder: obj.display_order,
|
||||||
locked: obj.locked,
|
locked: obj.locked,
|
||||||
visible: obj.visible !== false,
|
visible: obj.visible !== false,
|
||||||
hierarchyLevel: obj.hierarchy_level,
|
hierarchyLevel: obj.hierarchy_level,
|
||||||
parentKey: obj.parent_key,
|
parentKey: obj.parent_key,
|
||||||
externalKey: obj.external_key,
|
externalKey: obj.external_key,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setPlacedObjects(loadedObjects);
|
||||||
|
|
||||||
|
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
|
||||||
|
if (dbConnectionId && hierarchyConfigData?.material) {
|
||||||
|
const locationObjects = loadedObjects.filter(
|
||||||
|
(obj) =>
|
||||||
|
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||||
|
obj.locaKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
|
||||||
|
const materialCountPromises = locationObjects.map(async (obj) => {
|
||||||
|
try {
|
||||||
|
const matResponse = await getMaterials(dbConnectionId, {
|
||||||
|
tableName: hierarchyConfigData.material.tableName,
|
||||||
|
keyColumn: hierarchyConfigData.material.keyColumn,
|
||||||
|
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
||||||
|
layerColumn: hierarchyConfigData.material.layerColumn,
|
||||||
|
locaKey: obj.locaKey!,
|
||||||
|
});
|
||||||
|
if (matResponse.success && matResponse.data) {
|
||||||
|
return { id: obj.id, count: matResponse.data.length };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
|
||||||
|
}
|
||||||
|
return { id: obj.id, count: 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
setPlacedObjects(loadedObjects);
|
const materialCounts = await Promise.all(materialCountPromises);
|
||||||
|
|
||||||
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
|
// materialCount 업데이트
|
||||||
if (dbConnectionId && hierarchyConfigData?.material) {
|
setPlacedObjects((prev) =>
|
||||||
const locationObjects = loadedObjects.filter(
|
prev.map((obj) => {
|
||||||
(obj) =>
|
const countData = materialCounts.find((m) => m.id === obj.id);
|
||||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
if (countData && countData.count > 0) {
|
||||||
obj.locaKey
|
return { ...obj, materialCount: countData.count };
|
||||||
);
|
|
||||||
|
|
||||||
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
|
|
||||||
const materialCountPromises = locationObjects.map(async (obj) => {
|
|
||||||
try {
|
|
||||||
const matResponse = await getMaterials(dbConnectionId, {
|
|
||||||
tableName: hierarchyConfigData.material.tableName,
|
|
||||||
keyColumn: hierarchyConfigData.material.keyColumn,
|
|
||||||
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
|
||||||
layerColumn: hierarchyConfigData.material.layerColumn,
|
|
||||||
locaKey: obj.locaKey!,
|
|
||||||
});
|
|
||||||
if (matResponse.success && matResponse.data) {
|
|
||||||
return { id: obj.id, count: matResponse.data.length };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
|
|
||||||
}
|
}
|
||||||
return { id: obj.id, count: 0 };
|
return obj;
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
const materialCounts = await Promise.all(materialCountPromises);
|
|
||||||
|
|
||||||
// materialCount 업데이트
|
|
||||||
setPlacedObjects((prev) =>
|
|
||||||
prev.map((obj) => {
|
|
||||||
const countData = materialCounts.find((m) => m.id === obj.id);
|
|
||||||
if (countData && countData.count > 0) {
|
|
||||||
return { ...obj, materialCount: countData.count };
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.error || "레이아웃 조회 실패");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
throw new Error(response.error || "레이아웃 조회 실패");
|
||||||
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "오류",
|
|
||||||
description: errorMessage,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "오류",
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 위젯 새로고침 핸들러
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setSelectedObject(null);
|
||||||
|
setMaterials([]);
|
||||||
|
setShowInfoPanel(false);
|
||||||
|
await loadLayout();
|
||||||
|
setIsRefreshing(false);
|
||||||
|
toast({
|
||||||
|
title: "새로고침 완료",
|
||||||
|
description: "데이터가 갱신되었습니다.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
loadLayout();
|
loadLayout();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [layoutId]); // toast 제거 - 무한 루프 방지
|
}, [layoutId]);
|
||||||
|
|
||||||
// Location의 자재 목록 로드
|
// Location의 자재 목록 로드
|
||||||
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
||||||
|
|
@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
||||||
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing || isLoading}
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
|
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
{/* 메인 영역 */}
|
||||||
|
|
@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
// Area가 없으면 기존 평면 리스트 유지
|
// Area가 없으면 기존 평면 리스트 유지
|
||||||
if (areaObjects.length === 0) {
|
if (areaObjects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredObjects.map((obj) => {
|
{filteredObjects.map((obj) => {
|
||||||
let typeLabel = obj.type;
|
let typeLabel = obj.type;
|
||||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||||
else if (obj.type === "area") typeLabel = "Area";
|
else if (obj.type === "area") typeLabel = "Area";
|
||||||
else if (obj.type === "rack") typeLabel = "랙";
|
else if (obj.type === "rack") typeLabel = "랙";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={obj.id}
|
key={obj.id}
|
||||||
onClick={() => handleObjectClick(obj.id)}
|
onClick={() => handleObjectClick(obj.id)}
|
||||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{obj.name}</p>
|
<p className="text-sm font-medium">{obj.name}</p>
|
||||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
<span
|
<span
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: obj.color }}
|
style={{ backgroundColor: obj.color }}
|
||||||
/>
|
/>
|
||||||
<span>{typeLabel}</span>
|
<span>{typeLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{obj.areaKey && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{obj.locaKey && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||||
|
<p className="text-xs text-yellow-600">
|
||||||
|
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{obj.areaKey && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{obj.locaKey && (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
|
||||||
<p className="text-xs text-yellow-600">
|
|
||||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||||
|
|
@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
위치: ({locationObj.position.x.toFixed(1)},{" "}
|
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||||
{locationObj.position.z.toFixed(1)})
|
|
||||||
</p>
|
</p>
|
||||||
{locationObj.locaKey && (
|
{locationObj.locaKey && (
|
||||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
|
|
@ -183,15 +177,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
} else {
|
} else {
|
||||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||||
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
|
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||||
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
|
// 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
|
||||||
const parentData =
|
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
|
||||||
|
|
||||||
|
// parentDataMapping에서 명시된 필드만 추출
|
||||||
|
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
|
||||||
|
|
||||||
|
// 부모 데이터 소스
|
||||||
|
const rawParentData =
|
||||||
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
||||||
? splitPanelParentData
|
? splitPanelParentData
|
||||||
: splitPanelContext?.getMappedParentData() || {};
|
: splitPanelContext?.selectedLeftData || {};
|
||||||
|
|
||||||
|
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
|
||||||
|
const parentData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 필수 연결 필드: company_code (멀티테넌시)
|
||||||
|
if (rawParentData.company_code) {
|
||||||
|
parentData.company_code = rawParentData.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentDataMapping에 정의된 필드만 전달
|
||||||
|
for (const mapping of parentDataMapping) {
|
||||||
|
const sourceValue = rawParentData[mapping.sourceColumn];
|
||||||
|
if (sourceValue !== undefined && sourceValue !== null) {
|
||||||
|
parentData[mapping.targetColumn] = sourceValue;
|
||||||
|
console.log(
|
||||||
|
`🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${sourceValue}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
|
||||||
|
if (parentDataMapping.length === 0) {
|
||||||
|
const linkFieldPatterns = ["_code", "_id"];
|
||||||
|
const excludeFields = [
|
||||||
|
"id",
|
||||||
|
"company_code",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(rawParentData)) {
|
||||||
|
if (excludeFields.includes(key)) continue;
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
|
||||||
|
// 연결 필드 패턴 확인
|
||||||
|
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||||
|
if (isLinkField) {
|
||||||
|
parentData[key] = value;
|
||||||
|
console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(parentData).length > 0) {
|
if (Object.keys(parentData).length > 0) {
|
||||||
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
|
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
|
||||||
setFormData(parentData);
|
setFormData(parentData);
|
||||||
} else {
|
} else {
|
||||||
setFormData({});
|
setFormData({});
|
||||||
|
|
@ -604,19 +649,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||||
{modalState.description && !loading && (
|
{modalState.description && !loading && (
|
||||||
<DialogDescription className="text-muted-foreground text-xs">
|
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
|
||||||
{modalState.description}
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||||
{loading ? "화면을 불러오는 중입니다..." : ""}
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
|
||||||
|
|
@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
|
|
||||||
// 추가 데이터 조회 설정이 있으면 실행
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
if (additionalQuery?.enabled) {
|
||||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
const queryMode = additionalQuery.queryMode || "table";
|
||||||
const matchValue = row[sourceColumn];
|
|
||||||
|
// 커스텀 쿼리 모드
|
||||||
if (matchValue !== undefined && matchValue !== null) {
|
if (queryMode === "custom" && additionalQuery.customQuery) {
|
||||||
setDetailPopupLoading(true);
|
setDetailPopupLoading(true);
|
||||||
try {
|
try {
|
||||||
const query = `
|
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
||||||
SELECT *
|
let query = additionalQuery.customQuery;
|
||||||
FROM ${additionalQuery.tableName}
|
// console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
|
||||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
// console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
|
||||||
LIMIT 1;
|
// console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
|
||||||
`;
|
|
||||||
|
Object.keys(row).forEach((key) => {
|
||||||
|
const value = row[key];
|
||||||
|
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
||||||
|
// SQL 인젝션 방지를 위해 값 이스케이프
|
||||||
|
const safeValue = typeof value === "string"
|
||||||
|
? value.replace(/'/g, "''")
|
||||||
|
: value;
|
||||||
|
query = query.replace(placeholder, String(safeValue ?? ""));
|
||||||
|
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
|
||||||
|
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(query);
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
|
||||||
|
|
||||||
if (result.success && result.rows.length > 0) {
|
if (result.success && result.rows.length > 0) {
|
||||||
setAdditionalDetailData(result.rows[0]);
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
|
@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("추가 데이터 로드 실패:", err);
|
console.error("커스텀 쿼리 실행 실패:", err);
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
} finally {
|
} finally {
|
||||||
setDetailPopupLoading(false);
|
setDetailPopupLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 테이블 조회 모드
|
||||||
|
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("추가 데이터 로드 실패:", err);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config.rowDetailPopup],
|
[config.rowDetailPopup],
|
||||||
|
|
@ -136,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "date":
|
case "date":
|
||||||
return new Date(value).toLocaleDateString("ko-KR");
|
try {
|
||||||
|
const dateVal = new Date(value);
|
||||||
|
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return new Date(value).toLocaleString("ko-KR");
|
try {
|
||||||
|
const dateVal = new Date(value);
|
||||||
|
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
case "number":
|
case "number":
|
||||||
return Number(value).toLocaleString("ko-KR");
|
return Number(value).toLocaleString("ko-KR");
|
||||||
case "currency":
|
case "currency":
|
||||||
|
|
@ -222,13 +276,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
const groups: FieldGroup[] = [];
|
const groups: FieldGroup[] = [];
|
||||||
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
||||||
|
|
||||||
|
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
||||||
|
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
||||||
|
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
||||||
|
? { ...row, ...additional } // additional이 row를 덮어씀
|
||||||
|
: row;
|
||||||
|
|
||||||
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
||||||
let basicFields: { column: string; label: string }[] = [];
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
if (displayColumns && displayColumns.length > 0) {
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
// DisplayColumnConfig 형식 지원
|
// DisplayColumnConfig 형식 지원
|
||||||
|
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
||||||
basicFields = displayColumns
|
basicFields = displayColumns
|
||||||
.map((colConfig) => {
|
.map((colConfig) => {
|
||||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
|
@ -237,8 +299,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
})
|
})
|
||||||
.filter((item) => allKeys.includes(item.column));
|
.filter((item) => allKeys.includes(item.column));
|
||||||
} else {
|
} else {
|
||||||
// 전체 컬럼
|
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
||||||
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
||||||
|
basicFields = Object.keys(additional)
|
||||||
|
.filter((key) => !key.startsWith("_"))
|
||||||
|
.map((key) => ({ column: key, label: key }));
|
||||||
|
} else {
|
||||||
|
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
@ -253,8 +321,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
||||||
if (additional && Object.keys(additional).length > 0) {
|
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
||||||
// 운행 정보
|
// 운행 정보
|
||||||
if (additional.last_trip_start || additional.last_trip_end) {
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
setTripInfoLoading(identifier);
|
setTripInfoLoading(identifier);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// user_id 또는 vehicle_number로 조회
|
// user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
|
||||||
const query = `SELECT
|
const query = `SELECT
|
||||||
id, vehicle_number, user_id,
|
id, vehicle_number, user_id,
|
||||||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
last_trip_start,
|
||||||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
last_trip_end,
|
||||||
|
last_trip_distance, last_trip_time,
|
||||||
|
last_empty_start,
|
||||||
|
last_empty_end,
|
||||||
|
last_empty_distance, last_empty_time,
|
||||||
departure, arrival, status
|
departure, arrival, status
|
||||||
FROM vehicles
|
FROM vehicles
|
||||||
WHERE user_id = '${identifier}'
|
WHERE user_id = '${identifier}'
|
||||||
|
|
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
if (identifiers.length === 0) return;
|
if (identifiers.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 모든 마커의 운행/공차 정보를 한 번에 조회
|
// 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
|
||||||
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const query = `SELECT
|
const query = `SELECT
|
||||||
id, vehicle_number, user_id,
|
id, vehicle_number, user_id,
|
||||||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
last_trip_start,
|
||||||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
last_trip_end,
|
||||||
|
last_trip_distance, last_trip_time,
|
||||||
|
last_empty_start,
|
||||||
|
last_empty_end,
|
||||||
|
last_empty_distance, last_empty_time,
|
||||||
departure, arrival, status
|
departure, arrival, status
|
||||||
FROM vehicles
|
FROM vehicles
|
||||||
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
|
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
||||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||||
|
|
@ -264,8 +264,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
setFormData(editData || {});
|
setFormData(editData || {});
|
||||||
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
|
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
|
||||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
|
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
|
||||||
setOriginalData(isCreateMode ? {} : (editData || {}));
|
setOriginalData(isCreateMode ? {} : editData || {});
|
||||||
|
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
|
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +298,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modalState.isOpen && modalState.screenId) {
|
if (modalState.isOpen && modalState.screenId) {
|
||||||
loadScreenData(modalState.screenId);
|
loadScreenData(modalState.screenId);
|
||||||
|
|
||||||
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
|
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
|
||||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
|
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
|
||||||
loadGroupData();
|
loadGroupData();
|
||||||
|
|
@ -436,7 +436,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
|
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
|
||||||
if (saveData?._saveCompleted) {
|
if (saveData?._saveCompleted) {
|
||||||
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
|
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
|
||||||
|
|
||||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||||
if (modalState.onSave) {
|
if (modalState.onSave) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -445,7 +445,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
console.error("onSave 콜백 에러:", callbackError);
|
console.error("onSave 콜백 에러:", callbackError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -470,13 +470,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
|
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
|
||||||
const normalizeDateField = (value: any): string | null => {
|
const normalizeDateField = (value: any): string | null => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
|
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
|
||||||
if (value instanceof Date || typeof value === "string") {
|
if (value instanceof Date || typeof value === "string") {
|
||||||
try {
|
try {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (isNaN(date.getTime())) return null;
|
if (isNaN(date.getTime())) return null;
|
||||||
|
|
||||||
// YYYY-MM-DD 형식으로 변환
|
// YYYY-MM-DD 형식으로 변환
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
@ -487,7 +487,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -508,7 +508,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
const insertData: Record<string, any> = { ...currentData };
|
const insertData: Record<string, any> = { ...currentData };
|
||||||
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
|
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
|
||||||
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
|
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
|
||||||
|
|
||||||
delete insertData.id; // id는 자동 생성되므로 제거
|
delete insertData.id; // id는 자동 생성되므로 제거
|
||||||
|
|
||||||
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
|
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
|
||||||
|
|
@ -592,9 +592,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
for (const currentData of groupData) {
|
for (const currentData of groupData) {
|
||||||
if (currentData.id) {
|
if (currentData.id) {
|
||||||
// id 기반 매칭 (인덱스 기반 X)
|
// id 기반 매칭 (인덱스 기반 X)
|
||||||
const originalItemData = originalGroupData.find(
|
const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
|
||||||
(orig) => orig.id === currentData.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!originalItemData) {
|
if (!originalItemData) {
|
||||||
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
|
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
|
||||||
|
|
@ -604,13 +602,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
// 🆕 값 정규화 함수 (타입 통일)
|
// 🆕 값 정규화 함수 (타입 통일)
|
||||||
const normalizeValue = (val: any, fieldName?: string): any => {
|
const normalizeValue = (val: any, fieldName?: string): any => {
|
||||||
if (val === null || val === undefined || val === "") return null;
|
if (val === null || val === undefined || val === "") return null;
|
||||||
|
|
||||||
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
|
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
|
||||||
if (fieldName && dateFields.includes(fieldName)) {
|
if (fieldName && dateFields.includes(fieldName)) {
|
||||||
const normalizedDate = normalizeDateField(val);
|
const normalizedDate = normalizeDateField(val);
|
||||||
return normalizedDate;
|
return normalizedDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof val === "string" && !isNaN(Number(val))) {
|
if (typeof val === "string" && !isNaN(Number(val))) {
|
||||||
// 숫자로 변환 가능한 문자열은 숫자로
|
// 숫자로 변환 가능한 문자열은 숫자로
|
||||||
return Number(val);
|
return Number(val);
|
||||||
|
|
@ -667,9 +665,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
// 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
|
// 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
|
||||||
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
|
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
|
||||||
const deletedItems = originalGroupData.filter(
|
const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
|
||||||
(orig) => orig.id && !currentIds.has(orig.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const deletedItem of deletedItems) {
|
for (const deletedItem of deletedItems) {
|
||||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||||
|
|
@ -677,7 +673,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
try {
|
try {
|
||||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||||
deletedItem.id,
|
deletedItem.id,
|
||||||
screenData.screenInfo.tableName
|
screenData.screenInfo.tableName,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
@ -760,11 +756,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE
|
// originalData가 비어있으면 INSERT, 있으면 UPDATE
|
||||||
const isCreateMode = Object.keys(originalData).length === 0;
|
const isCreateMode = Object.keys(originalData).length === 0;
|
||||||
|
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
// INSERT 모드
|
// INSERT 모드
|
||||||
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
|
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
|
||||||
|
|
||||||
const response = await dynamicFormApi.saveFormData({
|
const response = await dynamicFormApi.saveFormData({
|
||||||
screenId: modalState.screenId!,
|
screenId: modalState.screenId!,
|
||||||
tableName: screenData.screenInfo.tableName,
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
|
@ -908,12 +904,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding
|
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
|
||||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||||
const dialogGap = 16; // DialogContent gap-4
|
const dialogGap = 16; // DialogContent gap-4
|
||||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||||
|
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
|
||||||
|
|
||||||
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding;
|
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden p-0",
|
className: "overflow-hidden p-0",
|
||||||
|
|
@ -930,10 +927,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent
|
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
|
||||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
|
||||||
style={modalStyle.style}
|
|
||||||
>
|
|
||||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
||||||
|
|
@ -946,7 +940,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -959,7 +953,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
height: screenDimensions?.height || 600,
|
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
|
|
@ -969,25 +963,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
// 컴포넌트 위치를 offset만큼 조정
|
// 컴포넌트 위치를 offset만큼 조정
|
||||||
const offsetX = screenDimensions?.offsetX || 0;
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
const offsetY = screenDimensions?.offsetY || 0;
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
|
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
|
||||||
|
|
||||||
const adjustedComponent = {
|
const adjustedComponent = {
|
||||||
...component,
|
...component,
|
||||||
position: {
|
position: {
|
||||||
...component.position,
|
...component.position,
|
||||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||||
|
|
||||||
|
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||||
|
const enrichedFormData = {
|
||||||
|
...(groupData.length > 0 ? groupData[0] : formData),
|
||||||
|
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
|
||||||
|
screenId: modalState.screenId, // 화면 ID 추가
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔍 디버깅: enrichedFormData 확인
|
||||||
|
console.log("🔑 [EditModal] enrichedFormData 생성:", {
|
||||||
|
"screenData.screenInfo": screenData.screenInfo,
|
||||||
|
"screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
|
||||||
|
"enrichedFormData.tableName": enrichedFormData.tableName,
|
||||||
|
"enrichedFormData.id": enrichedFormData.id,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InteractiveScreenViewerDynamic
|
<InteractiveScreenViewerDynamic
|
||||||
key={component.id}
|
key={component.id}
|
||||||
component={adjustedComponent}
|
component={adjustedComponent}
|
||||||
allComponents={screenData.components}
|
allComponents={screenData.components}
|
||||||
formData={groupData.length > 0 ? groupData[0] : formData}
|
formData={enrichedFormData}
|
||||||
|
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
// 🆕 그룹 데이터가 있으면 처리
|
// 🆕 그룹 데이터가 있으면 처리
|
||||||
if (groupData.length > 0) {
|
if (groupData.length > 0) {
|
||||||
|
|
@ -1000,14 +1010,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
prev.map((item) => ({
|
prev.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
screenInfo={{
|
screenInfo={{
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import { cn } from "@/lib/utils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
||||||
|
|
@ -2101,113 +2102,115 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableOptionsProvider>
|
<SplitPanelProvider>
|
||||||
<div className="flex h-full flex-col">
|
<TableOptionsProvider>
|
||||||
{/* 테이블 옵션 툴바 */}
|
<div className="flex h-full flex-col">
|
||||||
<TableOptionsToolbar />
|
{/* 테이블 옵션 툴바 */}
|
||||||
|
<TableOptionsToolbar />
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
|
||||||
{shouldShowLabel && (
|
|
||||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
|
||||||
{labelText}
|
|
||||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
|
||||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
|
||||||
{showValidationPanel && enhancedValidation && (
|
|
||||||
<div className="absolute bottom-4 right-4 z-50">
|
|
||||||
<FormValidationIndicator
|
|
||||||
validationState={enhancedValidation.validationState}
|
|
||||||
saveState={enhancedValidation.saveState}
|
|
||||||
onSave={async () => {
|
|
||||||
const success = await enhancedValidation.saveForm();
|
|
||||||
if (success) {
|
|
||||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
canSave={enhancedValidation.canSave}
|
|
||||||
compact={true}
|
|
||||||
showDetails={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 모달 화면 */}
|
|
||||||
<Dialog open={!!popupScreen} onOpenChange={() => {
|
|
||||||
setPopupScreen(null);
|
|
||||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
|
||||||
}}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
|
||||||
<DialogHeader className="px-6 pt-4 pb-2">
|
|
||||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
{/* 메인 컨텐츠 */}
|
||||||
{popupLoading ? (
|
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||||
<div className="flex items-center justify-center py-8">
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
<div className="text-muted-foreground">화면을 불러오는 중...</div>
|
{shouldShowLabel && (
|
||||||
</div>
|
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
) : popupLayout.length > 0 ? (
|
{labelText}
|
||||||
<div className="relative bg-background border rounded" style={{
|
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||||
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
</label>
|
||||||
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
|
||||||
minHeight: "400px",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden"
|
|
||||||
}}>
|
|
||||||
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
|
||||||
{popupLayout.map((popupComponent) => (
|
|
||||||
<div
|
|
||||||
key={popupComponent.id}
|
|
||||||
className="absolute"
|
|
||||||
style={{
|
|
||||||
left: `${popupComponent.position.x}px`,
|
|
||||||
top: `${popupComponent.position.y}px`,
|
|
||||||
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
|
||||||
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
|
||||||
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
|
||||||
<InteractiveScreenViewer
|
|
||||||
component={popupComponent}
|
|
||||||
allComponents={popupLayout}
|
|
||||||
hideLabel={false}
|
|
||||||
screenInfo={popupScreenInfo || undefined}
|
|
||||||
formData={popupFormData}
|
|
||||||
onFormDataChange={(fieldName, value) => {
|
|
||||||
console.log("💾 팝업 formData 업데이트:", {
|
|
||||||
fieldName,
|
|
||||||
value,
|
|
||||||
valueType: typeof value,
|
|
||||||
prevFormData: popupFormData
|
|
||||||
});
|
|
||||||
|
|
||||||
setPopupFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-muted-foreground">화면 데이터가 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||||
|
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
|
||||||
</TableOptionsProvider>
|
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||||
|
{showValidationPanel && enhancedValidation && (
|
||||||
|
<div className="absolute bottom-4 right-4 z-50">
|
||||||
|
<FormValidationIndicator
|
||||||
|
validationState={enhancedValidation.validationState}
|
||||||
|
saveState={enhancedValidation.saveState}
|
||||||
|
onSave={async () => {
|
||||||
|
const success = await enhancedValidation.saveForm();
|
||||||
|
if (success) {
|
||||||
|
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
canSave={enhancedValidation.canSave}
|
||||||
|
compact={true}
|
||||||
|
showDetails={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달 화면 */}
|
||||||
|
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||||
|
setPopupScreen(null);
|
||||||
|
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||||
|
<DialogHeader className="px-6 pt-4 pb-2">
|
||||||
|
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||||
|
{popupLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">화면을 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
) : popupLayout.length > 0 ? (
|
||||||
|
<div className="relative bg-background border rounded" style={{
|
||||||
|
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
||||||
|
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
||||||
|
minHeight: "400px",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}>
|
||||||
|
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
||||||
|
{popupLayout.map((popupComponent) => (
|
||||||
|
<div
|
||||||
|
key={popupComponent.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${popupComponent.position.x}px`,
|
||||||
|
top: `${popupComponent.position.y}px`,
|
||||||
|
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
||||||
|
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
||||||
|
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||||
|
<InteractiveScreenViewer
|
||||||
|
component={popupComponent}
|
||||||
|
allComponents={popupLayout}
|
||||||
|
hideLabel={false}
|
||||||
|
screenInfo={popupScreenInfo || undefined}
|
||||||
|
formData={popupFormData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
console.log("💾 팝업 formData 업데이트:", {
|
||||||
|
fieldName,
|
||||||
|
value,
|
||||||
|
valueType: typeof value,
|
||||||
|
prevFormData: popupFormData
|
||||||
|
});
|
||||||
|
|
||||||
|
setPopupFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">화면 데이터가 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</TableOptionsProvider>
|
||||||
|
</SplitPanelProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
||||||
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
||||||
|
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
Type,
|
Type,
|
||||||
|
|
@ -110,8 +111,8 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
||||||
const WidgetRenderer: React.FC<{
|
const WidgetRenderer: React.FC<{
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
isDesignMode?: boolean;
|
isDesignMode?: boolean;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
|
|
@ -253,22 +254,23 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
// 플로우 위젯의 실제 높이 측정
|
// 플로우 위젯의 실제 높이 측정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
const isFlowWidget =
|
||||||
|
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||||
|
|
||||||
if (isFlowWidget && contentRef.current) {
|
if (isFlowWidget && contentRef.current) {
|
||||||
const measureHeight = () => {
|
const measureHeight = () => {
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
// getBoundingClientRect()로 실제 렌더링된 높이 측정
|
// getBoundingClientRect()로 실제 렌더링된 높이 측정
|
||||||
const rect = contentRef.current.getBoundingClientRect();
|
const rect = contentRef.current.getBoundingClientRect();
|
||||||
const measured = rect.height;
|
const measured = rect.height;
|
||||||
|
|
||||||
// scrollHeight도 함께 확인하여 더 큰 값 사용
|
// scrollHeight도 함께 확인하여 더 큰 값 사용
|
||||||
const scrollHeight = contentRef.current.scrollHeight;
|
const scrollHeight = contentRef.current.scrollHeight;
|
||||||
const rawHeight = Math.max(measured, scrollHeight);
|
const rawHeight = Math.max(measured, scrollHeight);
|
||||||
|
|
||||||
// 40px 단위로 올림
|
// 40px 단위로 올림
|
||||||
const finalHeight = Math.ceil(rawHeight / 40) * 40;
|
const finalHeight = Math.ceil(rawHeight / 40) * 40;
|
||||||
|
|
||||||
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
|
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
|
||||||
setActualHeight(finalHeight);
|
setActualHeight(finalHeight);
|
||||||
}
|
}
|
||||||
|
|
@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
}, [component.id, fileUpdateTrigger]);
|
}, [component.id, fileUpdateTrigger]);
|
||||||
|
|
||||||
// 컴포넌트 스타일 계산
|
// 컴포넌트 스타일 계산
|
||||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
const isFlowWidget =
|
||||||
|
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||||
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
||||||
|
|
||||||
const positionX = position?.x || 0;
|
const positionX = position?.x || 0;
|
||||||
const positionY = position?.y || 0;
|
const positionY = position?.y || 0;
|
||||||
|
|
||||||
|
// 🆕 분할 패널 리사이즈 Context
|
||||||
|
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
|
||||||
|
|
||||||
|
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||||
|
const componentType = (component as any).componentType || "";
|
||||||
|
const componentId = (component as any).componentId || "";
|
||||||
|
const widgetType = (component as any).widgetType || "";
|
||||||
|
|
||||||
|
const isButtonComponent =
|
||||||
|
(type === "widget" && widgetType === "button") ||
|
||||||
|
(type === "component" &&
|
||||||
|
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||||
|
["button-primary", "button-secondary"].includes(componentId)));
|
||||||
|
|
||||||
|
// 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만)
|
||||||
|
if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) {
|
||||||
|
console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", {
|
||||||
|
id: component.id,
|
||||||
|
type,
|
||||||
|
componentType,
|
||||||
|
componentId,
|
||||||
|
widgetType,
|
||||||
|
isButtonComponent,
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||||
|
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => {
|
||||||
|
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||||
|
const isSplitPanelComponent =
|
||||||
|
type === "component" &&
|
||||||
|
["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || "");
|
||||||
|
|
||||||
|
if (!isButtonComponent || isSplitPanelComponent) {
|
||||||
|
return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentWidth = size?.width || 100;
|
||||||
|
const componentHeight = size?.height || 40;
|
||||||
|
|
||||||
|
// 분할 패널 위에 있는지 확인
|
||||||
|
const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
// 디버깅: 버튼이 분할 패널 위에 있는지 확인
|
||||||
|
if (isButtonComponent) {
|
||||||
|
console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", {
|
||||||
|
componentId: component.id,
|
||||||
|
componentType: (component as any).componentType,
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
componentWidth,
|
||||||
|
componentHeight,
|
||||||
|
hasOverlap: !!overlap,
|
||||||
|
isInLeftPanel: overlap?.isInLeftPanel,
|
||||||
|
panelInfo: overlap
|
||||||
|
? {
|
||||||
|
panelId: overlap.panelId,
|
||||||
|
panelX: overlap.panel.x,
|
||||||
|
panelY: overlap.panel.y,
|
||||||
|
panelWidth: overlap.panel.width,
|
||||||
|
leftWidthPercent: overlap.panel.leftWidthPercent,
|
||||||
|
initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overlap || !overlap.isInLeftPanel) {
|
||||||
|
// 분할 패널 위에 없거나 우측 패널 위에 있음
|
||||||
|
return {
|
||||||
|
adjustedPositionX: positionX,
|
||||||
|
isOnSplitPanel: !!overlap,
|
||||||
|
isDraggingSplitPanel: overlap?.panel.isDragging ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌측 패널 위에 있음 - 위치 조정
|
||||||
|
const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", {
|
||||||
|
componentId: component.id,
|
||||||
|
originalX: positionX,
|
||||||
|
adjustedX: adjusted,
|
||||||
|
delta: adjusted - positionX,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
adjustedPositionX: adjusted,
|
||||||
|
isOnSplitPanel: true,
|
||||||
|
isDraggingSplitPanel: overlap.panel.isDragging,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
size?.width,
|
||||||
|
size?.height,
|
||||||
|
isButtonComponent,
|
||||||
|
type,
|
||||||
|
component,
|
||||||
|
getAdjustedX,
|
||||||
|
getOverlappingSplitPanel,
|
||||||
|
]);
|
||||||
|
|
||||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||||
const getWidth = () => {
|
const getWidth = () => {
|
||||||
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||||
|
|
@ -437,23 +545,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
...style, // 먼저 적용하고
|
...style, // 먼저 적용하고
|
||||||
left: positionX,
|
left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용
|
||||||
top: positionY,
|
top: positionY,
|
||||||
width: getWidth(), // 우선순위에 따른 너비
|
width: getWidth(), // 우선순위에 따른 너비
|
||||||
height: getHeight(), // 우선순위에 따른 높이
|
height: getHeight(), // 우선순위에 따른 높이
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
// right 속성 강제 제거
|
// right 속성 강제 제거
|
||||||
right: undefined,
|
right: undefined,
|
||||||
|
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||||
|
transition:
|
||||||
|
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 선택된 컴포넌트 스타일
|
// 선택된 컴포넌트 스타일
|
||||||
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
||||||
const selectionStyle = isSelected && !isSectionPaper
|
const selectionStyle =
|
||||||
? {
|
isSelected && !isSectionPaper
|
||||||
outline: "2px solid rgb(59, 130, 246)",
|
? {
|
||||||
outlineOffset: "2px",
|
outline: "2px solid rgb(59, 130, 246)",
|
||||||
}
|
outlineOffset: "2px",
|
||||||
: {};
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
|
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
|
||||||
|
|
@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{/* 컴포넌트 타입별 렌더링 */}
|
{/* 컴포넌트 타입별 렌더링 */}
|
||||||
<div
|
<div ref={isFlowWidget ? contentRef : undefined} className="h-full w-full">
|
||||||
ref={isFlowWidget ? contentRef : undefined}
|
|
||||||
className="h-full w-full"
|
|
||||||
>
|
|
||||||
{/* 영역 타입 */}
|
{/* 영역 타입 */}
|
||||||
{type === "area" && renderArea(component, children)}
|
{type === "area" && renderArea(component, children)}
|
||||||
|
|
||||||
|
|
@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-auto w-full">
|
<div className="h-auto w-full">
|
||||||
<FlowWidget
|
<FlowWidget component={flowComponent as any} onSelectedDataChange={onFlowSelectedDataChange} />
|
||||||
component={flowComponent as any}
|
|
||||||
onSelectedDataChange={onFlowSelectedDataChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 탭 컴포넌트 타입 */}
|
{/* 탭 컴포넌트 타입 */}
|
||||||
{(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) &&
|
{(type === "tabs" ||
|
||||||
|
(type === "component" &&
|
||||||
|
((component as any).componentType === "tabs-widget" ||
|
||||||
|
(component as any).componentId === "tabs-widget"))) &&
|
||||||
(() => {
|
(() => {
|
||||||
console.log("🎯 탭 컴포넌트 조건 충족:", {
|
console.log("🎯 탭 컴포넌트 조건 충족:", {
|
||||||
type,
|
type,
|
||||||
|
|
@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
<Badge key={tab.id} variant="outline" className="text-xs">
|
<Badge key={tab.id} variant="outline" className="text-xs">
|
||||||
{tab.label || `탭 ${index + 1}`}
|
{tab.label || `탭 ${index + 1}`}
|
||||||
{tab.screenName && (
|
{tab.screenName && (
|
||||||
<span className="ml-1 text-[10px] text-gray-400">
|
<span className="ml-1 text-[10px] text-gray-400">({tab.screenName})</span>
|
||||||
({tab.screenName})
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||||
{type === "component" && (() => {
|
{type === "component" &&
|
||||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
(() => {
|
||||||
return (
|
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||||
<DynamicComponentRenderer
|
return (
|
||||||
component={component}
|
<DynamicComponentRenderer
|
||||||
isSelected={isSelected}
|
component={component}
|
||||||
isDesignMode={isDesignMode}
|
isSelected={isSelected}
|
||||||
onClick={onClick}
|
isDesignMode={isDesignMode}
|
||||||
onDragStart={onDragStart}
|
onClick={onClick}
|
||||||
onDragEnd={onDragEnd}
|
onDragStart={onDragStart}
|
||||||
{...restProps}
|
onDragEnd={onDragEnd}
|
||||||
>
|
{...restProps}
|
||||||
{children}
|
>
|
||||||
</DynamicComponentRenderer>
|
{children}
|
||||||
);
|
</DynamicComponentRenderer>
|
||||||
})()}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||||
{type === "widget" && !isFileComponent(component) && (
|
{type === "widget" && !isFileComponent(component) && (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WidgetRenderer
|
<WidgetRenderer
|
||||||
component={component}
|
component={component}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Building,
|
Building,
|
||||||
File,
|
File,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
|
|
||||||
// 컴포넌트 렌더러들 자동 등록
|
// 컴포넌트 렌더러들 자동 등록
|
||||||
import "@/lib/registry/components";
|
import "@/lib/registry/components";
|
||||||
|
|
@ -60,7 +61,7 @@ interface RealtimePreviewProps {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
columnOrder?: string[];
|
columnOrder?: string[];
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
|
// 🆕 분할 패널 리사이즈 Context
|
||||||
|
const splitPanelContext = useSplitPanel();
|
||||||
|
|
||||||
|
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||||
|
const componentType = (component as any).componentType || "";
|
||||||
|
const componentId = (component as any).componentId || "";
|
||||||
|
const widgetType = (component as any).widgetType || "";
|
||||||
|
|
||||||
|
const isButtonComponent =
|
||||||
|
(type === "widget" && widgetType === "button") ||
|
||||||
|
(type === "component" &&
|
||||||
|
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||||
|
["button-primary", "button-secondary"].includes(componentId)));
|
||||||
|
|
||||||
|
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
||||||
|
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||||
|
const initialPanelIdRef = React.useRef<string | null>(null);
|
||||||
|
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
||||||
|
const isInLeftPanelRef = React.useRef<boolean | null>(null);
|
||||||
|
|
||||||
|
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||||
|
const calculateButtonPosition = () => {
|
||||||
|
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||||
|
const isSplitPanelComponent =
|
||||||
|
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
||||||
|
|
||||||
|
if (!isButtonComponent || isSplitPanelComponent) {
|
||||||
|
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentWidth = size?.width || 100;
|
||||||
|
const componentHeight = size?.height || 40;
|
||||||
|
|
||||||
|
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
||||||
|
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
// 분할 패널 위에 없으면 기준점 초기화
|
||||||
|
if (!overlap) {
|
||||||
|
if (initialPanelIdRef.current !== null) {
|
||||||
|
initialPanelRatioRef.current = null;
|
||||||
|
initialPanelIdRef.current = null;
|
||||||
|
isInLeftPanelRef.current = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adjustedPositionX: position.x,
|
||||||
|
isOnSplitPanel: false,
|
||||||
|
isDraggingSplitPanel: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { panel } = overlap;
|
||||||
|
|
||||||
|
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
||||||
|
if (initialPanelIdRef.current !== overlap.panelId) {
|
||||||
|
initialPanelRatioRef.current = panel.leftWidthPercent;
|
||||||
|
initialPanelIdRef.current = overlap.panelId;
|
||||||
|
|
||||||
|
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
||||||
|
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
||||||
|
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||||
|
const componentCenterX = position.x + componentWidth / 2;
|
||||||
|
const relativeX = componentCenterX - panel.x;
|
||||||
|
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
||||||
|
|
||||||
|
isInLeftPanelRef.current = wasInLeftPanel;
|
||||||
|
console.log("📌 [버튼 기준점 설정]:", {
|
||||||
|
componentId: component.id,
|
||||||
|
panelId: overlap.panelId,
|
||||||
|
initialRatio: panel.leftWidthPercent,
|
||||||
|
isInLeftPanel: wasInLeftPanel,
|
||||||
|
buttonCenterX: componentCenterX,
|
||||||
|
leftPanelWidth: initialLeftPanelWidth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
||||||
|
if (!isInLeftPanelRef.current) {
|
||||||
|
return {
|
||||||
|
adjustedPositionX: position.x,
|
||||||
|
isOnSplitPanel: true,
|
||||||
|
isDraggingSplitPanel: panel.isDragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
||||||
|
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
|
||||||
|
|
||||||
|
// 기준 비율 대비 현재 비율로 분할선 위치 계산
|
||||||
|
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
|
||||||
|
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
|
||||||
|
|
||||||
|
// 분할선 이동량 (px)
|
||||||
|
const dividerDelta = currentDividerX - baseDividerX;
|
||||||
|
|
||||||
|
// 변화가 없으면 원래 위치 반환
|
||||||
|
if (Math.abs(dividerDelta) < 1) {
|
||||||
|
return {
|
||||||
|
adjustedPositionX: position.x,
|
||||||
|
isOnSplitPanel: true,
|
||||||
|
isDraggingSplitPanel: panel.isDragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
||||||
|
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
|
||||||
|
const adjustedX = position.x + dividerDelta;
|
||||||
|
|
||||||
|
console.log("📍 [버튼 위치 조정]:", {
|
||||||
|
componentId: component.id,
|
||||||
|
originalX: position.x,
|
||||||
|
adjustedX,
|
||||||
|
dividerDelta,
|
||||||
|
baseRatio,
|
||||||
|
currentRatio: panel.leftWidthPercent,
|
||||||
|
baseDividerX,
|
||||||
|
currentDividerX,
|
||||||
|
isDragging: panel.isDragging,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
adjustedPositionX: adjustedX,
|
||||||
|
isOnSplitPanel: true,
|
||||||
|
isDraggingSplitPanel: panel.isDragging,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||||
|
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
left: `${position.x}px`,
|
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||||
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
||||||
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
right: undefined,
|
right: undefined,
|
||||||
|
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||||
|
transition:
|
||||||
|
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
|
|
||||||
|
interface SplitPanelAwareWrapperProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
componentX: number;
|
||||||
|
componentY: number;
|
||||||
|
componentWidth: number;
|
||||||
|
componentHeight: number;
|
||||||
|
componentType?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 드래그 리사이즈에 따라 컴포넌트 위치를 자동 조정하는 래퍼
|
||||||
|
*
|
||||||
|
* 동작 방식:
|
||||||
|
* 1. 컴포넌트가 분할 패널의 좌측 영역 위에 있는지 감지
|
||||||
|
* 2. 좌측 영역 위에 있으면, 드래그 핸들 이동량만큼 X 좌표를 조정
|
||||||
|
* 3. 우측 영역이나 분할 패널 외부에 있으면 원래 위치 유지
|
||||||
|
*/
|
||||||
|
export const SplitPanelAwareWrapper: React.FC<SplitPanelAwareWrapperProps> = ({
|
||||||
|
children,
|
||||||
|
componentX,
|
||||||
|
componentY,
|
||||||
|
componentWidth,
|
||||||
|
componentHeight,
|
||||||
|
componentType,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
|
||||||
|
|
||||||
|
// 분할 패널 위에 있는지 확인 및 조정된 X 좌표 계산
|
||||||
|
const { adjustedX, isInLeftPanel, isDragging } = useMemo(() => {
|
||||||
|
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
if (!overlap) {
|
||||||
|
// 분할 패널 위에 없음
|
||||||
|
return { adjustedX: componentX, isInLeftPanel: false, isDragging: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overlap.isInLeftPanel) {
|
||||||
|
// 우측 패널 위에 있음 - 원래 위치 유지
|
||||||
|
return { adjustedX: componentX, isInLeftPanel: false, isDragging: overlap.panel.isDragging };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌측 패널 위에 있음 - 위치 조정
|
||||||
|
const adjusted = getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
adjustedX: adjusted,
|
||||||
|
isInLeftPanel: true,
|
||||||
|
isDragging: overlap.panel.isDragging,
|
||||||
|
};
|
||||||
|
}, [componentX, componentY, componentWidth, componentHeight, getAdjustedX, getOverlappingSplitPanel]);
|
||||||
|
|
||||||
|
// 조정된 스타일
|
||||||
|
const adjustedStyle: React.CSSProperties = {
|
||||||
|
...style,
|
||||||
|
position: "absolute",
|
||||||
|
left: `${adjustedX}px`,
|
||||||
|
top: `${componentY}px`,
|
||||||
|
width: componentWidth,
|
||||||
|
height: componentHeight,
|
||||||
|
// 드래그 중에는 트랜지션 없이 즉시 이동, 드래그 끝나면 부드럽게
|
||||||
|
transition: isDragging ? "none" : "left 0.1s ease-out",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디버그 로깅 (개발 중에만)
|
||||||
|
// if (isInLeftPanel) {
|
||||||
|
// console.log(`📍 [SplitPanelAwareWrapper] 위치 조정:`, {
|
||||||
|
// componentType,
|
||||||
|
// originalX: componentX,
|
||||||
|
// adjustedX,
|
||||||
|
// delta: adjustedX - componentX,
|
||||||
|
// isInLeftPanel,
|
||||||
|
// isDragging,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={adjustedStyle} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SplitPanelAwareWrapper;
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Settings, Clock, Info, Workflow } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Settings, Clock, Info, Workflow, Plus, Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
|
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
|
||||||
|
|
||||||
|
|
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
|
||||||
onUpdateProperty: (path: string, value: any) => void;
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 다중 제어 설정 인터페이스
|
||||||
|
interface FlowControlConfig {
|
||||||
|
id: string;
|
||||||
|
flowId: number;
|
||||||
|
flowName: string;
|
||||||
|
executionTiming: "before" | "after" | "replace";
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 단순화된 버튼 제어 설정 패널
|
* 🔥 다중 제어 지원 버튼 설정 패널
|
||||||
*
|
*
|
||||||
* 노드 플로우 실행만 지원:
|
* 기능:
|
||||||
* - 플로우 선택 및 실행 타이밍 설정
|
* - 여러 개의 노드 플로우 선택 및 순서 지정
|
||||||
|
* - 각 플로우별 실행 타이밍 설정
|
||||||
|
* - 드래그앤드롭 또는 버튼으로 순서 변경
|
||||||
*/
|
*/
|
||||||
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
|
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
|
||||||
component,
|
component,
|
||||||
|
|
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
const config = component.webTypeConfig || {};
|
const config = component.webTypeConfig || {};
|
||||||
const dataflowConfig = config.dataflowConfig || {};
|
const dataflowConfig = config.dataflowConfig || {};
|
||||||
|
|
||||||
|
// 다중 제어 설정 (배열)
|
||||||
|
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
|
||||||
|
|
||||||
// 🔥 State 관리
|
// 🔥 State 관리
|
||||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 플로우 선택 핸들러
|
* 🔥 제어 추가
|
||||||
*/
|
*/
|
||||||
const handleFlowSelect = (flowId: string) => {
|
const handleAddControl = useCallback(() => {
|
||||||
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
const newControl: FlowControlConfig = {
|
||||||
if (selectedFlow) {
|
id: `control_${Date.now()}`,
|
||||||
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함)
|
flowId: 0,
|
||||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
flowName: "",
|
||||||
...dataflowConfig,
|
executionTiming: "after",
|
||||||
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용
|
order: flowControls.length + 1,
|
||||||
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요
|
};
|
||||||
flowConfig: {
|
|
||||||
flowId: selectedFlow.flowId,
|
const updatedControls = [...flowControls, newControl];
|
||||||
flowName: selectedFlow.flowName,
|
updateFlowControls(updatedControls);
|
||||||
executionTiming: "before", // 기본값
|
}, [flowControls]);
|
||||||
contextData: {},
|
|
||||||
},
|
/**
|
||||||
});
|
* 🔥 제어 삭제
|
||||||
}
|
*/
|
||||||
|
const handleRemoveControl = useCallback(
|
||||||
|
(controlId: string) => {
|
||||||
|
const updatedControls = flowControls
|
||||||
|
.filter((c) => c.id !== controlId)
|
||||||
|
.map((c, index) => ({ ...c, order: index + 1 }));
|
||||||
|
updateFlowControls(updatedControls);
|
||||||
|
},
|
||||||
|
[flowControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 제어 플로우 선택
|
||||||
|
*/
|
||||||
|
const handleFlowSelect = useCallback(
|
||||||
|
(controlId: string, flowId: string) => {
|
||||||
|
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
||||||
|
if (selectedFlow) {
|
||||||
|
const updatedControls = flowControls.map((c) =>
|
||||||
|
c.id === controlId ? { ...c, flowId: selectedFlow.flowId, flowName: selectedFlow.flowName } : c,
|
||||||
|
);
|
||||||
|
updateFlowControls(updatedControls);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flows, flowControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 실행 타이밍 변경
|
||||||
|
*/
|
||||||
|
const handleTimingChange = useCallback(
|
||||||
|
(controlId: string, timing: "before" | "after" | "replace") => {
|
||||||
|
const updatedControls = flowControls.map((c) => (c.id === controlId ? { ...c, executionTiming: timing } : c));
|
||||||
|
updateFlowControls(updatedControls);
|
||||||
|
},
|
||||||
|
[flowControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 순서 위로 이동
|
||||||
|
*/
|
||||||
|
const handleMoveUp = useCallback(
|
||||||
|
(controlId: string) => {
|
||||||
|
const index = flowControls.findIndex((c) => c.id === controlId);
|
||||||
|
if (index > 0) {
|
||||||
|
const updatedControls = [...flowControls];
|
||||||
|
[updatedControls[index - 1], updatedControls[index]] = [updatedControls[index], updatedControls[index - 1]];
|
||||||
|
// 순서 번호 재정렬
|
||||||
|
updatedControls.forEach((c, i) => (c.order = i + 1));
|
||||||
|
updateFlowControls(updatedControls);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flowControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 순서 아래로 이동
|
||||||
|
*/
|
||||||
|
const handleMoveDown = useCallback(
|
||||||
|
(controlId: string) => {
|
||||||
|
const index = flowControls.findIndex((c) => c.id === controlId);
|
||||||
|
if (index < flowControls.length - 1) {
|
||||||
|
const updatedControls = [...flowControls];
|
||||||
|
[updatedControls[index], updatedControls[index + 1]] = [updatedControls[index + 1], updatedControls[index]];
|
||||||
|
// 순서 번호 재정렬
|
||||||
|
updatedControls.forEach((c, i) => (c.order = i + 1));
|
||||||
|
updateFlowControls(updatedControls);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flowControls],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 제어 목록 업데이트 (백엔드 호환성 유지)
|
||||||
|
*/
|
||||||
|
const updateFlowControls = (controls: FlowControlConfig[]) => {
|
||||||
|
// 첫 번째 제어를 기존 형식으로도 저장 (하위 호환성)
|
||||||
|
const firstValidControl = controls.find((c) => c.flowId > 0);
|
||||||
|
|
||||||
|
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||||
|
...dataflowConfig,
|
||||||
|
// 기존 형식 (하위 호환성)
|
||||||
|
selectedDiagramId: firstValidControl?.flowId || null,
|
||||||
|
selectedRelationshipId: null,
|
||||||
|
flowConfig: firstValidControl
|
||||||
|
? {
|
||||||
|
flowId: firstValidControl.flowId,
|
||||||
|
flowName: firstValidControl.flowName,
|
||||||
|
executionTiming: firstValidControl.executionTiming,
|
||||||
|
contextData: {},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// 새로운 다중 제어 형식
|
||||||
|
flowControls: controls,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
|
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
|
||||||
{config.enableDataflowControl && (
|
{config.enableDataflowControl && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FlowSelector
|
{/* 제어 목록 헤더 */}
|
||||||
flows={flows}
|
<div className="flex items-center justify-between">
|
||||||
selectedFlowId={dataflowConfig.flowConfig?.flowId}
|
<div className="flex items-center space-x-2">
|
||||||
onSelect={handleFlowSelect}
|
<Workflow className="h-4 w-4 text-green-600" />
|
||||||
loading={loading}
|
<Label>제어 목록 (순서대로 실행)</Label>
|
||||||
/>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddControl} className="h-8">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
제어 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{dataflowConfig.flowConfig && (
|
{/* 제어 목록 */}
|
||||||
<div className="space-y-4">
|
{flowControls.length === 0 ? (
|
||||||
<Separator />
|
<div className="rounded-md border border-dashed p-6 text-center">
|
||||||
<ExecutionTimingSelector
|
<Workflow className="mx-auto h-8 w-8 text-gray-400" />
|
||||||
value={dataflowConfig.flowConfig.executionTiming}
|
<p className="mt-2 text-sm text-gray-500">등록된 제어가 없습니다</p>
|
||||||
onChange={(timing) =>
|
<Button variant="outline" size="sm" onClick={handleAddControl} className="mt-3">
|
||||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
|
<Plus className="mr-1 h-3 w-3" />첫 번째 제어 추가
|
||||||
}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{flowControls.map((control, index) => (
|
||||||
|
<FlowControlItem
|
||||||
|
key={control.id}
|
||||||
|
control={control}
|
||||||
|
flows={flows}
|
||||||
|
loading={loading}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === flowControls.length - 1}
|
||||||
|
onFlowSelect={(flowId) => handleFlowSelect(control.id, flowId)}
|
||||||
|
onTimingChange={(timing) => handleTimingChange(control.id, timing)}
|
||||||
|
onMoveUp={() => handleMoveUp(control.id)}
|
||||||
|
onMoveDown={() => handleMoveDown(control.id)}
|
||||||
|
onRemove={() => handleRemoveControl(control.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded bg-green-50 p-3">
|
{/* 안내 메시지 */}
|
||||||
<div className="flex items-start space-x-2">
|
{flowControls.length > 0 && (
|
||||||
<Info className="mt-0.5 h-4 w-4 text-green-600" />
|
<div className="rounded bg-blue-50 p-3">
|
||||||
<div className="text-xs text-green-800">
|
<div className="flex items-start space-x-2">
|
||||||
<p className="font-medium">노드 플로우 실행 정보:</p>
|
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||||
<p className="mt-1">선택한 플로우의 모든 노드가 순차적/병렬로 실행됩니다.</p>
|
<div className="text-xs text-blue-800">
|
||||||
<p className="mt-1">• 독립 트랜잭션: 각 액션은 독립적으로 커밋/롤백</p>
|
<p className="font-medium">다중 제어 실행 정보:</p>
|
||||||
<p>• 연쇄 중단: 부모 노드 실패 시 자식 노드 스킵</p>
|
<p className="mt-1">• 제어는 위에서 아래 순서대로 순차 실행됩니다</p>
|
||||||
</div>
|
<p>• 각 제어는 독립 트랜잭션으로 처리됩니다</p>
|
||||||
|
<p>• 이전 제어 실패 시 다음 제어는 실행되지 않습니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,90 +271,89 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 플로우 선택 컴포넌트
|
* 🔥 개별 제어 아이템 컴포넌트
|
||||||
*/
|
*/
|
||||||
const FlowSelector: React.FC<{
|
const FlowControlItem: React.FC<{
|
||||||
|
control: FlowControlConfig;
|
||||||
flows: NodeFlow[];
|
flows: NodeFlow[];
|
||||||
selectedFlowId?: number;
|
|
||||||
onSelect: (flowId: string) => void;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}> = ({ flows, selectedFlowId, onSelect, loading }) => {
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
onFlowSelect: (flowId: string) => void;
|
||||||
|
onTimingChange: (timing: "before" | "after" | "replace") => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}> = ({ control, flows, loading, isFirst, isLast, onFlowSelect, onTimingChange, onMoveUp, onMoveDown, onRemove }) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Card className="p-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-start gap-2">
|
||||||
<Workflow className="h-4 w-4 text-green-600" />
|
{/* 순서 표시 및 이동 버튼 */}
|
||||||
<Label>실행할 노드 플로우 선택</Label>
|
<div className="flex flex-col items-center gap-1">
|
||||||
</div>
|
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-full p-0 text-xs">
|
||||||
|
{control.order}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveUp} disabled={isFirst}>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveDown} disabled={isLast}>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}>
|
{/* 플로우 선택 및 설정 */}
|
||||||
<SelectTrigger>
|
<div className="flex-1 space-y-2">
|
||||||
<SelectValue placeholder="플로우를 선택하세요" />
|
{/* 플로우 선택 */}
|
||||||
</SelectTrigger>
|
<Select value={control.flowId > 0 ? control.flowId.toString() : ""} onValueChange={onFlowSelect}>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-8 text-xs">
|
||||||
{loading ? (
|
<SelectValue placeholder="플로우를 선택하세요" />
|
||||||
<div className="p-4 text-center text-sm text-gray-500">플로우 목록을 불러오는 중...</div>
|
</SelectTrigger>
|
||||||
) : flows.length === 0 ? (
|
<SelectContent>
|
||||||
<div className="p-4 text-center text-sm text-gray-500">
|
{loading ? (
|
||||||
<p>사용 가능한 플로우가 없습니다</p>
|
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||||
<p className="mt-2 text-xs">노드 편집기에서 플로우를 먼저 생성하세요</p>
|
) : flows.length === 0 ? (
|
||||||
</div>
|
<div className="p-2 text-center text-xs text-gray-500">플로우가 없습니다</div>
|
||||||
) : (
|
) : (
|
||||||
flows.map((flow) => (
|
flows.map((flow) => (
|
||||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||||
<div className="flex flex-col">
|
<span className="text-xs">{flow.flowName}</span>
|
||||||
<span className="font-medium">{flow.flowName}</span>
|
</SelectItem>
|
||||||
{flow.flowDescription && (
|
))
|
||||||
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
|
)}
|
||||||
)}
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
|
||||||
|
{/* 실행 타이밍 */}
|
||||||
|
<Select value={control.executionTiming} onValueChange={onTimingChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="before">
|
||||||
|
<span className="text-xs">Before (사전 실행)</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
<SelectItem value="after">
|
||||||
)}
|
<span className="text-xs">After (사후 실행)</span>
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
<SelectItem value="replace">
|
||||||
</div>
|
<span className="text-xs">Replace (대체 실행)</span>
|
||||||
);
|
</SelectItem>
|
||||||
};
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
/**
|
{/* 삭제 버튼 */}
|
||||||
* 🔥 실행 타이밍 선택 컴포넌트
|
<Button
|
||||||
*/
|
variant="ghost"
|
||||||
const ExecutionTimingSelector: React.FC<{
|
size="icon"
|
||||||
value: string;
|
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
onChange: (timing: "before" | "after" | "replace") => void;
|
onClick={onRemove}
|
||||||
}> = ({ value, onChange }) => {
|
>
|
||||||
return (
|
<Trash2 className="h-4 w-4" />
|
||||||
<div className="space-y-2">
|
</Button>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Clock className="h-4 w-4 text-orange-600" />
|
|
||||||
<Label>실행 타이밍</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
<Select value={value} onValueChange={onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="실행 타이밍을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="before">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">Before (사전 실행)</span>
|
|
||||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 전에 플로우를 실행합니다</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="after">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">After (사후 실행)</span>
|
|
||||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 후에 플로우를 실행합니다</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="replace">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">Replace (대체 실행)</span>
|
|
||||||
<span className="text-muted-foreground text-xs">버튼 액션 대신 플로우만 실행합니다</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,13 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Plus, X } from "lucide-react";
|
import { Layers } from "lucide-react";
|
||||||
import { TableFilter } from "@/types/table-options";
|
import { TableFilter, GroupSumConfig } from "@/types/table-options";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -77,17 +73,37 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
|
||||||
const [selectAll, setSelectAll] = useState(false);
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 그룹별 합산 설정
|
||||||
|
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
|
||||||
|
const [groupByColumn, setGroupByColumn] = useState<string>("");
|
||||||
|
|
||||||
// localStorage에서 저장된 필터 설정 불러오기
|
// localStorage에서 저장된 필터 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (table?.columns && table?.tableName) {
|
if (table?.columns && table?.tableName) {
|
||||||
// 화면별로 독립적인 필터 설정 저장
|
// 화면별로 독립적인 필터 설정 저장
|
||||||
const storageKey = screenId
|
const storageKey = screenId
|
||||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||||
: `table_filters_${table.tableName}`;
|
: `table_filters_${table.tableName}`;
|
||||||
const savedFilters = localStorage.getItem(storageKey);
|
const savedFilters = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
// 🆕 그룹핑 설정도 불러오기
|
||||||
|
const groupSumKey = screenId
|
||||||
|
? `table_groupsum_${table.tableName}_screen_${screenId}`
|
||||||
|
: `table_groupsum_${table.tableName}`;
|
||||||
|
const savedGroupSum = localStorage.getItem(groupSumKey);
|
||||||
|
|
||||||
|
if (savedGroupSum) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedGroupSum) as GroupSumConfig;
|
||||||
|
setGroupSumEnabled(parsed.enabled);
|
||||||
|
setGroupByColumn(parsed.groupByColumn || "");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹핑 설정 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let filters: ColumnFilterConfig[];
|
let filters: ColumnFilterConfig[];
|
||||||
|
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[];
|
const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[];
|
||||||
|
|
@ -96,13 +112,15 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
.filter((col) => col.filterable !== false)
|
.filter((col) => col.filterable !== false)
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
const saved = parsed.find((f) => f.columnName === col.columnName);
|
const saved = parsed.find((f) => f.columnName === col.columnName);
|
||||||
return saved || {
|
return (
|
||||||
columnName: col.columnName,
|
saved || {
|
||||||
columnLabel: col.columnLabel,
|
columnName: col.columnName,
|
||||||
inputType: col.inputType || "text",
|
columnLabel: col.columnLabel,
|
||||||
enabled: false,
|
inputType: col.inputType || "text",
|
||||||
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
enabled: false,
|
||||||
};
|
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장된 필터 설정 불러오기 실패:", error);
|
console.error("저장된 필터 설정 불러오기 실패:", error);
|
||||||
|
|
@ -127,26 +145,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
filterType: mapInputTypeToFilterType(col.inputType || "text"),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
setColumnFilters(filters);
|
setColumnFilters(filters);
|
||||||
}
|
}
|
||||||
}, [table?.columns, table?.tableName]);
|
}, [table?.columns, table?.tableName]);
|
||||||
|
|
||||||
// inputType을 filterType으로 매핑
|
// inputType을 filterType으로 매핑
|
||||||
const mapInputTypeToFilterType = (
|
const mapInputTypeToFilterType = (inputType: string): "text" | "number" | "date" | "select" => {
|
||||||
inputType: string
|
|
||||||
): "text" | "number" | "date" | "select" => {
|
|
||||||
if (inputType.includes("number") || inputType.includes("decimal")) {
|
if (inputType.includes("number") || inputType.includes("decimal")) {
|
||||||
return "number";
|
return "number";
|
||||||
}
|
}
|
||||||
if (inputType.includes("date") || inputType.includes("time")) {
|
if (inputType.includes("date") || inputType.includes("time")) {
|
||||||
return "date";
|
return "date";
|
||||||
}
|
}
|
||||||
if (
|
if (inputType.includes("select") || inputType.includes("code") || inputType.includes("category")) {
|
||||||
inputType.includes("select") ||
|
|
||||||
inputType.includes("code") ||
|
|
||||||
inputType.includes("category")
|
|
||||||
) {
|
|
||||||
return "select";
|
return "select";
|
||||||
}
|
}
|
||||||
return "text";
|
return "text";
|
||||||
|
|
@ -155,31 +167,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
// 전체 선택/해제
|
// 전체 선택/해제
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
const toggleSelectAll = (checked: boolean) => {
|
||||||
setSelectAll(checked);
|
setSelectAll(checked);
|
||||||
setColumnFilters((prev) =>
|
setColumnFilters((prev) => prev.map((filter) => ({ ...filter, enabled: checked })));
|
||||||
prev.map((filter) => ({ ...filter, enabled: checked }))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 개별 필터 토글
|
// 개별 필터 토글
|
||||||
const toggleFilter = (columnName: string) => {
|
const toggleFilter = (columnName: string) => {
|
||||||
setColumnFilters((prev) =>
|
setColumnFilters((prev) =>
|
||||||
prev.map((filter) =>
|
prev.map((filter) => (filter.columnName === columnName ? { ...filter, enabled: !filter.enabled } : filter)),
|
||||||
filter.columnName === columnName
|
|
||||||
? { ...filter, enabled: !filter.enabled }
|
|
||||||
: filter
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터 타입 변경
|
// 필터 타입 변경
|
||||||
const updateFilterType = (
|
const updateFilterType = (columnName: string, filterType: "text" | "number" | "date" | "select") => {
|
||||||
columnName: string,
|
|
||||||
filterType: "text" | "number" | "date" | "select"
|
|
||||||
) => {
|
|
||||||
setColumnFilters((prev) =>
|
setColumnFilters((prev) =>
|
||||||
prev.map((filter) =>
|
prev.map((filter) => (filter.columnName === columnName ? { ...filter, filterType } : filter)),
|
||||||
filter.columnName === columnName ? { ...filter, filterType } : filter
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -198,44 +199,76 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
|
|
||||||
// localStorage에 저장 (화면별로 독립적)
|
// localStorage에 저장 (화면별로 독립적)
|
||||||
if (table?.tableName) {
|
if (table?.tableName) {
|
||||||
const storageKey = screenId
|
const storageKey = screenId
|
||||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||||
: `table_filters_${table.tableName}`;
|
: `table_filters_${table.tableName}`;
|
||||||
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
||||||
|
|
||||||
|
// 🆕 그룹핑 설정 저장
|
||||||
|
const groupSumKey = screenId
|
||||||
|
? `table_groupsum_${table.tableName}_screen_${screenId}`
|
||||||
|
: `table_groupsum_${table.tableName}`;
|
||||||
|
|
||||||
|
if (groupSumEnabled && groupByColumn) {
|
||||||
|
const selectedColumn = columnFilters.find((f) => f.columnName === groupByColumn);
|
||||||
|
const groupSumConfig: GroupSumConfig = {
|
||||||
|
enabled: true,
|
||||||
|
groupByColumn: groupByColumn,
|
||||||
|
groupByColumnLabel: selectedColumn?.columnLabel,
|
||||||
|
};
|
||||||
|
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
|
||||||
|
table?.onGroupSumChange?.(groupSumConfig);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(groupSumKey);
|
||||||
|
table?.onGroupSumChange?.(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table?.onFilterChange(activeFilters);
|
table?.onFilterChange(activeFilters);
|
||||||
|
|
||||||
// 콜백으로 활성화된 필터 정보 전달
|
// 콜백으로 활성화된 필터 정보 전달
|
||||||
onFiltersApplied?.(activeFilters);
|
onFiltersApplied?.(activeFilters);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기화 (즉시 저장 및 적용)
|
// 초기화 (즉시 저장 및 적용)
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
const clearedFilters = columnFilters.map((filter) => ({
|
const clearedFilters = columnFilters.map((filter) => ({
|
||||||
...filter,
|
...filter,
|
||||||
enabled: false
|
enabled: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setColumnFilters(clearedFilters);
|
setColumnFilters(clearedFilters);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
|
|
||||||
|
// 🆕 그룹핑 설정 초기화
|
||||||
|
setGroupSumEnabled(false);
|
||||||
|
setGroupByColumn("");
|
||||||
|
|
||||||
// localStorage에서 제거 (화면별로 독립적)
|
// localStorage에서 제거 (화면별로 독립적)
|
||||||
if (table?.tableName) {
|
if (table?.tableName) {
|
||||||
const storageKey = screenId
|
const storageKey = screenId
|
||||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||||
: `table_filters_${table.tableName}`;
|
: `table_filters_${table.tableName}`;
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
|
|
||||||
|
// 🆕 그룹핑 설정도 제거
|
||||||
|
const groupSumKey = screenId
|
||||||
|
? `table_groupsum_${table.tableName}_screen_${screenId}`
|
||||||
|
: `table_groupsum_${table.tableName}`;
|
||||||
|
localStorage.removeItem(groupSumKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 빈 필터 배열로 적용
|
// 빈 필터 배열로 적용
|
||||||
table?.onFilterChange([]);
|
table?.onFilterChange([]);
|
||||||
|
|
||||||
|
// 🆕 그룹핑 해제
|
||||||
|
table?.onGroupSumChange?.(null);
|
||||||
|
|
||||||
// 콜백으로 빈 필터 정보 전달
|
// 콜백으로 빈 필터 정보 전달
|
||||||
onFiltersApplied?.([]);
|
onFiltersApplied?.([]);
|
||||||
|
|
||||||
// 즉시 닫기
|
// 즉시 닫기
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
@ -246,9 +279,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
||||||
검색 필터 설정
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
@ -256,17 +287,12 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* 전체 선택/해제 */}
|
{/* 전체 선택/해제 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
|
<div className="bg-muted/30 flex items-center justify-between rounded-lg border p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox checked={selectAll} onCheckedChange={(checked) => toggleSelectAll(checked as boolean)} />
|
||||||
checked={selectAll}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleSelectAll(checked as boolean)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">전체 선택/해제</span>
|
<span className="text-sm font-medium">전체 선택/해제</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-muted-foreground text-xs">
|
||||||
{enabledCount} / {columnFilters.length}개
|
{enabledCount} / {columnFilters.length}개
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -277,30 +303,21 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
{columnFilters.map((filter) => (
|
{columnFilters.map((filter) => (
|
||||||
<div
|
<div
|
||||||
key={filter.columnName}
|
key={filter.columnName}
|
||||||
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
|
className="bg-background hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 transition-colors"
|
||||||
>
|
>
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
<Checkbox
|
<Checkbox checked={filter.enabled} onCheckedChange={() => toggleFilter(filter.columnName)} />
|
||||||
checked={filter.enabled}
|
|
||||||
onCheckedChange={() => toggleFilter(filter.columnName)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 컬럼 정보 */}
|
{/* 컬럼 정보 */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">
|
<div className="truncate text-sm font-medium">{filter.columnLabel}</div>
|
||||||
{filter.columnLabel}
|
<div className="text-muted-foreground truncate text-xs">{filter.columnName}</div>
|
||||||
</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{filter.columnName}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 타입 선택 */}
|
{/* 필터 타입 선택 */}
|
||||||
<Select
|
<Select
|
||||||
value={filter.filterType}
|
value={filter.filterType}
|
||||||
onValueChange={(val: any) =>
|
onValueChange={(val: any) => updateFilterType(filter.columnName, val)}
|
||||||
updateFilterType(filter.columnName, val)
|
|
||||||
}
|
|
||||||
disabled={!filter.enabled}
|
disabled={!filter.enabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[110px] text-xs sm:h-9 sm:text-sm">
|
<SelectTrigger className="h-8 w-[110px] text-xs sm:h-9 sm:text-sm">
|
||||||
|
|
@ -321,11 +338,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newWidth = parseInt(e.target.value) || 200;
|
const newWidth = parseInt(e.target.value) || 200;
|
||||||
setColumnFilters((prev) =>
|
setColumnFilters((prev) =>
|
||||||
prev.map((f) =>
|
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
|
||||||
f.columnName === filter.columnName
|
|
||||||
? { ...f, width: newWidth }
|
|
||||||
: f
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={!filter.enabled}
|
disabled={!filter.enabled}
|
||||||
|
|
@ -334,31 +347,56 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
min={50}
|
min={50}
|
||||||
max={500}
|
max={500}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
<span className="text-muted-foreground text-xs">px</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 🆕 그룹별 합산 설정 */}
|
||||||
|
<div className="bg-muted/30 space-y-3 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="text-muted-foreground h-4 w-4" />
|
||||||
|
<Label htmlFor="group-sum-toggle" className="cursor-pointer text-sm font-medium">
|
||||||
|
그룹별 합산
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Switch id="group-sum-toggle" checked={groupSumEnabled} onCheckedChange={setGroupSumEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupSumEnabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
그룹 기준 컬럼 (숫자 컬럼은 자동으로 합산됩니다)
|
||||||
|
</Label>
|
||||||
|
<Select value={groupByColumn} onValueChange={setGroupByColumn}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="그룹 기준 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnFilters.map((filter) => (
|
||||||
|
<SelectItem key={filter.columnName} value={filter.columnName}>
|
||||||
|
{filter.columnLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 안내 메시지 */}
|
{/* 안내 메시지 */}
|
||||||
<div className="rounded-lg bg-muted/50 p-3 text-center text-xs text-muted-foreground">
|
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-center text-xs">
|
||||||
검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요
|
검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button variant="ghost" onClick={clearFilters} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
variant="ghost"
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
>
|
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" onClick={onClose} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -373,4 +411,3 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
|
import {
|
||||||
|
RepeaterFieldGroupConfig,
|
||||||
|
RepeaterData,
|
||||||
|
RepeaterItemData,
|
||||||
|
RepeaterFieldDefinition,
|
||||||
|
CalculationFormula,
|
||||||
|
} from "@/types/repeater";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||||
|
|
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||||
|
|
||||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
const [categoryMappings, setCategoryMappings] = useState<
|
||||||
|
Record<string, Record<string, { label: string; color: string }>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 설정 기본값
|
// 설정 기본값
|
||||||
const {
|
const {
|
||||||
|
|
@ -78,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 접힌 상태 관리 (각 항목별)
|
// 접힌 상태 관리 (각 항목별)
|
||||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||||
const initialCalcDoneRef = useRef(false);
|
const initialCalcDoneRef = useRef(false);
|
||||||
|
|
||||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||||
const deletedItemIdsRef = useRef<string[]>([]);
|
const deletedItemIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value.length > 0) {
|
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
|
||||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
if (value.length === 0) {
|
||||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
|
||||||
|
if (minItems > 0) {
|
||||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
const emptyItems = Array(minItems)
|
||||||
const updatedValue = value.map(item => {
|
.fill(null)
|
||||||
const updatedItem = { ...item };
|
.map(() => createEmptyItem());
|
||||||
let hasChange = false;
|
setItems(emptyItems);
|
||||||
|
|
||||||
calculatedFields.forEach(calcField => {
|
|
||||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
|
||||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
|
||||||
updatedItem[calcField.name] = calculatedValue;
|
|
||||||
hasChange = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
|
||||||
if (updatedItem.id) {
|
|
||||||
updatedItem._existingRecord = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasChange ? updatedItem : item;
|
|
||||||
});
|
|
||||||
|
|
||||||
setItems(updatedValue);
|
|
||||||
initialCalcDoneRef.current = true;
|
|
||||||
|
|
||||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
|
||||||
const dataWithMeta = config.targetTable
|
|
||||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
|
||||||
: updatedValue;
|
|
||||||
onChange?.(dataWithMeta);
|
|
||||||
} else {
|
} else {
|
||||||
// 🆕 기존 레코드 플래그 추가
|
setItems([]);
|
||||||
const valueWithFlag = value.map(item => ({
|
|
||||||
...item,
|
|
||||||
_existingRecord: !!item.id,
|
|
||||||
}));
|
|
||||||
setItems(valueWithFlag);
|
|
||||||
}
|
}
|
||||||
|
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||||
|
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||||
|
|
||||||
|
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||||
|
const updatedValue = value.map((item) => {
|
||||||
|
const updatedItem = { ...item };
|
||||||
|
let hasChange = false;
|
||||||
|
|
||||||
|
calculatedFields.forEach((calcField) => {
|
||||||
|
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||||
|
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||||
|
updatedItem[calcField.name] = calculatedValue;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||||
|
if (updatedItem.id) {
|
||||||
|
updatedItem._existingRecord = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasChange ? updatedItem : item;
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(updatedValue);
|
||||||
|
initialCalcDoneRef.current = true;
|
||||||
|
|
||||||
|
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||||
|
const dataWithMeta = config.targetTable
|
||||||
|
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||||
|
: updatedValue;
|
||||||
|
onChange?.(dataWithMeta);
|
||||||
|
} else {
|
||||||
|
// 🆕 기존 레코드 플래그 추가
|
||||||
|
const valueWithFlag = value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
_existingRecord: !!item.id,
|
||||||
|
}));
|
||||||
|
setItems(valueWithFlag);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
|
@ -164,14 +185,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (items.length <= minItems) {
|
if (items.length <= minItems) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||||
const removedItem = items[index];
|
const removedItem = items[index];
|
||||||
if (removedItem?.id) {
|
if (removedItem?.id) {
|
||||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItems = items.filter((_, i) => i !== index);
|
const newItems = items.filter((_, i) => i !== index);
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
|
|
||||||
|
|
@ -179,10 +200,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||||
const currentDeletedIds = deletedItemIdsRef.current;
|
const currentDeletedIds = deletedItemIdsRef.current;
|
||||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||||
|
|
||||||
const dataWithMeta = config.targetTable
|
const dataWithMeta = config.targetTable
|
||||||
? newItems.map((item, idx) => ({
|
? newItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
_targetTable: config.targetTable,
|
_targetTable: config.targetTable,
|
||||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||||
|
|
@ -205,16 +226,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
...newItems[itemIndex],
|
...newItems[itemIndex],
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||||
calculatedFields.forEach(calcField => {
|
calculatedFields.forEach((calcField) => {
|
||||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||||
if (calculatedValue !== null) {
|
if (calculatedValue !== null) {
|
||||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||||
itemIndex,
|
itemIndex,
|
||||||
|
|
@ -227,8 +248,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 🆕 삭제된 항목 ID 목록도 유지
|
// 🆕 삭제된 항목 ID 목록도 유지
|
||||||
const currentDeletedIds = deletedItemIdsRef.current;
|
const currentDeletedIds = deletedItemIdsRef.current;
|
||||||
const dataWithMeta = config.targetTable
|
const dataWithMeta = config.targetTable
|
||||||
? newItems.map((item, idx) => ({
|
? newItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
_targetTable: config.targetTable,
|
_targetTable: config.targetTable,
|
||||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||||
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||||
|
|
@ -288,14 +309,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
*/
|
*/
|
||||||
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
||||||
if (!formula || !formula.field1) return null;
|
if (!formula || !formula.field1) return null;
|
||||||
|
|
||||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||||
const value2 = formula.field2
|
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
|
||||||
? (parseFloat(item[formula.field2]) || 0)
|
|
||||||
: (formula.constantValue ?? 0);
|
|
||||||
|
|
||||||
let result: number;
|
let result: number;
|
||||||
|
|
||||||
switch (formula.operator) {
|
switch (formula.operator) {
|
||||||
case "+":
|
case "+":
|
||||||
result = value1 + value2;
|
result = value1 + value2;
|
||||||
|
|
@ -331,7 +350,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
default:
|
default:
|
||||||
result = value1;
|
result = value1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -341,42 +360,44 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
* @param format 포맷 설정
|
* @param format 포맷 설정
|
||||||
* @returns 포맷된 문자열
|
* @returns 포맷된 문자열
|
||||||
*/
|
*/
|
||||||
const formatNumber = (
|
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
|
||||||
value: number | null,
|
|
||||||
format?: RepeaterFieldDefinition["numberFormat"]
|
|
||||||
): string => {
|
|
||||||
if (value === null || isNaN(value)) return "-";
|
if (value === null || isNaN(value)) return "-";
|
||||||
|
|
||||||
let formattedValue = value;
|
let formattedValue = value;
|
||||||
|
|
||||||
// 소수점 자릿수 적용
|
// 소수점 자릿수 적용
|
||||||
if (format?.decimalPlaces !== undefined) {
|
if (format?.decimalPlaces !== undefined) {
|
||||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 천 단위 구분자
|
// 천 단위 구분자
|
||||||
let result = format?.useThousandSeparator !== false
|
let result =
|
||||||
? formattedValue.toLocaleString("ko-KR", {
|
format?.useThousandSeparator !== false
|
||||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
? formattedValue.toLocaleString("ko-KR", {
|
||||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||||
})
|
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||||
: formattedValue.toString();
|
})
|
||||||
|
: formattedValue.toString();
|
||||||
|
|
||||||
// 접두사/접미사 추가
|
// 접두사/접미사 추가
|
||||||
if (format?.prefix) result = format.prefix + result;
|
if (format?.prefix) result = format.prefix + result;
|
||||||
if (format?.suffix) result = result + format.suffix;
|
if (format?.suffix) result = result + format.suffix;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 개별 필드 렌더링
|
// 개별 필드 렌더링
|
||||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||||
const isReadonly = disabled || readonly || field.readonly;
|
const isReadonly = disabled || readonly || field.readonly;
|
||||||
|
|
||||||
|
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||||
|
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||||
|
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
value: value || "",
|
value: value || "",
|
||||||
disabled: isReadonly,
|
disabled: isReadonly,
|
||||||
placeholder: field.placeholder,
|
placeholder: defaultPlaceholder,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -385,25 +406,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const item = items[itemIndex];
|
const item = items[itemIndex];
|
||||||
const calculatedValue = calculateValue(field.formula, item);
|
const calculatedValue = calculateValue(field.formula, item);
|
||||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||||
|
|
||||||
return (
|
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
|
||||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
|
||||||
{formattedValue}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||||
if (field.type === "category") {
|
if (field.type === "category") {
|
||||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||||
|
|
||||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||||
const mapping = categoryMappings[field.name];
|
const mapping = categoryMappings[field.name];
|
||||||
const valueStr = String(value); // 값을 문자열로 변환
|
const valueStr = String(value); // 값을 문자열로 변환
|
||||||
const categoryData = mapping?.[valueStr];
|
const categoryData = mapping?.[valueStr];
|
||||||
const displayLabel = categoryData?.label || valueStr;
|
const displayLabel = categoryData?.label || valueStr;
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||||
|
|
||||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||||
fieldName: field.name,
|
fieldName: field.name,
|
||||||
value: valueStr,
|
value: valueStr,
|
||||||
|
|
@ -412,12 +429,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
displayLabel,
|
displayLabel,
|
||||||
displayColor,
|
displayColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 색상이 "none"이면 일반 텍스트로 표시
|
// 색상이 "none"이면 일반 텍스트로 표시
|
||||||
if (displayColor === "none") {
|
if (displayColor === "none") {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -436,10 +453,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (field.displayMode === "readonly") {
|
if (field.displayMode === "readonly") {
|
||||||
// select 타입인 경우 옵션에서 라벨 찾기
|
// select 타입인 경우 옵션에서 라벨 찾기
|
||||||
if (field.type === "select" && value && field.options) {
|
if (field.type === "select" && value && field.options) {
|
||||||
const option = field.options.find(opt => opt.value === value);
|
const option = field.options.find((opt) => opt.value === value);
|
||||||
return <span className="text-sm">{option?.label || value}</span>;
|
return <span className="text-sm">{option?.label || value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||||
const mapping = categoryMappings[field.name];
|
const mapping = categoryMappings[field.name];
|
||||||
if (mapping && value) {
|
if (mapping && value) {
|
||||||
|
|
@ -461,16 +478,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 색상이 없으면 텍스트로 표시
|
// 색상이 없으면 텍스트로 표시
|
||||||
return <span className="text-sm text-foreground">{categoryData.label}</span>;
|
return <span className="text-foreground text-sm">{categoryData.label}</span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 텍스트
|
// 일반 텍스트
|
||||||
return (
|
return <span className="text-foreground text-sm">{value || "-"}</span>;
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{value || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
|
|
@ -500,35 +513,46 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="resize-none min-w-[100px]"
|
className="min-w-[100px] resize-none"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "date":
|
case "date": {
|
||||||
|
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환
|
||||||
|
let dateValue = value || "";
|
||||||
|
if (dateValue && typeof dateValue === "string") {
|
||||||
|
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출
|
||||||
|
if (dateValue.includes("T")) {
|
||||||
|
dateValue = dateValue.split("T")[0];
|
||||||
|
}
|
||||||
|
// 유효한 날짜인지 확인
|
||||||
|
const parsedDate = new Date(dateValue);
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
dateValue = ""; // 유효하지 않은 날짜면 빈 값
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
value={dateValue}
|
||||||
type="date"
|
type="date"
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)}
|
||||||
className="min-w-[120px]"
|
className="min-w-[120px]"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||||
const numValue = parseFloat(value) || 0;
|
const numValue = parseFloat(value) || 0;
|
||||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||||
|
|
||||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||||
if (isReadonly) {
|
if (isReadonly) {
|
||||||
return (
|
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
|
||||||
<span className="text-sm min-w-[80px] inline-block">
|
|
||||||
{formattedDisplay}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-[80px]">
|
<div className="relative min-w-[80px]">
|
||||||
|
|
@ -540,15 +564,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
max={field.validation?.max}
|
max={field.validation?.max}
|
||||||
className="pr-1"
|
className="pr-1"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
|
||||||
{formattedDisplay}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
|
@ -597,31 +617,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||||
const categoryFields = fields.filter(f => f.type === "category");
|
const categoryFields = fields.filter((f) => f.type === "category");
|
||||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
|
||||||
|
|
||||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||||
|
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||||
|
|
||||||
// 1. 카테고리 타입 필드 매핑 로드
|
// 1. 카테고리 타입 필드 매핑 로드
|
||||||
for (const field of categoryFields) {
|
for (const field of categoryFields) {
|
||||||
const columnName = field.name;
|
const columnName = field.name;
|
||||||
|
|
||||||
if (categoryMappings[columnName]) continue;
|
if (categoryMappings[columnName]) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tableName = config.targetTable;
|
const tableName = config.targetTable;
|
||||||
if (!tableName) continue;
|
if (!tableName) continue;
|
||||||
|
|
||||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color: string }> = {};
|
const mapping: Record<string, { label: string; color: string }> = {};
|
||||||
|
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
const key = String(item.valueCode);
|
const key = String(item.valueCode);
|
||||||
mapping[key] = {
|
mapping[key] = {
|
||||||
|
|
@ -629,10 +649,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
color: item.color || "#64748b",
|
color: item.color || "#64748b",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||||
|
|
||||||
setCategoryMappings(prev => ({
|
setCategoryMappings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[columnName]: mapping,
|
[columnName]: mapping,
|
||||||
}));
|
}));
|
||||||
|
|
@ -641,29 +661,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||||
// material, division 등 조인된 테이블의 카테고리 필드
|
// material, division 등 조인된 테이블의 카테고리 필드
|
||||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
const joinedTableFields = ["material", "division", "status", "currency_code"];
|
||||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
|
||||||
|
|
||||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||||
// item_info 테이블에서 카테고리 매핑 로드
|
// item_info 테이블에서 카테고리 매핑 로드
|
||||||
const joinedTableName = 'item_info';
|
const joinedTableName = "item_info";
|
||||||
|
|
||||||
for (const field of fieldsToLoadFromJoinedTable) {
|
for (const field of fieldsToLoadFromJoinedTable) {
|
||||||
const columnName = field.name;
|
const columnName = field.name;
|
||||||
|
|
||||||
if (categoryMappings[columnName]) continue;
|
if (categoryMappings[columnName]) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color: string }> = {};
|
const mapping: Record<string, { label: string; color: string }> = {};
|
||||||
|
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
const key = String(item.valueCode);
|
const key = String(item.valueCode);
|
||||||
mapping[key] = {
|
mapping[key] = {
|
||||||
|
|
@ -671,10 +691,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
color: item.color || "#64748b",
|
color: item.color || "#64748b",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||||
|
|
||||||
setCategoryMappings(prev => ({
|
setCategoryMappings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[columnName]: mapping,
|
[columnName]: mapping,
|
||||||
}));
|
}));
|
||||||
|
|
@ -694,9 +714,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 text-center">
|
<div className="border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<p className="text-sm font-medium text-destructive">필드가 정의되지 않았습니다</p>
|
<p className="text-destructive text-sm font-medium">필드가 정의되지 않았습니다</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">속성 패널에서 필드를 추가하세요.</p>
|
<p className="text-muted-foreground mt-2 text-xs">속성 패널에서 필드를 추가하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -706,8 +726,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
|
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
|
<p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
|
||||||
{!readonly && !disabled && items.length < maxItems && (
|
{!readonly && !disabled && items.length < maxItems && (
|
||||||
<Button type="button" onClick={handleAddItem} size="sm">
|
<Button type="button" onClick={handleAddItem} size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -740,7 +760,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
||||||
|
|
@ -751,7 +771,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<TableRow
|
<TableRow
|
||||||
key={itemIndex}
|
key={itemIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background transition-colors hover:bg-muted/50",
|
"bg-background hover:bg-muted/50 transition-colors",
|
||||||
draggedIndex === itemIndex && "opacity-50",
|
draggedIndex === itemIndex && "opacity-50",
|
||||||
)}
|
)}
|
||||||
draggable={allowReorder && !readonly && !disabled}
|
draggable={allowReorder && !readonly && !disabled}
|
||||||
|
|
@ -762,15 +782,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
>
|
>
|
||||||
{/* 인덱스 번호 */}
|
{/* 인덱스 번호 */}
|
||||||
{showIndex && (
|
{showIndex && (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||||
{itemIndex + 1}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{allowReorder && !readonly && !disabled && (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -789,7 +807,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
title="항목 제거"
|
title="항목 제거"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -829,12 +847,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{allowReorder && !readonly && !disabled && (
|
||||||
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-muted-foreground" />
|
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0 cursor-move" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 인덱스 번호 */}
|
{/* 인덱스 번호 */}
|
||||||
{showIndex && (
|
{showIndex && (
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">항목 {itemIndex + 1}</CardTitle>
|
<CardTitle className="text-foreground text-sm font-semibold">항목 {itemIndex + 1}</CardTitle>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -859,7 +877,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
title="항목 제거"
|
title="항목 제거"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -873,9 +891,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<div className={getFieldsLayoutClass()}>
|
<div className={getFieldsLayoutClass()}>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-foreground text-sm font-medium">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{renderField(field, itemIndex, item[field.name])}
|
{renderField(field, itemIndex, item[field.name])}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -906,7 +924,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제한 안내 */}
|
{/* 제한 안내 */}
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>현재: {items.length}개 항목</span>
|
<span>현재: {items.length}개 항목</span>
|
||||||
<span>
|
<span>
|
||||||
(최소: {minItems}, 최대: {maxItems})
|
(최소: {minItems}, 최대: {maxItems})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
|
import {
|
||||||
|
RepeaterFieldGroupConfig,
|
||||||
|
RepeaterFieldDefinition,
|
||||||
|
RepeaterFieldType,
|
||||||
|
CalculationOperator,
|
||||||
|
CalculationFormula,
|
||||||
|
} from "@/types/repeater";
|
||||||
import { ColumnInfo } from "@/types/screen";
|
import { ColumnInfo } from "@/types/screen";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -34,10 +40,10 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
||||||
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
||||||
|
|
||||||
// 설정 입력 필드의 로컬 상태
|
// 설정 입력 필드의 로컬 상태
|
||||||
const [localConfigInputs, setLocalConfigInputs] = useState({
|
const [localConfigInputs, setLocalConfigInputs] = useState({
|
||||||
addButtonText: config.addButtonText || "",
|
addButtonText: config.addButtonText || "",
|
||||||
|
|
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||||
setLocalInputs(prev => ({
|
setLocalInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[index]: {
|
[index]: {
|
||||||
...prev[index],
|
...prev[index],
|
||||||
[field]: value
|
[field]: value,
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
newFields[index] = {
|
newFields[index] = {
|
||||||
...newFields[index],
|
...newFields[index],
|
||||||
label: localInput.label,
|
label: localInput.label,
|
||||||
placeholder: localInput.placeholder
|
placeholder: localInput.placeholder,
|
||||||
};
|
};
|
||||||
handleFieldsChange(newFields);
|
handleFieldsChange(newFields);
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 FK 컬럼 설정 (분할 패널용) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">FK 연결 컬럼 (분할 패널용)</Label>
|
||||||
|
<Select
|
||||||
|
value={(config as any).fkColumn || "__none__"}
|
||||||
|
onValueChange={(value) => handleChange("fkColumn" as any, value === "__none__" ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="FK 컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">사용 안함 (그룹화 컬럼 사용)</SelectItem>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
분할 패널에서 좌측 테이블 선택 시 이 컬럼을 기준으로 데이터를 필터링합니다.
|
||||||
|
<br />
|
||||||
|
예: serial_no를 선택하면 좌측에서 선택한 장비의 serial_no에 해당하는 데이터만 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 필드 정의 */}
|
{/* 필드 정의 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||||
|
|
@ -263,8 +295,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||||
const col = column as any;
|
const col = column as any;
|
||||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
const fieldType =
|
||||||
|
col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||||
|
|
||||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
input_type: col.input_type,
|
input_type: col.input_type,
|
||||||
|
|
@ -273,19 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
widgetType: col.widgetType,
|
widgetType: col.widgetType,
|
||||||
finalType: fieldType,
|
finalType: fieldType,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
name: column.columnName,
|
name: column.columnName,
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
type: fieldType as RepeaterFieldType,
|
type: fieldType as RepeaterFieldType,
|
||||||
});
|
});
|
||||||
// 로컬 입력 상태도 업데이트
|
// 로컬 입력 상태도 업데이트
|
||||||
setLocalInputs(prev => ({
|
setLocalInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[index]: {
|
[index]: {
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
placeholder: prev[index]?.placeholder || ""
|
placeholder: prev[index]?.placeholder || "",
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||||
}}
|
}}
|
||||||
|
|
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-xs">라벨</Label>
|
<Label className="text-xs">라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
||||||
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
|
onChange={(e) => updateFieldLocal(index, "label", e.target.value)}
|
||||||
onBlur={() => handleFieldBlur(index)}
|
onBlur={() => handleFieldBlur(index)}
|
||||||
placeholder="필드 라벨"
|
placeholder="필드 라벨"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
|
|
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Placeholder</Label>
|
<Label className="text-xs">Placeholder</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
value={
|
||||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
localInputs[index]?.placeholder !== undefined
|
||||||
|
? localInputs[index].placeholder
|
||||||
|
: field.placeholder || ""
|
||||||
|
}
|
||||||
|
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
|
||||||
onBlur={() => handleFieldBlur(index)}
|
onBlur={() => handleFieldBlur(index)}
|
||||||
placeholder="입력 안내"
|
placeholder="입력 안내"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
|
|
@ -374,15 +411,17 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Calculator className="h-4 w-4 text-blue-600" />
|
<Calculator className="h-4 w-4 text-blue-600" />
|
||||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 1 선택 */}
|
{/* 필드 1 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.field1 || ""}
|
value={field.formula?.field1 || ""}
|
||||||
onValueChange={(value) => updateField(index, {
|
onValueChange={(value) =>
|
||||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: { ...field.formula, field1: value } as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
|
@ -398,54 +437,75 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 연산자 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.operator || "+"}
|
value={field.formula?.operator || "+"}
|
||||||
onValueChange={(value) => updateField(index, {
|
onValueChange={(value) =>
|
||||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[9999]">
|
<SelectContent className="z-[9999]">
|
||||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
<SelectItem value="+" className="text-xs">
|
||||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
+ 더하기
|
||||||
<SelectItem value="*" className="text-xs">× 곱하기</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="/" className="text-xs">÷ 나누기</SelectItem>
|
<SelectItem value="-" className="text-xs">
|
||||||
<SelectItem value="%" className="text-xs">% 나머지</SelectItem>
|
- 빼기
|
||||||
<SelectItem value="round" className="text-xs">반올림</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="floor" className="text-xs">내림</SelectItem>
|
<SelectItem value="*" className="text-xs">
|
||||||
<SelectItem value="ceil" className="text-xs">올림</SelectItem>
|
× 곱하기
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="/" className="text-xs">
|
||||||
|
÷ 나누기
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="%" className="text-xs">
|
||||||
|
% 나머지
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="round" className="text-xs">
|
||||||
|
반올림
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="floor" className="text-xs">
|
||||||
|
내림
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ceil" className="text-xs">
|
||||||
|
올림
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 두 번째 필드 또는 상수값 */}
|
{/* 두 번째 필드 또는 상수값 */}
|
||||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
|
value={
|
||||||
|
field.formula?.field2 ||
|
||||||
|
(field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")
|
||||||
|
}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value.startsWith("__const__")) {
|
if (value.startsWith("__const__")) {
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
formula: {
|
formula: {
|
||||||
...field.formula,
|
...field.formula,
|
||||||
field2: undefined,
|
field2: undefined,
|
||||||
constantValue: 0
|
constantValue: 0,
|
||||||
} as CalculationFormula
|
} as CalculationFormula,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
formula: {
|
formula: {
|
||||||
...field.formula,
|
...field.formula,
|
||||||
field2: value,
|
field2: value,
|
||||||
constantValue: undefined
|
constantValue: undefined,
|
||||||
} as CalculationFormula
|
} as CalculationFormula,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -475,14 +535,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
value={field.formula?.decimalPlaces ?? 0}
|
value={field.formula?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: {
|
||||||
|
...field.formula,
|
||||||
|
decimalPlaces: parseInt(e.target.value) || 0,
|
||||||
|
} as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상수값 입력 필드 */}
|
{/* 상수값 입력 필드 */}
|
||||||
{field.formula?.constantValue !== undefined && (
|
{field.formula?.constantValue !== undefined && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -490,15 +555,20 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={field.formula.constantValue}
|
value={field.formula.constantValue}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: {
|
||||||
|
...field.formula,
|
||||||
|
constantValue: parseFloat(e.target.value) || 0,
|
||||||
|
} as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="숫자 입력"
|
placeholder="숫자 입력"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 숫자 포맷 설정 */}
|
{/* 숫자 포맷 설정 */}
|
||||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||||
|
|
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`thousand-sep-${index}`}
|
id={`thousand-sep-${index}`}
|
||||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||||
onCheckedChange={(checked) => updateField(index, {
|
onCheckedChange={(checked) =>
|
||||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||||
천 단위 구분자
|
천 단위 구분자
|
||||||
|
|
@ -519,9 +591,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-[10px]">소수점:</Label>
|
<Label className="text-[10px]">소수점:</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
|
|
@ -532,31 +606,34 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.prefix || ""}
|
value={field.numberFormat?.prefix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접두사 (₩)"
|
placeholder="접두사 (₩)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.suffix || ""}
|
value={field.numberFormat?.suffix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접미사 (원)"
|
placeholder="접미사 (원)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 계산식 미리보기 */}
|
{/* 계산식 미리보기 */}
|
||||||
<div className="rounded bg-white p-2 text-xs">
|
<div className="rounded bg-white p-2 text-xs">
|
||||||
<span className="text-gray-500">계산식: </span>
|
<span className="text-gray-500">계산식: </span>
|
||||||
<code className="font-mono text-blue-700">
|
<code className="font-mono text-blue-700">
|
||||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
|
||||||
field.formula?.field2 ||
|
{field.formula?.field2 ||
|
||||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
|
||||||
}
|
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`number-thousand-sep-${index}`}
|
id={`number-thousand-sep-${index}`}
|
||||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||||
onCheckedChange={(checked) => updateField(index, {
|
onCheckedChange={(checked) =>
|
||||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||||
천 단위 구분자
|
천 단위 구분자
|
||||||
|
|
@ -583,9 +662,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-[10px]">소수점:</Label>
|
<Label className="text-[10px]">소수점:</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
|
|
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.prefix || ""}
|
value={field.numberFormat?.prefix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접두사 (₩)"
|
placeholder="접두사 (₩)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.suffix || ""}
|
value={field.numberFormat?.suffix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접미사 (원)"
|
placeholder="접미사 (원)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||||
import { logger } from "@/lib/utils/logger";
|
import { logger } from "@/lib/utils/logger";
|
||||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
|
|
@ -14,17 +14,21 @@ interface ScreenContextValue {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
formData: Record<string, any>;
|
||||||
|
updateFormData: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
// 컴포넌트 등록
|
// 컴포넌트 등록
|
||||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||||
unregisterDataProvider: (componentId: string) => void;
|
unregisterDataProvider: (componentId: string) => void;
|
||||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||||
unregisterDataReceiver: (componentId: string) => void;
|
unregisterDataReceiver: (componentId: string) => void;
|
||||||
|
|
||||||
// 컴포넌트 조회
|
// 컴포넌트 조회
|
||||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||||
|
|
||||||
// 모든 컴포넌트 조회
|
// 모든 컴포넌트 조회
|
||||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||||
|
|
@ -42,10 +46,31 @@ interface ScreenContextProviderProps {
|
||||||
/**
|
/**
|
||||||
* 화면 컨텍스트 프로바이더
|
* 화면 컨텍스트 프로바이더
|
||||||
*/
|
*/
|
||||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
export function ScreenContextProvider({
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
splitPanelPosition,
|
||||||
|
children,
|
||||||
|
}: ScreenContextProviderProps) {
|
||||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 업데이트 함수
|
||||||
|
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const updated = { ...prev, [fieldName]: value };
|
||||||
|
logger.debug("ScreenContext formData 업데이트", {
|
||||||
|
fieldName,
|
||||||
|
valueType: typeof value,
|
||||||
|
isArray: Array.isArray(value),
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
||||||
dataProvidersRef.current.set(componentId, provider);
|
dataProvidersRef.current.set(componentId, provider);
|
||||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||||
|
|
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
const value = React.useMemo<ScreenContextValue>(
|
||||||
screenId,
|
() => ({
|
||||||
tableName,
|
screenId,
|
||||||
splitPanelPosition,
|
tableName,
|
||||||
registerDataProvider,
|
splitPanelPosition,
|
||||||
unregisterDataProvider,
|
formData,
|
||||||
registerDataReceiver,
|
updateFormData,
|
||||||
unregisterDataReceiver,
|
registerDataProvider,
|
||||||
getDataProvider,
|
unregisterDataProvider,
|
||||||
getDataReceiver,
|
registerDataReceiver,
|
||||||
getAllDataProviders,
|
unregisterDataReceiver,
|
||||||
getAllDataReceivers,
|
getDataProvider,
|
||||||
}), [
|
getDataReceiver,
|
||||||
screenId,
|
getAllDataProviders,
|
||||||
tableName,
|
getAllDataReceivers,
|
||||||
splitPanelPosition,
|
}),
|
||||||
registerDataProvider,
|
[
|
||||||
unregisterDataProvider,
|
screenId,
|
||||||
registerDataReceiver,
|
tableName,
|
||||||
unregisterDataReceiver,
|
splitPanelPosition,
|
||||||
getDataProvider,
|
formData,
|
||||||
getDataReceiver,
|
updateFormData,
|
||||||
getAllDataProviders,
|
registerDataProvider,
|
||||||
getAllDataReceivers,
|
unregisterDataProvider,
|
||||||
]);
|
registerDataReceiver,
|
||||||
|
unregisterDataReceiver,
|
||||||
|
getDataProvider,
|
||||||
|
getDataReceiver,
|
||||||
|
getAllDataProviders,
|
||||||
|
getAllDataReceivers,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
@ -130,4 +162,3 @@ export function useScreenContext() {
|
||||||
export function useScreenContextOptional() {
|
export function useScreenContextOptional() {
|
||||||
return useContext(ScreenContext);
|
return useContext(ScreenContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,3 +192,4 @@ export function applyAutoFillToFormData(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export const uploadFiles = async (params: {
|
||||||
files: FileList | File[];
|
files: FileList | File[];
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
fieldName?: string;
|
fieldName?: string;
|
||||||
recordId?: string;
|
recordId?: string | number;
|
||||||
docType?: string;
|
docType?: string;
|
||||||
docTypeName?: string;
|
docTypeName?: string;
|
||||||
targetObjid?: string;
|
targetObjid?: string;
|
||||||
|
|
@ -43,6 +43,7 @@ export const uploadFiles = async (params: {
|
||||||
columnName?: string;
|
columnName?: string;
|
||||||
isVirtualFileColumn?: boolean;
|
isVirtualFileColumn?: boolean;
|
||||||
companyCode?: string; // 🔒 멀티테넌시: 회사 코드
|
companyCode?: string; // 🔒 멀티테넌시: 회사 코드
|
||||||
|
isRecordMode?: boolean; // 🆕 레코드 모드 플래그
|
||||||
}): Promise<FileUploadResponse> => {
|
}): Promise<FileUploadResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ export const uploadFiles = async (params: {
|
||||||
// 추가 파라미터들 추가
|
// 추가 파라미터들 추가
|
||||||
if (params.tableName) formData.append("tableName", params.tableName);
|
if (params.tableName) formData.append("tableName", params.tableName);
|
||||||
if (params.fieldName) formData.append("fieldName", params.fieldName);
|
if (params.fieldName) formData.append("fieldName", params.fieldName);
|
||||||
if (params.recordId) formData.append("recordId", params.recordId);
|
if (params.recordId) formData.append("recordId", String(params.recordId));
|
||||||
if (params.docType) formData.append("docType", params.docType);
|
if (params.docType) formData.append("docType", params.docType);
|
||||||
if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
|
if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
|
||||||
if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
|
if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
|
||||||
|
|
@ -66,6 +67,8 @@ export const uploadFiles = async (params: {
|
||||||
if (params.columnName) formData.append("columnName", params.columnName);
|
if (params.columnName) formData.append("columnName", params.columnName);
|
||||||
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
|
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
|
||||||
if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시
|
if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시
|
||||||
|
// 🆕 레코드 모드 플래그 추가 (백엔드에서 attachments 컬럼 자동 업데이트용)
|
||||||
|
if (params.isRecordMode !== undefined) formData.append("isRecordMode", params.isRecordMode.toString());
|
||||||
|
|
||||||
const response = await apiClient.post("/files/upload", formData, {
|
const response = await apiClient.post("/files/upload", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,9 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
|
||||||
isInModal: _isInModal,
|
isInModal: _isInModal,
|
||||||
isPreview: _isPreview,
|
isPreview: _isPreview,
|
||||||
originalData: _originalData,
|
originalData: _originalData,
|
||||||
|
_originalData: __originalData,
|
||||||
|
_initialData: __initialData,
|
||||||
|
_groupedData: __groupedData,
|
||||||
allComponents: _allComponents,
|
allComponents: _allComponents,
|
||||||
selectedRows: _selectedRows,
|
selectedRows: _selectedRows,
|
||||||
selectedRowsData: _selectedRowsData,
|
selectedRowsData: _selectedRowsData,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
|
|
||||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
|
|
@ -57,10 +57,10 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
|
|
||||||
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
||||||
allComponents?: any[];
|
allComponents?: any[];
|
||||||
|
|
||||||
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
|
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
|
||||||
groupedData?: Record<string, any>[];
|
groupedData?: Record<string, any>[];
|
||||||
}
|
}
|
||||||
|
|
@ -109,11 +109,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
||||||
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
|
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
|
||||||
const effectiveTableName = tableName || screenContext?.tableName;
|
const effectiveTableName = tableName || screenContext?.tableName;
|
||||||
const effectiveScreenId = screenId || screenContext?.screenId;
|
const effectiveScreenId = screenId || screenContext?.screenId;
|
||||||
|
|
||||||
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
||||||
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
||||||
const finalOnSave = onSave || propsOnSave;
|
const finalOnSave = onSave || propsOnSave;
|
||||||
|
|
@ -169,10 +169,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
if (!shouldFetchStatus) return;
|
if (!shouldFetchStatus) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
@ -180,12 +180,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
search: { [statusKeyField]: userId },
|
search: { [statusKeyField]: userId },
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||||
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
||||||
|
|
||||||
if (response.data?.success && firstRow) {
|
if (response.data?.success && firstRow) {
|
||||||
const newStatus = firstRow[statusFieldName];
|
const newStatus = firstRow[statusFieldName];
|
||||||
if (newStatus !== vehicleStatus) {
|
if (newStatus !== vehicleStatus) {
|
||||||
|
|
@ -206,10 +206,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 즉시 실행
|
// 즉시 실행
|
||||||
setStatusLoading(true);
|
setStatusLoading(true);
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
|
||||||
// 2초마다 갱신
|
// 2초마다 갱신
|
||||||
const interval = setInterval(fetchStatus, 2000);
|
const interval = setInterval(fetchStatus, 2000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|
@ -219,22 +219,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 버튼 비활성화 조건 계산
|
// 버튼 비활성화 조건 계산
|
||||||
const isOperationButtonDisabled = useMemo(() => {
|
const isOperationButtonDisabled = useMemo(() => {
|
||||||
const actionConfig = component.componentConfig?.action;
|
const actionConfig = component.componentConfig?.action;
|
||||||
|
|
||||||
if (actionConfig?.type !== "operation_control") return false;
|
if (actionConfig?.type !== "operation_control") return false;
|
||||||
|
|
||||||
// 1. 출발지/도착지 필수 체크
|
// 1. 출발지/도착지 필수 체크
|
||||||
if (actionConfig?.requireLocationFields) {
|
if (actionConfig?.requireLocationFields) {
|
||||||
const departureField = actionConfig.trackingDepartureField || "departure";
|
const departureField = actionConfig.trackingDepartureField || "departure";
|
||||||
const destinationField = actionConfig.trackingArrivalField || "destination";
|
const destinationField = actionConfig.trackingArrivalField || "destination";
|
||||||
|
|
||||||
const departure = formData?.[departureField];
|
const departure = formData?.[departureField];
|
||||||
const destination = formData?.[destinationField];
|
const destination = formData?.[destinationField];
|
||||||
|
|
||||||
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||||
// departureField, destinationField, departure, destination,
|
// departureField, destinationField, departure, destination,
|
||||||
// buttonLabel: component.label
|
// buttonLabel: component.label
|
||||||
// });
|
// });
|
||||||
|
|
||||||
if (!departure || departure === "" || !destination || destination === "") {
|
if (!departure || departure === "" || !destination || destination === "") {
|
||||||
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -246,20 +246,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const statusField = actionConfig.statusCheckField || "status";
|
const statusField = actionConfig.statusCheckField || "status";
|
||||||
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
||||||
const currentStatus = vehicleStatus || formData?.[statusField];
|
const currentStatus = vehicleStatus || formData?.[statusField];
|
||||||
|
|
||||||
const conditionType = actionConfig.statusConditionType || "enableOn";
|
const conditionType = actionConfig.statusConditionType || "enableOn";
|
||||||
const conditionValues = (actionConfig.statusConditionValues || "")
|
const conditionValues = (actionConfig.statusConditionValues || "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v: string) => v.trim())
|
.map((v: string) => v.trim())
|
||||||
.filter((v: string) => v);
|
.filter((v: string) => v);
|
||||||
|
|
||||||
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||||
// statusField,
|
// statusField,
|
||||||
// formDataStatus: formData?.[statusField],
|
// formDataStatus: formData?.[statusField],
|
||||||
// apiStatus: vehicleStatus,
|
// apiStatus: vehicleStatus,
|
||||||
// currentStatus,
|
// currentStatus,
|
||||||
// conditionType,
|
// conditionType,
|
||||||
// conditionValues,
|
// conditionValues,
|
||||||
// buttonLabel: component.label,
|
// buttonLabel: component.label,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
|
|
@ -274,7 +274,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conditionValues.length > 0) {
|
if (conditionValues.length > 0) {
|
||||||
if (conditionType === "enableOn") {
|
if (conditionType === "enableOn") {
|
||||||
// 이 상태일 때만 활성화
|
// 이 상태일 때만 활성화
|
||||||
|
|
@ -551,7 +551,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
*/
|
*/
|
||||||
const handleTransferDataAction = async (actionConfig: any) => {
|
const handleTransferDataAction = async (actionConfig: any) => {
|
||||||
const dataTransferConfig = actionConfig.dataTransfer;
|
const dataTransferConfig = actionConfig.dataTransfer;
|
||||||
|
|
||||||
if (!dataTransferConfig) {
|
if (!dataTransferConfig) {
|
||||||
toast.error("데이터 전달 설정이 없습니다.");
|
toast.error("데이터 전달 설정이 없습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -565,15 +565,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
try {
|
try {
|
||||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||||
|
|
||||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||||
if (!sourceProvider) {
|
if (!sourceProvider) {
|
||||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||||
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
||||||
|
|
||||||
const allProviders = screenContext.getAllDataProviders();
|
const allProviders = screenContext.getAllDataProviders();
|
||||||
|
|
||||||
// 테이블 리스트 우선 탐색
|
// 테이블 리스트 우선 탐색
|
||||||
for (const [id, provider] of allProviders) {
|
for (const [id, provider] of allProviders) {
|
||||||
if (provider.componentType === "table-list") {
|
if (provider.componentType === "table-list") {
|
||||||
|
|
@ -582,16 +582,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||||
if (!sourceProvider && allProviders.size > 0) {
|
if (!sourceProvider && allProviders.size > 0) {
|
||||||
const firstEntry = allProviders.entries().next().value;
|
const firstEntry = allProviders.entries().next().value;
|
||||||
if (firstEntry) {
|
if (firstEntry) {
|
||||||
sourceProvider = firstEntry[1];
|
sourceProvider = firstEntry[1];
|
||||||
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
|
console.log(
|
||||||
|
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sourceProvider) {
|
if (!sourceProvider) {
|
||||||
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -599,12 +601,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSourceData = sourceProvider.getSelectedData();
|
const rawSourceData = sourceProvider.getSelectedData();
|
||||||
|
|
||||||
// 🆕 배열이 아닌 경우 배열로 변환
|
// 🆕 배열이 아닌 경우 배열로 변환
|
||||||
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
|
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
|
||||||
|
|
||||||
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
|
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
|
||||||
|
|
||||||
if (!sourceData || sourceData.length === 0) {
|
if (!sourceData || sourceData.length === 0) {
|
||||||
toast.warning("선택된 데이터가 없습니다.");
|
toast.warning("선택된 데이터가 없습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -612,31 +614,32 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
||||||
let additionalData: Record<string, any> = {};
|
let additionalData: Record<string, any> = {};
|
||||||
|
|
||||||
// 방법 1: additionalSources 설정에서 가져오기
|
// 방법 1: additionalSources 설정에서 가져오기
|
||||||
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
||||||
for (const additionalSource of dataTransferConfig.additionalSources) {
|
for (const additionalSource of dataTransferConfig.additionalSources) {
|
||||||
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
||||||
|
|
||||||
if (additionalProvider) {
|
if (additionalProvider) {
|
||||||
const additionalValues = additionalProvider.getSelectedData();
|
const additionalValues = additionalProvider.getSelectedData();
|
||||||
|
|
||||||
if (additionalValues && additionalValues.length > 0) {
|
if (additionalValues && additionalValues.length > 0) {
|
||||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||||
const firstValue = additionalValues[0];
|
const firstValue = additionalValues[0];
|
||||||
|
|
||||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||||
if (additionalSource.fieldName) {
|
if (additionalSource.fieldName) {
|
||||||
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
additionalData[additionalSource.fieldName] =
|
||||||
|
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||||
} else {
|
} else {
|
||||||
// fieldName이 없으면 전체 객체 병합
|
// fieldName이 없으면 전체 객체 병합
|
||||||
additionalData = { ...additionalData, ...firstValue };
|
additionalData = { ...additionalData, ...firstValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
||||||
sourceId: additionalSource.componentId,
|
sourceId: additionalSource.componentId,
|
||||||
fieldName: additionalSource.fieldName,
|
fieldName: additionalSource.fieldName,
|
||||||
value: additionalData[additionalSource.fieldName || 'all'],
|
value: additionalData[additionalSource.fieldName || "all"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const conditionalValue = formData.__conditionalContainerValue;
|
const conditionalValue = formData.__conditionalContainerValue;
|
||||||
const conditionalLabel = formData.__conditionalContainerLabel;
|
const conditionalLabel = formData.__conditionalContainerLabel;
|
||||||
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
||||||
|
|
||||||
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
||||||
if (controlField) {
|
if (controlField) {
|
||||||
additionalData[controlField] = conditionalValue;
|
additionalData[controlField] = conditionalValue;
|
||||||
|
|
@ -663,7 +666,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
} else {
|
} else {
|
||||||
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
||||||
for (const [key, value] of Object.entries(formData)) {
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
if (value === conditionalValue && !key.startsWith('__')) {
|
if (value === conditionalValue && !key.startsWith("__")) {
|
||||||
additionalData[key] = conditionalValue;
|
additionalData[key] = conditionalValue;
|
||||||
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
||||||
fieldName: key,
|
fieldName: key,
|
||||||
|
|
@ -673,12 +676,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 못 찾았으면 기본 필드명 사용
|
// 못 찾았으면 기본 필드명 사용
|
||||||
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
|
if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) {
|
||||||
additionalData['condition_type'] = conditionalValue;
|
additionalData["condition_type"] = conditionalValue;
|
||||||
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
||||||
fieldName: 'condition_type',
|
fieldName: "condition_type",
|
||||||
value: conditionalValue,
|
value: conditionalValue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -710,7 +713,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||||
const mappedData = sourceData.map((row) => {
|
const mappedData = sourceData.map((row) => {
|
||||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||||
|
|
||||||
// 추가 데이터를 모든 행에 포함
|
// 추가 데이터를 모든 행에 포함
|
||||||
return {
|
return {
|
||||||
...mappedRow,
|
...mappedRow,
|
||||||
|
|
@ -730,7 +733,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
if (dataTransferConfig.targetType === "component") {
|
if (dataTransferConfig.targetType === "component") {
|
||||||
// 같은 화면의 컴포넌트로 전달
|
// 같은 화면의 컴포넌트로 전달
|
||||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
||||||
|
|
||||||
if (!targetReceiver) {
|
if (!targetReceiver) {
|
||||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -742,7 +745,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
mode: dataTransferConfig.mode || "append",
|
mode: dataTransferConfig.mode || "append",
|
||||||
mappingRules: dataTransferConfig.mappingRules || [],
|
mappingRules: dataTransferConfig.mappingRules || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||||
|
|
@ -750,17 +753,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||||
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
||||||
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
const currentPosition =
|
||||||
|
splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||||
|
|
||||||
if (!currentPosition) {
|
if (!currentPosition) {
|
||||||
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📦 분할 패널 데이터 전달:", {
|
console.log("📦 분할 패널 데이터 전달:", {
|
||||||
currentPosition,
|
currentPosition,
|
||||||
splitPanelPositionFromHook: splitPanelPosition,
|
splitPanelPositionFromHook: splitPanelPosition,
|
||||||
|
|
@ -768,14 +772,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
leftScreenId: splitPanelContext.leftScreenId,
|
leftScreenId: splitPanelContext.leftScreenId,
|
||||||
rightScreenId: splitPanelContext.rightScreenId,
|
rightScreenId: splitPanelContext.rightScreenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await splitPanelContext.transferToOtherSide(
|
const result = await splitPanelContext.transferToOtherSide(
|
||||||
currentPosition,
|
currentPosition,
|
||||||
mappedData,
|
mappedData,
|
||||||
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
||||||
dataTransferConfig.mode || "append"
|
dataTransferConfig.mode || "append",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -794,7 +798,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
if (dataTransferConfig.clearAfterTransfer) {
|
if (dataTransferConfig.clearAfterTransfer) {
|
||||||
sourceProvider.clearSelection();
|
sourceProvider.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 데이터 전달 실패:", error);
|
console.error("❌ 데이터 전달 실패:", error);
|
||||||
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
||||||
|
|
@ -828,16 +831,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 2. groupedData (부모창에서 모달로 전달된 데이터)
|
// 2. groupedData (부모창에서 모달로 전달된 데이터)
|
||||||
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
|
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
|
||||||
let effectiveSelectedRowsData = selectedRowsData;
|
let effectiveSelectedRowsData = selectedRowsData;
|
||||||
|
|
||||||
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
|
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
|
||||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) {
|
if (
|
||||||
|
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
|
||||||
|
groupedData &&
|
||||||
|
groupedData.length > 0
|
||||||
|
) {
|
||||||
effectiveSelectedRowsData = groupedData;
|
effectiveSelectedRowsData = groupedData;
|
||||||
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
|
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
|
||||||
count: groupedData.length,
|
count: groupedData.length,
|
||||||
data: groupedData,
|
data: groupedData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||||
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
|
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -845,11 +852,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||||
const modalData = dataRegistry[effectiveTableName];
|
const modalData = dataRegistry[effectiveTableName];
|
||||||
if (modalData && modalData.length > 0) {
|
if (modalData && modalData.length > 0) {
|
||||||
effectiveSelectedRowsData = modalData;
|
// modalDataStore는 {id, originalData, additionalData} 형태로 저장됨
|
||||||
|
// originalData를 추출하여 실제 행 데이터를 가져옴
|
||||||
|
effectiveSelectedRowsData = modalData.map((item: any) => {
|
||||||
|
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
|
||||||
|
return item.originalData || item;
|
||||||
|
});
|
||||||
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
|
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
|
||||||
tableName: effectiveTableName,
|
tableName: effectiveTableName,
|
||||||
count: modalData.length,
|
count: modalData.length,
|
||||||
data: modalData,
|
rawData: modalData,
|
||||||
|
extractedData: effectiveSelectedRowsData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -859,7 +872,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||||
const hasDataToDelete =
|
const hasDataToDelete =
|
||||||
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
|
||||||
|
(flowSelectedData && flowSelectedData.length > 0);
|
||||||
|
|
||||||
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
||||||
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
||||||
|
|
@ -905,8 +919,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합
|
||||||
|
// screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터
|
||||||
|
// props.formData: 부모에서 전달된 폼 데이터
|
||||||
|
const screenContextFormData = screenContext?.formData || {};
|
||||||
|
const propsFormData = formData || {};
|
||||||
|
|
||||||
|
// 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드
|
||||||
|
// (RepeaterFieldGroup 데이터는 screenContext에만 있음)
|
||||||
|
const effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
||||||
|
|
||||||
|
console.log("🔍 [ButtonPrimary] formData 선택:", {
|
||||||
|
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
|
||||||
|
screenContextKeys: Object.keys(screenContextFormData),
|
||||||
|
hasPropsFormData: Object.keys(propsFormData).length > 0,
|
||||||
|
propsFormDataKeys: Object.keys(propsFormData),
|
||||||
|
splitPanelPosition,
|
||||||
|
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||||
|
});
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: formData || {},
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
|
|
@ -996,6 +1029,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링
|
flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링
|
||||||
onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링
|
onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링
|
||||||
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
|
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
|
||||||
|
_originalData: __originalData, // DOM 필터링
|
||||||
|
_initialData: __initialData, // DOM 필터링
|
||||||
|
_groupedData: __groupedData, // DOM 필터링
|
||||||
refreshKey: _refreshKey, // 필터링 추가
|
refreshKey: _refreshKey, // 필터링 추가
|
||||||
isInModal: _isInModal, // 필터링 추가
|
isInModal: _isInModal, // 필터링 추가
|
||||||
mode: _mode, // 필터링 추가
|
mode: _mode, // 필터링 추가
|
||||||
|
|
@ -1073,15 +1109,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
// 🔧 크기에 따른 패딩 조정
|
// 🔧 크기에 따른 패딩 조정
|
||||||
padding:
|
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
lineHeight: "1.25",
|
||||||
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||||
...(component.style ? Object.fromEntries(
|
...(component.style
|
||||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
|
||||||
) : {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
||||||
|
|
@ -1103,7 +1138,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
<button
|
<button
|
||||||
type={componentConfig.actionType || "button"}
|
type={componentConfig.actionType || "button"}
|
||||||
disabled={finalDisabled}
|
disabled={finalDisabled}
|
||||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
||||||
style={buttonElementStyle}
|
style={buttonElementStyle}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
||||||
isInModal: _isInModal,
|
isInModal: _isInModal,
|
||||||
readonly: _readonly,
|
readonly: _readonly,
|
||||||
originalData: _originalData,
|
originalData: _originalData,
|
||||||
|
_originalData: __originalData,
|
||||||
|
_initialData: __initialData,
|
||||||
|
_groupedData: __groupedData,
|
||||||
allComponents: _allComponents,
|
allComponents: _allComponents,
|
||||||
onUpdateLayout: _onUpdateLayout,
|
onUpdateLayout: _onUpdateLayout,
|
||||||
selectedRows: _selectedRows,
|
selectedRows: _selectedRows,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { FileViewerModal } from "./FileViewerModal";
|
import { FileViewerModal } from "./FileViewerModal";
|
||||||
import { FileManagerModal } from "./FileManagerModal";
|
import { FileManagerModal } from "./FileManagerModal";
|
||||||
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
File,
|
File,
|
||||||
|
|
@ -92,6 +93,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🔑 인증 정보 가져오기
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
||||||
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
|
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
@ -102,28 +106,94 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
|
||||||
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
||||||
|
const recordTableName = formData?.tableName || component.tableName;
|
||||||
|
const recordId = formData?.id;
|
||||||
|
// 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용
|
||||||
|
// component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합
|
||||||
|
// 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용
|
||||||
|
const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments');
|
||||||
|
|
||||||
|
// 🔑 레코드 모드용 targetObjid 생성
|
||||||
|
const getRecordTargetObjid = useCallback(() => {
|
||||||
|
if (isRecordMode && recordTableName && recordId) {
|
||||||
|
return `${recordTableName}:${recordId}:${columnName}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [isRecordMode, recordTableName, recordId, columnName]);
|
||||||
|
|
||||||
|
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
|
||||||
|
const getUniqueKey = useCallback(() => {
|
||||||
|
if (isRecordMode && recordTableName && recordId) {
|
||||||
|
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
|
||||||
|
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
|
||||||
|
}
|
||||||
|
// 기본 모드: 컴포넌트 ID만 사용
|
||||||
|
return `fileUpload_${component.id}`;
|
||||||
|
}, [isRecordMode, recordTableName, recordId, component.id]);
|
||||||
|
|
||||||
|
// 🔍 디버깅: 레코드 모드 상태 로깅
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("📎 [FileUploadComponent] 모드 확인:", {
|
||||||
|
isRecordMode,
|
||||||
|
recordTableName,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
targetObjid: getRecordTargetObjid(),
|
||||||
|
uniqueKey: getUniqueKey(),
|
||||||
|
formDataKeys: formData ? Object.keys(formData) : [],
|
||||||
|
// 🔍 추가 디버깅: 어디서 tableName이 오는지 확인
|
||||||
|
"formData.tableName": formData?.tableName,
|
||||||
|
"component.tableName": component.tableName,
|
||||||
|
"component.columnName": component.columnName,
|
||||||
|
"component.id": component.id,
|
||||||
|
});
|
||||||
|
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
|
||||||
|
|
||||||
|
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
||||||
|
const prevRecordIdRef = useRef<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevRecordIdRef.current !== recordId) {
|
||||||
|
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
|
||||||
|
prev: prevRecordIdRef.current,
|
||||||
|
current: recordId,
|
||||||
|
isRecordMode,
|
||||||
|
});
|
||||||
|
prevRecordIdRef.current = recordId;
|
||||||
|
|
||||||
|
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
|
||||||
|
if (isRecordMode) {
|
||||||
|
setUploadedFiles([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [recordId, isRecordMode]);
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!component?.id) return;
|
if (!component?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
// 🔑 레코드별 고유 키 사용
|
||||||
|
const backupKey = getUniqueKey();
|
||||||
const backupFiles = localStorage.getItem(backupKey);
|
const backupFiles = localStorage.getItem(backupKey);
|
||||||
if (backupFiles) {
|
if (backupFiles) {
|
||||||
const parsedFiles = JSON.parse(backupFiles);
|
const parsedFiles = JSON.parse(backupFiles);
|
||||||
if (parsedFiles.length > 0) {
|
if (parsedFiles.length > 0) {
|
||||||
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
|
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
|
||||||
|
uniqueKey: backupKey,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
|
recordId: recordId,
|
||||||
restoredFiles: parsedFiles.length,
|
restoredFiles: parsedFiles.length,
|
||||||
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
||||||
});
|
});
|
||||||
setUploadedFiles(parsedFiles);
|
setUploadedFiles(parsedFiles);
|
||||||
|
|
||||||
// 전역 상태에도 복원
|
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
(window as any).globalFileState = {
|
(window as any).globalFileState = {
|
||||||
...(window as any).globalFileState,
|
...(window as any).globalFileState,
|
||||||
[component.id]: parsedFiles,
|
[backupKey]: parsedFiles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +201,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
||||||
}
|
}
|
||||||
}, [component.id]); // component.id가 변경될 때만 실행
|
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
|
||||||
|
|
||||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -152,12 +222,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const newFiles = event.detail.files || [];
|
const newFiles = event.detail.files || [];
|
||||||
setUploadedFiles(newFiles);
|
setUploadedFiles(newFiles);
|
||||||
|
|
||||||
// localStorage 백업 업데이트
|
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
const backupKey = getUniqueKey();
|
||||||
localStorage.setItem(backupKey, JSON.stringify(newFiles));
|
localStorage.setItem(backupKey, JSON.stringify(newFiles));
|
||||||
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
|
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
|
||||||
|
uniqueKey: backupKey,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
|
recordId: recordId,
|
||||||
fileCount: newFiles.length,
|
fileCount: newFiles.length,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -201,6 +273,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
if (!component?.id) return false;
|
if (!component?.id) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
||||||
|
if (isRecordMode && recordTableName && recordId) {
|
||||||
|
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
||||||
|
tableName: recordTableName,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
targetObjid: getRecordTargetObjid(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 1. formData에서 screenId 가져오기
|
// 1. formData에서 screenId 가져오기
|
||||||
let screenId = formData?.screenId;
|
let screenId = formData?.screenId;
|
||||||
|
|
||||||
|
|
@ -232,11 +314,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const params = {
|
const params = {
|
||||||
screenId,
|
screenId,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
tableName: formData?.tableName || component.tableName,
|
tableName: recordTableName || formData?.tableName || component.tableName,
|
||||||
recordId: formData?.id,
|
recordId: recordId || formData?.id,
|
||||||
columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용
|
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
|
||||||
|
|
||||||
const response = await getComponentFiles(params);
|
const response = await getComponentFiles(params);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
@ -255,11 +339,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합
|
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
|
||||||
let finalFiles = formattedFiles;
|
let finalFiles = formattedFiles;
|
||||||
|
const uniqueKey = getUniqueKey();
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
const backupFiles = localStorage.getItem(uniqueKey);
|
||||||
const backupFiles = localStorage.getItem(backupKey);
|
|
||||||
if (backupFiles) {
|
if (backupFiles) {
|
||||||
const parsedBackupFiles = JSON.parse(backupFiles);
|
const parsedBackupFiles = JSON.parse(backupFiles);
|
||||||
|
|
||||||
|
|
@ -268,7 +352,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
|
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
|
||||||
|
|
||||||
finalFiles = [...formattedFiles, ...additionalFiles];
|
finalFiles = [...formattedFiles, ...additionalFiles];
|
||||||
|
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
|
||||||
|
uniqueKey,
|
||||||
|
serverFiles: formattedFiles.length,
|
||||||
|
localFiles: parsedBackupFiles.length,
|
||||||
|
finalFiles: finalFiles.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("파일 병합 중 오류:", e);
|
console.warn("파일 병합 중 오류:", e);
|
||||||
|
|
@ -276,11 +365,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
setUploadedFiles(finalFiles);
|
setUploadedFiles(finalFiles);
|
||||||
|
|
||||||
// 전역 상태에도 저장
|
// 전역 상태에도 저장 (레코드별 고유 키 사용)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
(window as any).globalFileState = {
|
(window as any).globalFileState = {
|
||||||
...(window as any).globalFileState,
|
...(window as any).globalFileState,
|
||||||
[component.id]: finalFiles,
|
[uniqueKey]: finalFiles,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
|
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
|
||||||
|
|
@ -288,12 +377,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
uploadPage: window.location.pathname,
|
uploadPage: window.location.pathname,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
screenId: formData?.screenId,
|
screenId: formData?.screenId,
|
||||||
|
recordId: recordId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// localStorage 백업도 병합된 파일로 업데이트
|
// localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용)
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
|
||||||
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||||
}
|
}
|
||||||
|
|
@ -304,7 +393,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
console.error("파일 조회 오류:", error);
|
console.error("파일 조회 오류:", error);
|
||||||
}
|
}
|
||||||
return false; // 기존 로직 사용
|
return false; // 기존 로직 사용
|
||||||
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]);
|
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
|
||||||
|
|
||||||
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -316,6 +405,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
componentFiles: componentFiles.length,
|
componentFiles: componentFiles.length,
|
||||||
formData: formData,
|
formData: formData,
|
||||||
screenId: formData?.screenId,
|
screenId: formData?.screenId,
|
||||||
|
tableName: formData?.tableName, // 🔍 테이블명 확인
|
||||||
|
recordId: formData?.id, // 🔍 레코드 ID 확인
|
||||||
currentUploadedFiles: uploadedFiles.length,
|
currentUploadedFiles: uploadedFiles.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -371,9 +462,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
setUploadedFiles(files);
|
setUploadedFiles(files);
|
||||||
setForceUpdate((prev) => prev + 1);
|
setForceUpdate((prev) => prev + 1);
|
||||||
|
|
||||||
// localStorage 백업도 업데이트
|
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
const backupKey = getUniqueKey();
|
||||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("localStorage 백업 실패:", e);
|
console.warn("localStorage 백업 실패:", e);
|
||||||
|
|
@ -462,10 +553,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
|
// 🔑 레코드 모드 우선 사용
|
||||||
const tableName = formData?.tableName || component.tableName || "default_table";
|
const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table";
|
||||||
const recordId = formData?.id;
|
const effectiveRecordId = recordId || formData?.id;
|
||||||
const columnName = component.columnName || component.id;
|
const effectiveColumnName = columnName;
|
||||||
|
|
||||||
// screenId 추출 (우선순위: formData > URL)
|
// screenId 추출 (우선순위: formData > URL)
|
||||||
let screenId = formData?.screenId;
|
let screenId = formData?.screenId;
|
||||||
|
|
@ -478,47 +569,84 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetObjid;
|
let targetObjid;
|
||||||
// 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값
|
// 🔑 레코드 모드 판단 개선
|
||||||
const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_');
|
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
|
||||||
|
|
||||||
if (isRealRecord && tableName) {
|
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
|
||||||
// 실제 데이터 파일 (진짜 레코드 ID가 있을 때만)
|
// 🎯 레코드 모드: 특정 행에 파일 연결
|
||||||
targetObjid = `${tableName}:${recordId}:${columnName}`;
|
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
|
||||||
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
|
console.log("📁 [레코드 모드] 파일 업로드:", {
|
||||||
|
targetObjid,
|
||||||
|
tableName: effectiveTableName,
|
||||||
|
recordId: effectiveRecordId,
|
||||||
|
columnName: effectiveColumnName,
|
||||||
|
});
|
||||||
} else if (screenId) {
|
} else if (screenId) {
|
||||||
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
|
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
|
||||||
targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`;
|
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
|
||||||
|
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
|
||||||
} else {
|
} else {
|
||||||
// 기본값 (화면관리에서 사용)
|
// 기본값 (화면관리에서 사용)
|
||||||
targetObjid = `temp_${component.id}`;
|
targetObjid = `temp_${component.id}`;
|
||||||
console.log("📝 기본 파일 업로드:", targetObjid);
|
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
|
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
|
||||||
const userCompanyCode = (window as any).__user__?.companyCode;
|
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
|
||||||
|
|
||||||
|
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
|
||||||
|
userCompanyCode,
|
||||||
|
isRecordMode: effectiveIsRecordMode,
|
||||||
|
tableName: effectiveTableName,
|
||||||
|
recordId: effectiveRecordId,
|
||||||
|
columnName: effectiveColumnName,
|
||||||
|
targetObjid,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
|
||||||
|
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
|
||||||
|
const finalLinkedTable = effectiveIsRecordMode
|
||||||
|
? effectiveTableName
|
||||||
|
: (formData?.linkedTable || effectiveTableName);
|
||||||
|
|
||||||
const uploadData = {
|
const uploadData = {
|
||||||
// 🎯 formData에서 백엔드 API 설정 가져오기
|
// 🎯 formData에서 백엔드 API 설정 가져오기
|
||||||
autoLink: formData?.autoLink || true,
|
autoLink: formData?.autoLink || true,
|
||||||
linkedTable: formData?.linkedTable || tableName,
|
linkedTable: finalLinkedTable,
|
||||||
recordId: formData?.recordId || recordId || `temp_${component.id}`,
|
recordId: effectiveRecordId || `temp_${component.id}`,
|
||||||
columnName: formData?.columnName || columnName,
|
columnName: effectiveColumnName,
|
||||||
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
||||||
docType: component.fileConfig?.docType || "DOCUMENT",
|
docType: component.fileConfig?.docType || "DOCUMENT",
|
||||||
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
||||||
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
|
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
|
||||||
// 호환성을 위한 기존 필드들
|
// 호환성을 위한 기존 필드들
|
||||||
tableName: tableName,
|
tableName: effectiveTableName,
|
||||||
fieldName: columnName,
|
fieldName: effectiveColumnName,
|
||||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||||
|
// 🆕 레코드 모드 플래그
|
||||||
|
isRecordMode: effectiveIsRecordMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("📤 [FileUploadComponent] uploadData 최종:", {
|
||||||
|
isRecordMode: effectiveIsRecordMode,
|
||||||
|
linkedTable: finalLinkedTable,
|
||||||
|
recordId: effectiveRecordId,
|
||||||
|
columnName: effectiveColumnName,
|
||||||
|
targetObjid,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
|
||||||
|
filesCount: filesToUpload.length,
|
||||||
|
uploadData,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await uploadFiles({
|
const response = await uploadFiles({
|
||||||
files: filesToUpload,
|
files: filesToUpload,
|
||||||
...uploadData,
|
...uploadData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// FileUploadResponse 타입에 맞게 files 배열 사용
|
// FileUploadResponse 타입에 맞게 files 배열 사용
|
||||||
|
|
@ -553,9 +681,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
setUploadedFiles(updatedFiles);
|
setUploadedFiles(updatedFiles);
|
||||||
setUploadStatus("success");
|
setUploadStatus("success");
|
||||||
|
|
||||||
// localStorage 백업
|
// localStorage 백업 (레코드별 고유 키 사용)
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
const backupKey = getUniqueKey();
|
||||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("localStorage 백업 실패:", e);
|
console.warn("localStorage 백업 실패:", e);
|
||||||
|
|
@ -563,9 +691,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
// 전역 파일 상태 업데이트
|
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
||||||
const globalFileState = (window as any).globalFileState || {};
|
const globalFileState = (window as any).globalFileState || {};
|
||||||
globalFileState[component.id] = updatedFiles;
|
const uniqueKey = getUniqueKey();
|
||||||
|
globalFileState[uniqueKey] = updatedFiles;
|
||||||
(window as any).globalFileState = globalFileState;
|
(window as any).globalFileState = globalFileState;
|
||||||
|
|
||||||
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
||||||
|
|
@ -573,12 +702,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
uploadPage: window.location.pathname,
|
uploadPage: window.location.pathname,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
screenId: formData?.screenId,
|
screenId: formData?.screenId,
|
||||||
|
recordId: recordId, // 🆕 레코드 ID 추가
|
||||||
});
|
});
|
||||||
|
|
||||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||||
detail: {
|
detail: {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
|
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
||||||
|
recordId: recordId, // 🆕 레코드 ID 추가
|
||||||
files: updatedFiles,
|
files: updatedFiles,
|
||||||
fileCount: updatedFiles.length,
|
fileCount: updatedFiles.length,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -612,22 +744,54 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트)
|
||||||
|
if (effectiveIsRecordMode && onFormDataChange) {
|
||||||
|
// 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환
|
||||||
|
const attachmentsData = updatedFiles.map(file => ({
|
||||||
|
objid: file.objid,
|
||||||
|
realFileName: file.realFileName,
|
||||||
|
fileSize: file.fileSize,
|
||||||
|
fileExt: file.fileExt,
|
||||||
|
filePath: file.filePath,
|
||||||
|
regdate: file.regdate || new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("📎 [레코드 모드] attachments 컬럼 동기화:", {
|
||||||
|
tableName: effectiveTableName,
|
||||||
|
recordId: effectiveRecordId,
|
||||||
|
columnName: effectiveColumnName,
|
||||||
|
fileCount: attachmentsData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림
|
||||||
|
onFormDataChange({
|
||||||
|
[effectiveColumnName]: attachmentsData,
|
||||||
|
// 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보
|
||||||
|
__attachmentsUpdate: {
|
||||||
|
tableName: effectiveTableName,
|
||||||
|
recordId: effectiveRecordId,
|
||||||
|
columnName: effectiveColumnName,
|
||||||
|
files: attachmentsData,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
||||||
detail: {
|
detail: {
|
||||||
tableName: tableName,
|
tableName: effectiveTableName,
|
||||||
recordId: recordId,
|
recordId: effectiveRecordId,
|
||||||
columnName: columnName,
|
columnName: effectiveColumnName,
|
||||||
targetObjid: targetObjid,
|
targetObjid: targetObjid,
|
||||||
fileCount: updatedFiles.length,
|
fileCount: updatedFiles.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.dispatchEvent(refreshEvent);
|
window.dispatchEvent(refreshEvent);
|
||||||
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
|
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
|
||||||
tableName,
|
tableName: effectiveTableName,
|
||||||
recordId,
|
recordId: effectiveRecordId,
|
||||||
columnName,
|
columnName: effectiveColumnName,
|
||||||
targetObjid,
|
targetObjid,
|
||||||
fileCount: updatedFiles.length,
|
fileCount: updatedFiles.length,
|
||||||
});
|
});
|
||||||
|
|
@ -705,9 +869,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
|
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
|
||||||
setUploadedFiles(updatedFiles);
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
// localStorage 백업 업데이트
|
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
const backupKey = getUniqueKey();
|
||||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||||
|
|
@ -715,15 +879,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
// 전역 파일 상태 업데이트
|
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
||||||
const globalFileState = (window as any).globalFileState || {};
|
const globalFileState = (window as any).globalFileState || {};
|
||||||
globalFileState[component.id] = updatedFiles;
|
const uniqueKey = getUniqueKey();
|
||||||
|
globalFileState[uniqueKey] = updatedFiles;
|
||||||
(window as any).globalFileState = globalFileState;
|
(window as any).globalFileState = globalFileState;
|
||||||
|
|
||||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||||
detail: {
|
detail: {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
|
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
||||||
|
recordId: recordId, // 🆕 레코드 ID 추가
|
||||||
files: updatedFiles,
|
files: updatedFiles,
|
||||||
fileCount: updatedFiles.length,
|
fileCount: updatedFiles.length,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -749,13 +916,42 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후)
|
||||||
|
if (isRecordMode && onFormDataChange && recordTableName && recordId) {
|
||||||
|
const attachmentsData = updatedFiles.map(f => ({
|
||||||
|
objid: f.objid,
|
||||||
|
realFileName: f.realFileName,
|
||||||
|
fileSize: f.fileSize,
|
||||||
|
fileExt: f.fileExt,
|
||||||
|
filePath: f.filePath,
|
||||||
|
regdate: f.regdate || new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", {
|
||||||
|
tableName: recordTableName,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
remainingFiles: attachmentsData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFormDataChange({
|
||||||
|
[columnName]: attachmentsData,
|
||||||
|
__attachmentsUpdate: {
|
||||||
|
tableName: recordTableName,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
files: attachmentsData,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`${fileName} 삭제 완료`);
|
toast.success(`${fileName} 삭제 완료`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 삭제 오류:", error);
|
console.error("파일 삭제 오류:", error);
|
||||||
toast.error("파일 삭제에 실패했습니다.");
|
toast.error("파일 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploadedFiles, onUpdate, component.id],
|
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 대표 이미지 Blob URL 로드
|
// 대표 이미지 Blob URL 로드
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ export interface LocationSwapSelectorProps {
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
onFormDataChange?: (field: string, value: any) => void;
|
onFormDataChange?: (field: string, value: any) => void;
|
||||||
|
|
||||||
|
// 🆕 사용자 정보 (DB에서 초기값 로드용)
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
// componentConfig (화면 디자이너에서 전달)
|
// componentConfig (화면 디자이너에서 전달)
|
||||||
componentConfig?: {
|
componentConfig?: {
|
||||||
dataSource?: DataSourceConfig;
|
dataSource?: DataSourceConfig;
|
||||||
|
|
@ -65,6 +68,10 @@ export interface LocationSwapSelectorProps {
|
||||||
showSwapButton?: boolean;
|
showSwapButton?: boolean;
|
||||||
swapButtonPosition?: "center" | "right";
|
swapButtonPosition?: "center" | "right";
|
||||||
variant?: "card" | "inline" | "minimal";
|
variant?: "card" | "inline" | "minimal";
|
||||||
|
// 🆕 DB 초기값 로드 설정
|
||||||
|
loadFromDb?: boolean; // DB에서 초기값 로드 여부
|
||||||
|
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
|
||||||
|
dbKeyField?: string; // 키 필드 (기본: user_id)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +87,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
formData = {},
|
formData = {},
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
|
userId,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||||
|
|
@ -93,6 +101,11 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||||
const variant = config.variant || props.variant || "card";
|
const variant = config.variant || props.variant || "card";
|
||||||
|
|
||||||
|
// 🆕 DB 초기값 로드 설정
|
||||||
|
const loadFromDb = config.loadFromDb !== false; // 기본값 true
|
||||||
|
const dbTableName = config.dbTableName || "vehicles";
|
||||||
|
const dbKeyField = config.dbKeyField || "user_id";
|
||||||
|
|
||||||
// 기본 옵션 (포항/광양)
|
// 기본 옵션 (포항/광양)
|
||||||
const DEFAULT_OPTIONS: LocationOption[] = [
|
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||||
|
|
@ -104,6 +117,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isSwapping, setIsSwapping] = useState(false);
|
const [isSwapping, setIsSwapping] = useState(false);
|
||||||
|
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
|
||||||
|
|
||||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||||
|
|
@ -193,8 +207,89 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [dataSource, isDesignMode]);
|
}, [dataSource, isDesignMode]);
|
||||||
|
|
||||||
// formData에서 초기값 동기화
|
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const loadFromDatabase = async () => {
|
||||||
|
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
|
||||||
|
if (isDesignMode || !loadFromDb || !userId) {
|
||||||
|
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 로드했으면 스킵
|
||||||
|
if (dbLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${dbTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: { [dbKeyField]: userId },
|
||||||
|
autoFilter: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
|
||||||
|
|
||||||
|
if (vehicleData) {
|
||||||
|
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
|
||||||
|
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
|
||||||
|
|
||||||
|
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
|
||||||
|
|
||||||
|
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
|
||||||
|
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
|
||||||
|
setLocalDeparture(dbDeparture);
|
||||||
|
onFormDataChange?.(departureField, dbDeparture);
|
||||||
|
|
||||||
|
// 라벨도 업데이트
|
||||||
|
if (departureLabelField) {
|
||||||
|
const opt = options.find(o => o.value === dbDeparture);
|
||||||
|
if (opt) {
|
||||||
|
onFormDataChange?.(departureLabelField, opt.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbDestination && options.some(o => o.value === dbDestination)) {
|
||||||
|
setLocalDestination(dbDestination);
|
||||||
|
onFormDataChange?.(destinationField, dbDestination);
|
||||||
|
|
||||||
|
// 라벨도 업데이트
|
||||||
|
if (destinationLabelField) {
|
||||||
|
const opt = options.find(o => o.value === dbDestination);
|
||||||
|
if (opt) {
|
||||||
|
onFormDataChange?.(destinationLabelField, opt.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDbLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LocationSwapSelector] DB 로드 실패:", error);
|
||||||
|
setDbLoaded(true); // 실패해도 다시 시도하지 않음
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 옵션이 로드된 후에 DB 로드 실행
|
||||||
|
if (options.length > 0) {
|
||||||
|
loadFromDatabase();
|
||||||
|
}
|
||||||
|
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
|
||||||
|
|
||||||
|
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
|
||||||
|
useEffect(() => {
|
||||||
|
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
|
||||||
|
if (loadFromDb && userId && !dbLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const depVal = formData[departureField];
|
const depVal = formData[departureField];
|
||||||
const destVal = formData[destinationField];
|
const destVal = formData[destinationField];
|
||||||
|
|
||||||
|
|
@ -204,7 +299,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
if (destVal && options.some(o => o.value === destVal)) {
|
if (destVal && options.some(o => o.value === destVal)) {
|
||||||
setLocalDestination(destVal);
|
setLocalDestination(destVal);
|
||||||
}
|
}
|
||||||
}, [formData, departureField, destinationField, options]);
|
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
|
||||||
|
|
||||||
// 출발지 변경
|
// 출발지 변경
|
||||||
const handleDepartureChange = (selectedValue: string) => {
|
const handleDepartureChange = (selectedValue: string) => {
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,58 @@ export function LocationSwapSelectorConfigPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DB 초기값 로드 설정 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<h4 className="text-sm font-medium">DB 초기값 로드</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
새로고침 시에도 DB에 저장된 출발지/목적지를 자동으로 불러옵니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>DB에서 초기값 로드</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config?.loadFromDb !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config?.loadFromDb !== false && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>조회 테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={config?.dbTableName || "vehicles"}
|
||||||
|
onValueChange={(value) => handleChange("dbTableName", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vehicles">vehicles (기본)</SelectItem>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.name} value={table.name}>
|
||||||
|
{table.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>키 필드</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.dbKeyField || "user_id"}
|
||||||
|
onChange={(e) => handleChange("dbKeyField", e.target.value)}
|
||||||
|
placeholder="user_id"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
현재 사용자 ID로 조회할 필드 (기본: user_id)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
|
@ -480,6 +532,8 @@ export function LocationSwapSelectorConfigPanel({
|
||||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||||
<br />
|
<br />
|
||||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||||
|
<br />
|
||||||
|
4. DB 초기값 로드를 활성화하면 새로고침 후에도 값이 유지됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,15 @@ export function RepeatScreenModalComponent({
|
||||||
config,
|
config,
|
||||||
className,
|
className,
|
||||||
groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터
|
groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터
|
||||||
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지)
|
||||||
|
_initialData,
|
||||||
|
_originalData: _propsOriginalData,
|
||||||
|
_groupedData,
|
||||||
...props
|
...props
|
||||||
}: RepeatScreenModalComponentProps) {
|
}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
||||||
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
|
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
|
||||||
// DynamicComponentRenderer에서는 _groupedData로 전달됨
|
// DynamicComponentRenderer에서는 _groupedData로 전달됨
|
||||||
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
|
const groupedData = propsGroupedData || (props as any).groupedData || _groupedData;
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
...component?.config,
|
...component?.config,
|
||||||
|
|
|
||||||
|
|
@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const screenContext = useScreenContextOptional();
|
const screenContext = useScreenContextOptional();
|
||||||
const splitPanelContext = useSplitPanelContext();
|
const splitPanelContext = useSplitPanelContext();
|
||||||
const receiverRef = useRef<DataReceivable | null>(null);
|
const receiverRef = useRef<DataReceivable | null>(null);
|
||||||
|
|
||||||
// 🆕 그룹화된 데이터를 저장하는 상태
|
// 🆕 그룹화된 데이터를 저장하는 상태
|
||||||
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
||||||
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
||||||
const groupDataLoadedRef = useRef(false);
|
const groupDataLoadedRef = useRef(false);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
|
||||||
|
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 컴포넌트의 필드명 (formData 키)
|
// 컴포넌트의 필드명 (formData 키)
|
||||||
const fieldName = (component as any).columnName || component.id;
|
const fieldName = (component as any).columnName || component.id;
|
||||||
|
|
||||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||||
|
|
||||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||||
const groupByColumn = config.groupByColumn;
|
const groupByColumn = rawConfig.groupByColumn;
|
||||||
const targetTable = config.targetTable;
|
const targetTable = rawConfig.targetTable;
|
||||||
|
|
||||||
|
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
||||||
|
const config = useMemo(() => {
|
||||||
|
const rawFields = rawConfig.fields || [];
|
||||||
|
console.log("📋 [RepeaterFieldGroup] config 생성:", {
|
||||||
|
rawFieldsCount: rawFields.length,
|
||||||
|
rawFieldNames: rawFields.map((f: any) => f.name),
|
||||||
|
columnInfoKeys: Object.keys(columnInfo),
|
||||||
|
hasColumnInfo: Object.keys(columnInfo).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = rawFields.map((field: any) => {
|
||||||
|
const colInfo = columnInfo[field.name];
|
||||||
|
// DB의 webType 또는 web_type을 field.type으로 적용
|
||||||
|
const dbWebType = colInfo?.webType || colInfo?.web_type;
|
||||||
|
|
||||||
|
// 타입 오버라이드 조건:
|
||||||
|
// 1. field.type이 없거나
|
||||||
|
// 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우
|
||||||
|
const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text");
|
||||||
|
|
||||||
|
if (colInfo && dbWebType && shouldOverride) {
|
||||||
|
console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name} → ${dbWebType}`);
|
||||||
|
return { ...field, type: dbWebType };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
return { ...rawConfig, fields };
|
||||||
|
}, [rawConfig, columnInfo]);
|
||||||
|
|
||||||
// formData에서 값 가져오기 (value prop보다 우선)
|
// formData에서 값 가져오기 (value prop보다 우선)
|
||||||
const rawValue = formData?.[fieldName] ?? value;
|
const rawValue = formData?.[fieldName] ?? value;
|
||||||
|
|
@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
||||||
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
||||||
const isEditMode = formData?.id && !rawValue && !value;
|
const isEditMode = formData?.id && !rawValue && !value;
|
||||||
|
|
||||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||||
const configFields = config.fields || [];
|
const configFields = config.fields || [];
|
||||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
const hasRepeaterFieldsInFormData =
|
||||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||||
|
|
||||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||||
fieldName,
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
hasFormData: !!formData,
|
const isRightPanel = splitPanelPosition === "right";
|
||||||
|
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||||
|
|
||||||
|
// 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기
|
||||||
|
// screen-split-panel에서 설정한 linkedFilters 사용
|
||||||
|
const linkedFilters = splitPanelContext?.linkedFilters || [];
|
||||||
|
const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues;
|
||||||
|
|
||||||
|
// 🆕 FK 컬럼 설정 우선순위:
|
||||||
|
// 1. linkedFilters에서 targetTable에 해당하는 설정 찾기
|
||||||
|
// 2. config.fkColumn (컴포넌트 설정)
|
||||||
|
// 3. config.groupByColumn (그룹화 컬럼)
|
||||||
|
let fkSourceColumn: string | null = null;
|
||||||
|
let fkTargetColumn: string | null = null;
|
||||||
|
let linkedFilterTargetTable: string | null = null;
|
||||||
|
|
||||||
|
// linkedFilters에서 FK 컬럼 찾기
|
||||||
|
if (linkedFilters.length > 0 && selectedLeftData) {
|
||||||
|
// 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨)
|
||||||
|
const linkedFilter = linkedFilters[0];
|
||||||
|
fkSourceColumn = linkedFilter.sourceColumn;
|
||||||
|
|
||||||
|
// targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리
|
||||||
|
// 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no"
|
||||||
|
const targetColumnParts = linkedFilter.targetColumn.split(".");
|
||||||
|
if (targetColumnParts.length === 2) {
|
||||||
|
linkedFilterTargetTable = targetColumnParts[0];
|
||||||
|
fkTargetColumn = targetColumnParts[1];
|
||||||
|
} else {
|
||||||
|
fkTargetColumn = linkedFilter.targetColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블
|
||||||
|
const effectiveTargetTable = targetTable || linkedFilterTargetTable;
|
||||||
|
|
||||||
|
// 🆕 DB에서 컬럼 정보 로드 (webType 등)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumnInfo = async () => {
|
||||||
|
if (!effectiveTargetTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`);
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data);
|
||||||
|
|
||||||
|
// 응답 구조에 따라 데이터 추출
|
||||||
|
// 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } }
|
||||||
|
let columns: any[] = [];
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// data.columns가 배열인 경우 (실제 응답 구조)
|
||||||
|
if (Array.isArray(response.data.data.columns)) {
|
||||||
|
columns = response.data.data.columns;
|
||||||
|
}
|
||||||
|
// data가 배열인 경우
|
||||||
|
else if (Array.isArray(response.data.data)) {
|
||||||
|
columns = response.data.data;
|
||||||
|
}
|
||||||
|
// data 자체가 객체이고 배열이 아닌 경우 (키-값 형태)
|
||||||
|
else if (typeof response.data.data === "object") {
|
||||||
|
columns = Object.values(response.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// success 없이 바로 배열인 경우
|
||||||
|
else if (Array.isArray(response.data)) {
|
||||||
|
columns = response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개");
|
||||||
|
|
||||||
|
if (columns.length > 0) {
|
||||||
|
const colMap: Record<string, any> = {};
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
// columnName 또는 column_name 또는 name 키 사용
|
||||||
|
const colName = col.columnName || col.column_name || col.name;
|
||||||
|
if (colName) {
|
||||||
|
colMap[colName] = col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setColumnInfo(colMap);
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", {
|
||||||
|
table: effectiveTargetTable,
|
||||||
|
columns: Object.keys(colMap),
|
||||||
|
webTypes: Object.entries(colMap).map(
|
||||||
|
([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumnInfo();
|
||||||
|
}, [effectiveTargetTable]);
|
||||||
|
|
||||||
|
// linkedFilters가 없으면 config에서 가져오기
|
||||||
|
const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn;
|
||||||
|
const fkValue =
|
||||||
|
fkSourceColumn && selectedLeftData
|
||||||
|
? selectedLeftData[fkSourceColumn]
|
||||||
|
: fkColumn && selectedLeftData
|
||||||
|
? selectedLeftData[fkColumn]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||||
|
fieldName,
|
||||||
|
hasFormData: !!formData,
|
||||||
formDataId: formData?.id,
|
formDataId: formData?.id,
|
||||||
formDataValue: formData?.[fieldName],
|
formDataValue: formData?.[fieldName],
|
||||||
propsValue: value,
|
propsValue: value,
|
||||||
|
|
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
groupKeyValue,
|
groupKeyValue,
|
||||||
targetTable,
|
targetTable,
|
||||||
|
linkedFilterTargetTable,
|
||||||
|
effectiveTargetTable,
|
||||||
hasGroupedData: groupedData !== null,
|
hasGroupedData: groupedData !== null,
|
||||||
groupedDataLength: groupedData?.length,
|
groupedDataLength: groupedData?.length,
|
||||||
|
// 🆕 분할 패널 관련 정보
|
||||||
|
linkedFiltersCount: linkedFilters.length,
|
||||||
|
linkedFilters: linkedFilters.map((f) => `${f.sourceColumn} → ${f.targetColumn}`),
|
||||||
|
fkSourceColumn,
|
||||||
|
fkTargetColumn,
|
||||||
|
splitPanelPosition,
|
||||||
|
isRightPanel,
|
||||||
|
hasSelectedLeftData: !!selectedLeftData,
|
||||||
|
// 🆕 selectedLeftData 상세 정보 (디버깅용)
|
||||||
|
selectedLeftDataId: selectedLeftData?.id,
|
||||||
|
selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A",
|
||||||
|
selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||||
|
|
@ -82,16 +236,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
||||||
if (groupDataLoadedRef.current) return;
|
if (groupDataLoadedRef.current) return;
|
||||||
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
groupKeyValue,
|
groupKeyValue,
|
||||||
targetTable,
|
targetTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoadingGroupData(true);
|
setIsLoadingGroupData(true);
|
||||||
groupDataLoadedRef.current = true;
|
groupDataLoadedRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
||||||
// search 파라미터 사용 (filters가 아닌 search)
|
// search 파라미터 사용 (filters가 아닌 search)
|
||||||
|
|
@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
size: 100, // 충분히 큰 값
|
size: 100, // 충분히 큰 값
|
||||||
search: { [groupByColumn]: groupKeyValue },
|
search: { [groupByColumn]: groupKeyValue },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
||||||
success: response.data?.success,
|
success: response.data?.success,
|
||||||
hasData: !!response.data?.data,
|
hasData: !!response.data?.data,
|
||||||
dataType: typeof response.data?.data,
|
dataType: typeof response.data?.data,
|
||||||
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
||||||
if (response.data?.success && response.data?.data?.data) {
|
if (response.data?.success && response.data?.data?.data) {
|
||||||
const items = response.data.data.data; // 실제 데이터 배열
|
const items = response.data.data.data; // 실제 데이터 배열
|
||||||
|
|
@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
firstItem: items[0],
|
firstItem: items[0],
|
||||||
});
|
});
|
||||||
setGroupedData(items);
|
setGroupedData(items);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||||
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
||||||
setOriginalItemIds(itemIds);
|
setOriginalItemIds(itemIds);
|
||||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||||
|
|
||||||
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||||
splitPanelContext.addItemIds(itemIds);
|
splitPanelContext.addItemIds(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// onChange 호출하여 부모에게 알림
|
// onChange 호출하여 부모에게 알림
|
||||||
if (onChange && items.length > 0) {
|
if (onChange && items.length > 0) {
|
||||||
const dataWithMeta = items.map((item: any) => ({
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
|
@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
setIsLoadingGroupData(false);
|
setIsLoadingGroupData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadGroupedData();
|
loadGroupedData();
|
||||||
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
||||||
|
|
||||||
|
// 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드
|
||||||
|
// 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링
|
||||||
|
const prevFkValueRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDataByFK = async () => {
|
||||||
|
// 우측 패널이 아니면 스킵
|
||||||
|
if (!isRightPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화
|
||||||
|
if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) {
|
||||||
|
console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", {
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
prevFkValue: prevFkValueRef.current,
|
||||||
|
});
|
||||||
|
// 이전에 데이터가 있었다면 초기화
|
||||||
|
if (prevFkValueRef.current !== null) {
|
||||||
|
setGroupedData([]);
|
||||||
|
setOriginalItemIds([]);
|
||||||
|
onChange?.([]);
|
||||||
|
prevFkValueRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK 컬럼이나 타겟 테이블이 없으면 스킵
|
||||||
|
if (!fkColumn || !effectiveTargetTable) {
|
||||||
|
console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", {
|
||||||
|
fkColumn,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 FK 값으로 이미 로드했으면 스킵
|
||||||
|
const currentFkValueStr = String(fkValue);
|
||||||
|
if (prevFkValueRef.current === currentFkValueStr) {
|
||||||
|
console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevFkValueRef.current = currentFkValueStr;
|
||||||
|
|
||||||
|
console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", {
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoadingGroupData(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API 호출: FK 값을 기준으로 데이터 조회
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
search: { [fkColumn]: fkValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
const items = response.data?.data?.data || [];
|
||||||
|
console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", {
|
||||||
|
count: items.length,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정)
|
||||||
|
setGroupedData(items);
|
||||||
|
|
||||||
|
// 원본 데이터 ID 목록 저장
|
||||||
|
const itemIds = items.map((item: any) => String(item.id)).filter(Boolean);
|
||||||
|
setOriginalItemIds(itemIds);
|
||||||
|
|
||||||
|
// onChange 호출 (effectiveTargetTable 사용)
|
||||||
|
if (onChange) {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
_targetTable: effectiveTargetTable,
|
||||||
|
_existingRecord: !!item.id,
|
||||||
|
}));
|
||||||
|
onChange(dataWithMeta);
|
||||||
|
} else {
|
||||||
|
// 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어)
|
||||||
|
console.log("ℹ️ [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화");
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// API 실패 시 빈 배열로 설정
|
||||||
|
console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화");
|
||||||
|
setGroupedData([]);
|
||||||
|
setOriginalItemIds([]);
|
||||||
|
onChange?.([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error);
|
||||||
|
setGroupedData([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingGroupData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDataByFK();
|
||||||
|
}, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]);
|
||||||
|
|
||||||
// 값이 JSON 문자열인 경우 파싱
|
// 값이 JSON 문자열인 경우 파싱
|
||||||
let parsedValue: any[] = [];
|
let parsedValue: any[] = [];
|
||||||
|
|
||||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
|
||||||
if (groupedData !== null && groupedData.length > 0) {
|
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
|
||||||
|
if (groupedData !== null) {
|
||||||
parsedValue = groupedData;
|
parsedValue = groupedData;
|
||||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||||
|
|
@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 데이터 수신 핸들러
|
// 데이터 수신 핸들러
|
||||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
toast.warning("전달할 데이터가 없습니다");
|
toast.warning("전달할 데이터가 없습니다");
|
||||||
return;
|
return;
|
||||||
|
|
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const definedFields = configRef.current.fields || [];
|
const definedFields = configRef.current.fields || [];
|
||||||
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
||||||
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
|
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
|
||||||
const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']);
|
const systemFields = new Set([
|
||||||
|
"_targetTable",
|
||||||
|
"_isNewItem",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"writer",
|
||||||
|
"company_code",
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredData = normalizedData.map((item: any) => {
|
const filteredData = normalizedData.map((item: any) => {
|
||||||
const filteredItem: Record<string, any> = {};
|
const filteredItem: Record<string, any> = {};
|
||||||
Object.keys(item).forEach(key => {
|
Object.keys(item).forEach((key) => {
|
||||||
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
||||||
if (key === 'id') {
|
if (key === "id") {
|
||||||
return; // id 필드 제외
|
return; // id 필드 제외
|
||||||
}
|
}
|
||||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||||
|
|
@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
|
|
||||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||||
const currentValue = parsedValueRef.current;
|
const currentValue = parsedValueRef.current;
|
||||||
|
|
||||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||||
|
|
||||||
let newItems: any[];
|
let newItems: any[];
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
let duplicateCount = 0;
|
let duplicateCount = 0;
|
||||||
|
|
||||||
if (mode === "replace") {
|
if (mode === "replace") {
|
||||||
newItems = filteredData;
|
newItems = filteredData;
|
||||||
addedCount = filteredData.length;
|
addedCount = filteredData.length;
|
||||||
} else {
|
} else {
|
||||||
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
||||||
const existingItemCodes = new Set(
|
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
|
||||||
currentValue
|
|
||||||
.map((item: any) => item.item_code)
|
|
||||||
.filter(Boolean)
|
|
||||||
);
|
|
||||||
|
|
||||||
const uniqueNewItems = filteredData.filter((item: any) => {
|
const uniqueNewItems = filteredData.filter((item: any) => {
|
||||||
const itemCode = item.item_code;
|
const itemCode = item.item_code;
|
||||||
if (itemCode && existingItemCodes.has(itemCode)) {
|
if (itemCode && existingItemCodes.has(itemCode)) {
|
||||||
|
|
@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
newItems = [...currentValue, ...uniqueNewItems];
|
newItems = [...currentValue, ...uniqueNewItems];
|
||||||
addedCount = uniqueNewItems.length;
|
addedCount = uniqueNewItems.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||||
currentValue,
|
currentValue,
|
||||||
newItems,
|
newItems,
|
||||||
mode,
|
mode,
|
||||||
addedCount,
|
addedCount,
|
||||||
duplicateCount,
|
duplicateCount,
|
||||||
|
|
@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
||||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||||
const newItemCodes = newItems
|
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
|
||||||
.map((item: any) => String(item.item_code))
|
|
||||||
.filter(Boolean);
|
|
||||||
splitPanelContext.addItemIds(newItemCodes);
|
splitPanelContext.addItemIds(newItemCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 문자열로 변환하여 저장
|
// JSON 문자열로 변환하여 저장
|
||||||
const jsonValue = JSON.stringify(newItems);
|
const jsonValue = JSON.stringify(newItems);
|
||||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||||
jsonValue,
|
jsonValue,
|
||||||
hasOnChange: !!onChangeRef.current,
|
hasOnChange: !!onChangeRef.current,
|
||||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||||
fieldName: fieldNameRef.current,
|
fieldName: fieldNameRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||||
if (onFormDataChangeRef.current) {
|
if (onFormDataChangeRef.current) {
|
||||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||||
|
|
@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// DataReceivable 인터페이스 구현
|
// DataReceivable 인터페이스 구현
|
||||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
const dataReceiver = useMemo<DataReceivable>(
|
||||||
componentId: component.id,
|
() => ({
|
||||||
componentType: "repeater-field-group",
|
componentId: component.id,
|
||||||
receiveData: handleReceiveData,
|
componentType: "repeater-field-group",
|
||||||
}), [component.id, handleReceiveData]);
|
receiveData: handleReceiveData,
|
||||||
|
}),
|
||||||
|
[component.id, handleReceiveData],
|
||||||
|
);
|
||||||
|
|
||||||
// ScreenContext에 데이터 수신자로 등록
|
// ScreenContext에 데이터 수신자로 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screenContext && component.id) {
|
if (screenContext && component.id) {
|
||||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
screenContext.unregisterDataReceiver(component.id);
|
screenContext.unregisterDataReceiver(component.id);
|
||||||
};
|
};
|
||||||
|
|
@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
||||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
position: splitPanelPosition,
|
position: splitPanelPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||||
receiverRef.current = dataReceiver;
|
receiverRef.current = dataReceiver;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||||
|
|
@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
||||||
const { data, mode, mappingRules } = event.detail;
|
const { data, mode, mappingRules } = event.detail;
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
||||||
dataCount: data?.length,
|
dataCount: data?.length,
|
||||||
mode,
|
mode,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
if (splitPanelPosition === "right" && data && data.length > 0) {
|
if (splitPanelPosition === "right" && data && data.length > 0) {
|
||||||
|
|
@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
};
|
};
|
||||||
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
||||||
|
|
||||||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||||
const handleRepeaterChange = useCallback((newValue: any[]) => {
|
const handleRepeaterChange = useCallback(
|
||||||
// 배열을 JSON 문자열로 변환하여 저장
|
(newValue: any[]) => {
|
||||||
const jsonValue = JSON.stringify(newValue);
|
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||||
onChange?.(jsonValue);
|
let valueWithMeta = newValue;
|
||||||
|
|
||||||
// 🆕 groupedData 상태도 업데이트
|
if (isRightPanel && effectiveTargetTable) {
|
||||||
setGroupedData(newValue);
|
valueWithMeta = newValue.map((item: any) => {
|
||||||
|
const itemWithMeta = {
|
||||||
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
...item,
|
||||||
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
_targetTable: effectiveTargetTable,
|
||||||
// 현재 항목들의 ID 목록
|
};
|
||||||
const currentIds = newValue
|
|
||||||
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||||
.filter(Boolean);
|
if (fkColumn && fkValue && item._isNewItem) {
|
||||||
|
itemWithMeta[fkColumn] = fkValue;
|
||||||
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||||
const addedIds = splitPanelContext.addedItemIds;
|
fkColumn,
|
||||||
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
|
fkValue,
|
||||||
|
});
|
||||||
if (removedIds.length > 0) {
|
}
|
||||||
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
|
||||||
splitPanelContext.removeItemIds(removedIds);
|
return itemWithMeta;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로 추가된 ID가 있으면 등록
|
// 배열을 JSON 문자열로 변환하여 저장
|
||||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
const jsonValue = JSON.stringify(valueWithMeta);
|
||||||
if (newIds.length > 0) {
|
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
|
||||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
fieldName,
|
||||||
splitPanelContext.addItemIds(newIds);
|
itemCount: valueWithMeta.length,
|
||||||
|
isRightPanel,
|
||||||
|
hasScreenContextUpdateFormData: !!screenContext?.updateFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용
|
||||||
|
// (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴)
|
||||||
|
if (isRightPanel && screenContext?.updateFormData) {
|
||||||
|
screenContext.updateFormData(fieldName, jsonValue);
|
||||||
|
console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName });
|
||||||
|
} else {
|
||||||
|
// 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용
|
||||||
|
onChange?.(jsonValue);
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(fieldName, jsonValue);
|
||||||
|
console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
|
// 🆕 groupedData 상태도 업데이트
|
||||||
|
setGroupedData(valueWithMeta);
|
||||||
|
|
||||||
|
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
||||||
|
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
||||||
|
// 현재 항목들의 ID 목록
|
||||||
|
const currentIds = newValue
|
||||||
|
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
||||||
|
const addedIds = splitPanelContext.addedItemIds;
|
||||||
|
const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id));
|
||||||
|
|
||||||
|
if (removedIds.length > 0) {
|
||||||
|
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
||||||
|
splitPanelContext.removeItemIds(removedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로 추가된 ID가 있으면 등록
|
||||||
|
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||||
|
splitPanelContext.addItemIds(newIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onChange,
|
||||||
|
onFormDataChange,
|
||||||
|
splitPanelContext,
|
||||||
|
screenContext?.splitPanelPosition,
|
||||||
|
screenContext?.updateFormData,
|
||||||
|
isRightPanel,
|
||||||
|
effectiveTargetTable,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
fieldName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
|
||||||
|
const effectiveConfig = {
|
||||||
|
...config,
|
||||||
|
targetTable: effectiveTargetTable || config.targetTable,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterInput
|
<RepeaterInput
|
||||||
value={parsedValue}
|
value={parsedValue}
|
||||||
onChange={handleRepeaterChange}
|
onChange={handleRepeaterChange}
|
||||||
config={config}
|
config={effectiveConfig}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SplitPanelResize Context 타입 정의
|
||||||
|
* 분할 패널의 드래그 리사이즈 상태를 외부 컴포넌트(버튼 등)와 공유하기 위한 Context
|
||||||
|
*
|
||||||
|
* 주의: contexts/SplitPanelContext.tsx는 데이터 전달용 Context이고,
|
||||||
|
* 이 Context는 드래그 리사이즈 시 버튼 위치 조정을 위한 별도 Context입니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 정보 (컴포넌트 좌표 기준)
|
||||||
|
*/
|
||||||
|
export interface SplitPanelInfo {
|
||||||
|
id: string;
|
||||||
|
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
// 좌측 패널 비율 (0-100)
|
||||||
|
leftWidthPercent: number;
|
||||||
|
// 초기 좌측 패널 비율 (드래그 시작 시점)
|
||||||
|
initialLeftWidthPercent: number;
|
||||||
|
// 드래그 중 여부
|
||||||
|
isDragging: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitPanelResizeContextValue {
|
||||||
|
// 등록된 분할 패널들
|
||||||
|
splitPanels: Map<string, SplitPanelInfo>;
|
||||||
|
|
||||||
|
// 분할 패널 등록/해제/업데이트
|
||||||
|
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
|
||||||
|
unregisterSplitPanel: (id: string) => void;
|
||||||
|
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
|
||||||
|
|
||||||
|
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||||
|
// 반환값: { panelId, offsetX } 또는 null
|
||||||
|
getOverlappingSplitPanel: (
|
||||||
|
componentX: number,
|
||||||
|
componentY: number,
|
||||||
|
componentWidth: number,
|
||||||
|
componentHeight: number,
|
||||||
|
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
|
||||||
|
|
||||||
|
// 컴포넌트의 조정된 X 좌표 계산
|
||||||
|
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||||
|
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
|
||||||
|
|
||||||
|
// 레거시 호환 (단일 분할 패널용)
|
||||||
|
leftWidthPercent: number;
|
||||||
|
containerRect: DOMRect | null;
|
||||||
|
dividerX: number;
|
||||||
|
isDragging: boolean;
|
||||||
|
splitPanelId: string | null;
|
||||||
|
updateLeftWidth: (percent: number) => void;
|
||||||
|
updateContainerRect: (rect: DOMRect | null) => void;
|
||||||
|
updateDragging: (dragging: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context 생성
|
||||||
|
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SplitPanelResize Context Provider
|
||||||
|
* 스크린 빌더 레벨에서 감싸서 사용
|
||||||
|
*/
|
||||||
|
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
// 등록된 분할 패널들
|
||||||
|
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
|
||||||
|
const [, forceUpdate] = useState(0);
|
||||||
|
|
||||||
|
// 레거시 호환용 상태
|
||||||
|
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
|
||||||
|
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
|
||||||
|
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
|
||||||
|
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 분할 패널 등록
|
||||||
|
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
|
||||||
|
splitPanelsRef.current.set(id, { id, ...info });
|
||||||
|
setLegacySplitPanelId(id);
|
||||||
|
setLegacyLeftWidthPercent(info.leftWidthPercent);
|
||||||
|
forceUpdate((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 분할 패널 해제
|
||||||
|
const unregisterSplitPanel = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
splitPanelsRef.current.delete(id);
|
||||||
|
if (legacySplitPanelId === id) {
|
||||||
|
setLegacySplitPanelId(null);
|
||||||
|
}
|
||||||
|
forceUpdate((n) => n + 1);
|
||||||
|
},
|
||||||
|
[legacySplitPanelId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 분할 패널 업데이트
|
||||||
|
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
|
||||||
|
const panel = splitPanelsRef.current.get(id);
|
||||||
|
if (panel) {
|
||||||
|
const updatedPanel = { ...panel, ...updates };
|
||||||
|
splitPanelsRef.current.set(id, updatedPanel);
|
||||||
|
|
||||||
|
// 레거시 호환 상태 업데이트
|
||||||
|
if (updates.leftWidthPercent !== undefined) {
|
||||||
|
setLegacyLeftWidthPercent(updates.leftWidthPercent);
|
||||||
|
}
|
||||||
|
if (updates.isDragging !== undefined) {
|
||||||
|
setLegacyIsDragging(updates.isDragging);
|
||||||
|
}
|
||||||
|
|
||||||
|
forceUpdate((n) => n + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||||
|
*/
|
||||||
|
const getOverlappingSplitPanel = useCallback(
|
||||||
|
(
|
||||||
|
componentX: number,
|
||||||
|
componentY: number,
|
||||||
|
componentWidth: number,
|
||||||
|
componentHeight: number,
|
||||||
|
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
|
||||||
|
for (const [panelId, panel] of splitPanelsRef.current) {
|
||||||
|
// 컴포넌트의 중심점
|
||||||
|
const componentCenterX = componentX + componentWidth / 2;
|
||||||
|
const componentCenterY = componentY + componentHeight / 2;
|
||||||
|
|
||||||
|
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
|
||||||
|
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
|
||||||
|
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
|
||||||
|
|
||||||
|
if (isInPanelX && isInPanelY) {
|
||||||
|
// 좌측 패널의 현재 너비 (px)
|
||||||
|
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||||
|
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
|
||||||
|
const dividerX = panel.x + leftPanelWidth;
|
||||||
|
|
||||||
|
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
|
||||||
|
const isInLeftPanel = componentCenterX < dividerX;
|
||||||
|
|
||||||
|
return { panelId, panel, isInLeftPanel };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트의 조정된 X 좌표 계산
|
||||||
|
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||||
|
*
|
||||||
|
* 핵심 로직:
|
||||||
|
* - 버튼의 원래 X 좌표가 초기 좌측 패널 너비 내에서 어느 비율에 있는지 계산
|
||||||
|
* - 드래그로 좌측 패널 너비가 바뀌면, 같은 비율을 유지하도록 X 좌표 조정
|
||||||
|
*/
|
||||||
|
const getAdjustedX = useCallback(
|
||||||
|
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
|
||||||
|
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
if (!overlap || !overlap.isInLeftPanel) {
|
||||||
|
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
|
||||||
|
return componentX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { panel } = overlap;
|
||||||
|
|
||||||
|
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
|
||||||
|
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||||
|
// 현재 좌측 패널 너비 (드래그로 변경된 값)
|
||||||
|
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||||
|
|
||||||
|
// 변화가 없으면 원래 위치 반환
|
||||||
|
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
|
||||||
|
return componentX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트의 분할 패널 내 상대 X 좌표
|
||||||
|
const relativeX = componentX - panel.x;
|
||||||
|
|
||||||
|
// 좌측 패널 내에서의 비율 (0~1)
|
||||||
|
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
|
||||||
|
|
||||||
|
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
|
||||||
|
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
|
||||||
|
|
||||||
|
// 절대 X 좌표로 변환
|
||||||
|
const adjustedX = panel.x + adjustedRelativeX;
|
||||||
|
|
||||||
|
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
|
||||||
|
componentX,
|
||||||
|
panelX: panel.x,
|
||||||
|
relativeX,
|
||||||
|
initialLeftPanelWidth,
|
||||||
|
currentLeftPanelWidth,
|
||||||
|
ratioInLeftPanel,
|
||||||
|
adjustedX,
|
||||||
|
delta: adjustedX - componentX,
|
||||||
|
});
|
||||||
|
|
||||||
|
return adjustedX;
|
||||||
|
},
|
||||||
|
[getOverlappingSplitPanel],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 레거시 호환 - dividerX 계산
|
||||||
|
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
|
||||||
|
|
||||||
|
// 레거시 호환 함수들
|
||||||
|
const updateLeftWidth = useCallback((percent: number) => {
|
||||||
|
setLegacyLeftWidthPercent(percent);
|
||||||
|
// 첫 번째 분할 패널 업데이트
|
||||||
|
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||||
|
if (firstPanelId) {
|
||||||
|
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||||
|
if (panel) {
|
||||||
|
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forceUpdate((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateContainerRect = useCallback((rect: DOMRect | null) => {
|
||||||
|
setLegacyContainerRect(rect);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateDragging = useCallback((dragging: boolean) => {
|
||||||
|
setLegacyIsDragging(dragging);
|
||||||
|
// 첫 번째 분할 패널 업데이트
|
||||||
|
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||||
|
if (firstPanelId) {
|
||||||
|
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||||
|
if (panel) {
|
||||||
|
// 드래그 시작 시 초기 비율 저장
|
||||||
|
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
|
||||||
|
if (dragging) {
|
||||||
|
updates.initialLeftWidthPercent = panel.leftWidthPercent;
|
||||||
|
}
|
||||||
|
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forceUpdate((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<SplitPanelResizeContextValue>(
|
||||||
|
() => ({
|
||||||
|
splitPanels: splitPanelsRef.current,
|
||||||
|
registerSplitPanel,
|
||||||
|
unregisterSplitPanel,
|
||||||
|
updateSplitPanel,
|
||||||
|
getOverlappingSplitPanel,
|
||||||
|
getAdjustedX,
|
||||||
|
// 레거시 호환
|
||||||
|
leftWidthPercent: legacyLeftWidthPercent,
|
||||||
|
containerRect: legacyContainerRect,
|
||||||
|
dividerX: legacyDividerX,
|
||||||
|
isDragging: legacyIsDragging,
|
||||||
|
splitPanelId: legacySplitPanelId,
|
||||||
|
updateLeftWidth,
|
||||||
|
updateContainerRect,
|
||||||
|
updateDragging,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
registerSplitPanel,
|
||||||
|
unregisterSplitPanel,
|
||||||
|
updateSplitPanel,
|
||||||
|
getOverlappingSplitPanel,
|
||||||
|
getAdjustedX,
|
||||||
|
legacyLeftWidthPercent,
|
||||||
|
legacyContainerRect,
|
||||||
|
legacyDividerX,
|
||||||
|
legacyIsDragging,
|
||||||
|
legacySplitPanelId,
|
||||||
|
updateLeftWidth,
|
||||||
|
updateContainerRect,
|
||||||
|
updateDragging,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SplitPanelResize Context 사용 훅
|
||||||
|
* 분할 패널의 드래그 리사이즈 상태를 구독합니다.
|
||||||
|
*/
|
||||||
|
export const useSplitPanel = (): SplitPanelResizeContextValue => {
|
||||||
|
const context = useContext(SplitPanelResizeContext);
|
||||||
|
|
||||||
|
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
|
||||||
|
if (!context) {
|
||||||
|
return {
|
||||||
|
splitPanels: new Map(),
|
||||||
|
registerSplitPanel: () => {},
|
||||||
|
unregisterSplitPanel: () => {},
|
||||||
|
updateSplitPanel: () => {},
|
||||||
|
getOverlappingSplitPanel: () => null,
|
||||||
|
getAdjustedX: (x) => x,
|
||||||
|
leftWidthPercent: 30,
|
||||||
|
containerRect: null,
|
||||||
|
dividerX: 0,
|
||||||
|
isDragging: false,
|
||||||
|
splitPanelId: null,
|
||||||
|
updateLeftWidth: () => {},
|
||||||
|
updateContainerRect: () => {},
|
||||||
|
updateDragging: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트의 조정된 위치를 계산하는 훅
|
||||||
|
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 X 좌표가 조정됨
|
||||||
|
*
|
||||||
|
* @param componentX - 컴포넌트의 X 좌표 (px)
|
||||||
|
* @param componentY - 컴포넌트의 Y 좌표 (px)
|
||||||
|
* @param componentWidth - 컴포넌트 너비 (px)
|
||||||
|
* @param componentHeight - 컴포넌트 높이 (px)
|
||||||
|
* @returns 조정된 X 좌표와 관련 정보
|
||||||
|
*/
|
||||||
|
export const useAdjustedComponentPosition = (
|
||||||
|
componentX: number,
|
||||||
|
componentY: number,
|
||||||
|
componentWidth: number,
|
||||||
|
componentHeight: number,
|
||||||
|
) => {
|
||||||
|
const context = useSplitPanel();
|
||||||
|
|
||||||
|
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||||
|
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
adjustedX,
|
||||||
|
isInSplitPanel: !!overlap,
|
||||||
|
isInLeftPanel: overlap?.isInLeftPanel ?? false,
|
||||||
|
isDragging: overlap?.panel.isDragging ?? false,
|
||||||
|
panelId: overlap?.panelId ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버튼 등 외부 컴포넌트에서 분할 패널 좌측 영역 내 위치를 계산하는 훅 (레거시 호환)
|
||||||
|
*/
|
||||||
|
export const useAdjustedPosition = (originalXPercent: number) => {
|
||||||
|
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||||
|
|
||||||
|
const isInLeftPanel = originalXPercent <= leftWidthPercent;
|
||||||
|
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
|
||||||
|
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
adjustedXPercent,
|
||||||
|
adjustedXPx,
|
||||||
|
isInLeftPanel,
|
||||||
|
isDragging,
|
||||||
|
dividerX,
|
||||||
|
containerRect,
|
||||||
|
leftWidthPercent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버튼이 좌측 패널 위에 배치되었을 때, 드래그에 따라 위치가 조정되는 스타일을 반환하는 훅 (레거시 호환)
|
||||||
|
*/
|
||||||
|
export const useSplitPanelAwarePosition = (
|
||||||
|
initialLeftPercent: number,
|
||||||
|
options?: {
|
||||||
|
followDivider?: boolean;
|
||||||
|
offset?: number;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||||
|
const { followDivider = false, offset = 0 } = options || {};
|
||||||
|
|
||||||
|
if (followDivider) {
|
||||||
|
return {
|
||||||
|
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
|
||||||
|
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${adjustedLeft}%`,
|
||||||
|
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SplitPanelResizeContext;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { ComponentRendererProps } from "../../types";
|
import { ComponentRendererProps } from "../../types";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -34,8 +34,9 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useSplitPanel } from "./SplitPanelContext";
|
||||||
|
|
||||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
|
|
@ -73,12 +74,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 엔티티 조인 컬럼명 변환 헬퍼
|
||||||
|
// "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근
|
||||||
|
const getEntityJoinValue = useCallback(
|
||||||
|
(item: any, columnName: string, entityColumnMap?: Record<string, string>): any => {
|
||||||
|
// 직접 매칭 시도
|
||||||
|
if (item[columnName] !== undefined) {
|
||||||
|
return item[columnName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name)
|
||||||
|
if (columnName.includes(".")) {
|
||||||
|
const [tableName, fieldName] = columnName.split(".");
|
||||||
|
|
||||||
|
// 🔍 엔티티 조인 컬럼 값 추출
|
||||||
|
// 예: item_info.item_name, item_info.standard, item_info.unit
|
||||||
|
|
||||||
|
// 1️⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등)
|
||||||
|
const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id");
|
||||||
|
|
||||||
|
// 2️⃣ 정확한 키 매핑 시도: 소스컬럼_필드명
|
||||||
|
// 예: item_code_item_name, item_code_standard, item_code_unit
|
||||||
|
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||||||
|
if (item[exactKey] !== undefined) {
|
||||||
|
return item[exactKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 2-1️⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우)
|
||||||
|
// 예: item_info.item_name → item_id_item_name
|
||||||
|
const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
|
||||||
|
if (item[idPatternKey] !== undefined) {
|
||||||
|
return item[idPatternKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용)
|
||||||
|
// 예: item_code_name (item_name의 별칭)
|
||||||
|
if (fieldName === "item_name" || fieldName === "name") {
|
||||||
|
const aliasKey = `${inferredSourceColumn}_name`;
|
||||||
|
if (item[aliasKey] !== undefined) {
|
||||||
|
return item[aliasKey];
|
||||||
|
}
|
||||||
|
// 🆕 item_id_name 패턴도 시도
|
||||||
|
const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`;
|
||||||
|
if (item[idAliasKey] !== undefined) {
|
||||||
|
return item[idAliasKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우)
|
||||||
|
if (entityColumnMap && entityColumnMap[tableName]) {
|
||||||
|
const sourceColumn = entityColumnMap[tableName];
|
||||||
|
const joinedColumnName = `${sourceColumn}_${fieldName}`;
|
||||||
|
if (item[joinedColumnName] !== undefined) {
|
||||||
|
return item[joinedColumnName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5️⃣ 테이블명_컬럼명 형식으로 시도
|
||||||
|
const underscoreKey = `${tableName}_${fieldName}`;
|
||||||
|
if (item[underscoreKey] !== undefined) {
|
||||||
|
return item[underscoreKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// TableOptions Context
|
// TableOptions Context
|
||||||
const { registerTable, unregisterTable } = useTableOptions();
|
const { registerTable, unregisterTable } = useTableOptions();
|
||||||
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
|
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
|
||||||
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
|
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
|
||||||
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
|
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
|
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
|
||||||
|
const [leftGroupSumConfig, setLeftGroupSumConfig] = useState<GroupSumConfig | null>(null); // 🆕 그룹별 합산 설정
|
||||||
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
||||||
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||||
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
@ -125,6 +195,202 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유)
|
||||||
|
const splitPanelContext = useSplitPanel();
|
||||||
|
const {
|
||||||
|
registerSplitPanel: ctxRegisterSplitPanel,
|
||||||
|
unregisterSplitPanel: ctxUnregisterSplitPanel,
|
||||||
|
updateSplitPanel: ctxUpdateSplitPanel,
|
||||||
|
} = splitPanelContext;
|
||||||
|
const splitPanelId = `split-panel-${component.id}`;
|
||||||
|
|
||||||
|
// 디버깅: Context 연결 상태 확인
|
||||||
|
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
|
||||||
|
componentId: component.id,
|
||||||
|
splitPanelId,
|
||||||
|
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
|
||||||
|
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
|
||||||
|
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
|
||||||
|
const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel);
|
||||||
|
ctxRegisterRef.current = ctxRegisterSplitPanel;
|
||||||
|
ctxUnregisterRef.current = ctxUnregisterSplitPanel;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 컴포넌트의 위치와 크기 정보
|
||||||
|
const panelX = component.position?.x || 0;
|
||||||
|
const panelY = component.position?.y || 0;
|
||||||
|
const panelWidth = component.size?.width || component.style?.width || 800;
|
||||||
|
const panelHeight = component.size?.height || component.style?.height || 600;
|
||||||
|
|
||||||
|
const panelInfo = {
|
||||||
|
x: panelX,
|
||||||
|
y: panelY,
|
||||||
|
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
|
||||||
|
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
|
||||||
|
leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용
|
||||||
|
initialLeftWidthPercent: splitRatio,
|
||||||
|
isDragging: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
|
||||||
|
splitPanelId,
|
||||||
|
panelInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctxRegisterRef.current(splitPanelId, panelInfo);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
|
||||||
|
ctxUnregisterRef.current(splitPanelId);
|
||||||
|
};
|
||||||
|
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [splitPanelId]);
|
||||||
|
|
||||||
|
// 위치/크기 변경 시 Context 업데이트 (등록 후)
|
||||||
|
const ctxUpdateRef = useRef(ctxUpdateSplitPanel);
|
||||||
|
ctxUpdateRef.current = ctxUpdateSplitPanel;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const panelX = component.position?.x || 0;
|
||||||
|
const panelY = component.position?.y || 0;
|
||||||
|
const panelWidth = component.size?.width || component.style?.width || 800;
|
||||||
|
const panelHeight = component.size?.height || component.style?.height || 600;
|
||||||
|
|
||||||
|
ctxUpdateRef.current(splitPanelId, {
|
||||||
|
x: panelX,
|
||||||
|
y: panelY,
|
||||||
|
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
|
||||||
|
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
splitPanelId,
|
||||||
|
component.position?.x,
|
||||||
|
component.position?.y,
|
||||||
|
component.size?.width,
|
||||||
|
component.size?.height,
|
||||||
|
component.style?.width,
|
||||||
|
component.style?.height,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// leftWidth 변경 시 Context 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth });
|
||||||
|
}, [leftWidth, splitPanelId]);
|
||||||
|
|
||||||
|
// 드래그 상태 변경 시 Context 업데이트
|
||||||
|
// 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지
|
||||||
|
const prevIsDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasJustDragging = prevIsDraggingRef.current && !isDragging;
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
// 드래그 시작 시: 현재 비율을 초기 비율로 저장
|
||||||
|
ctxUpdateRef.current(splitPanelId, {
|
||||||
|
isDragging: true,
|
||||||
|
initialLeftWidthPercent: leftWidth,
|
||||||
|
});
|
||||||
|
} else if (wasJustDragging) {
|
||||||
|
// 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정)
|
||||||
|
ctxUpdateRef.current(splitPanelId, {
|
||||||
|
isDragging: false,
|
||||||
|
initialLeftWidthPercent: leftWidth,
|
||||||
|
});
|
||||||
|
console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", {
|
||||||
|
splitPanelId,
|
||||||
|
finalLeftWidthPercent: leftWidth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prevIsDraggingRef.current = isDragging;
|
||||||
|
}, [isDragging, splitPanelId, leftWidth]);
|
||||||
|
|
||||||
|
// 🆕 그룹별 합산된 데이터 계산
|
||||||
|
const summedLeftData = useMemo(() => {
|
||||||
|
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
|
||||||
|
|
||||||
|
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
|
||||||
|
if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) {
|
||||||
|
console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
|
||||||
|
return leftData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupByColumn = leftGroupSumConfig.groupByColumn;
|
||||||
|
const groupMap = new Map<string, any>();
|
||||||
|
|
||||||
|
// 조인 컬럼인지 확인하고 실제 키 추론
|
||||||
|
const getActualKey = (columnName: string, item: any): string => {
|
||||||
|
if (columnName.includes(".")) {
|
||||||
|
const [refTable, fieldName] = columnName.split(".");
|
||||||
|
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||||
|
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||||||
|
console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined });
|
||||||
|
if (item[exactKey] !== undefined) return exactKey;
|
||||||
|
if (fieldName === "item_name" || fieldName === "name") {
|
||||||
|
const aliasKey = `${inferredSourceColumn}_name`;
|
||||||
|
if (item[aliasKey] !== undefined) return aliasKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return columnName;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 숫자 타입인지 확인하는 함수
|
||||||
|
const isNumericValue = (value: any): boolean => {
|
||||||
|
if (value === null || value === undefined || value === "") return false;
|
||||||
|
const num = parseFloat(String(value));
|
||||||
|
return !isNaN(num) && isFinite(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹핑 수행
|
||||||
|
leftData.forEach((item) => {
|
||||||
|
const actualKey = getActualKey(groupByColumn, item);
|
||||||
|
const groupValue = String(item[actualKey] || item[groupByColumn] || "");
|
||||||
|
|
||||||
|
// 원본 ID 추출 (id, ID, 또는 첫 번째 값)
|
||||||
|
const originalId = item.id || item.ID || Object.values(item)[0];
|
||||||
|
|
||||||
|
if (!groupMap.has(groupValue)) {
|
||||||
|
// 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열
|
||||||
|
groupMap.set(groupValue, {
|
||||||
|
...item,
|
||||||
|
_groupCount: 1,
|
||||||
|
_originalIds: [originalId],
|
||||||
|
_originalItems: [item], // 🆕 원본 데이터 전체 저장
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existing = groupMap.get(groupValue);
|
||||||
|
existing._groupCount += 1;
|
||||||
|
existing._originalIds.push(originalId);
|
||||||
|
existing._originalItems.push(item); // 🆕 원본 데이터 추가
|
||||||
|
|
||||||
|
// 모든 키에 대해 숫자면 합산
|
||||||
|
Object.keys(item).forEach((key) => {
|
||||||
|
const value = item[key];
|
||||||
|
if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) {
|
||||||
|
const numValue = parseFloat(String(value));
|
||||||
|
const existingValue = parseFloat(String(existing[key] || 0));
|
||||||
|
existing[key] = existingValue + numValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
groupMap.set(groupValue, existing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = Array.from(groupMap.values());
|
||||||
|
console.log("🔗 [분할패널] 그룹별 합산 결과:", {
|
||||||
|
원본개수: leftData.length,
|
||||||
|
그룹개수: result.length,
|
||||||
|
그룹기준: groupByColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [leftData, leftGroupSumConfig]);
|
||||||
|
|
||||||
// 컴포넌트 스타일
|
// 컴포넌트 스타일
|
||||||
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
||||||
const getHeightValue = () => {
|
const getHeightValue = () => {
|
||||||
|
|
@ -433,14 +699,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
||||||
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
||||||
|
|
||||||
|
// 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환
|
||||||
|
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
||||||
|
const additionalJoinColumns: Array<{
|
||||||
|
sourceTable: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
referenceTable: string;
|
||||||
|
joinAlias: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등)
|
||||||
|
const sourceColumnMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
configuredColumns.forEach((col: any) => {
|
||||||
|
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||||
|
if (colName && colName.includes(".")) {
|
||||||
|
const [refTable, refColumn] = colName.split(".");
|
||||||
|
// 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id)
|
||||||
|
// 기본: _info → _code, 백업: _info → _id
|
||||||
|
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||||
|
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
|
||||||
|
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
|
||||||
|
const inferredSourceColumn = primarySourceColumn;
|
||||||
|
|
||||||
|
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
|
||||||
|
const existingJoin = additionalJoinColumns.find(
|
||||||
|
(j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingJoin) {
|
||||||
|
// 새로운 조인 추가 (첫 번째 컬럼)
|
||||||
|
additionalJoinColumns.push({
|
||||||
|
sourceTable: leftTableName,
|
||||||
|
sourceColumn: inferredSourceColumn,
|
||||||
|
referenceTable: refTable,
|
||||||
|
joinAlias: `${inferredSourceColumn}_${refColumn}`,
|
||||||
|
});
|
||||||
|
sourceColumnMap[refTable] = inferredSourceColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등)
|
||||||
|
// 단, 첫 번째 컬럼과 다른 경우만
|
||||||
|
const existingAliases = additionalJoinColumns
|
||||||
|
.filter((j) => j.referenceTable === refTable)
|
||||||
|
.map((j) => j.joinAlias);
|
||||||
|
const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`;
|
||||||
|
|
||||||
|
if (!existingAliases.includes(newAlias)) {
|
||||||
|
additionalJoinColumns.push({
|
||||||
|
sourceTable: leftTableName,
|
||||||
|
sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn,
|
||||||
|
referenceTable: refTable,
|
||||||
|
joinAlias: newAlias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
|
||||||
|
console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
|
||||||
|
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 100,
|
size: 100,
|
||||||
search: filters, // 필터 조건 전달
|
search: filters, // 필터 조건 전달
|
||||||
enableEntityJoin: true, // 엔티티 조인 활성화
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
||||||
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||||
|
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔍 디버깅: API 응답 데이터의 키 확인
|
||||||
|
if (result.data && result.data.length > 0) {
|
||||||
|
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
|
||||||
|
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
|
||||||
|
}
|
||||||
|
|
||||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||||
if (leftColumn && result.data.length > 0) {
|
if (leftColumn && result.data.length > 0) {
|
||||||
|
|
@ -466,6 +799,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
componentConfig.leftPanel?.tableName,
|
componentConfig.leftPanel?.tableName,
|
||||||
|
componentConfig.leftPanel?.columns,
|
||||||
|
componentConfig.leftPanel?.dataFilter,
|
||||||
componentConfig.rightPanel?.relation?.leftColumn,
|
componentConfig.rightPanel?.relation?.leftColumn,
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
toast,
|
toast,
|
||||||
|
|
@ -502,6 +837,68 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const keys = componentConfig.rightPanel?.relation?.keys;
|
const keys = componentConfig.rightPanel?.relation?.keys;
|
||||||
const leftTable = componentConfig.leftPanel?.tableName;
|
const leftTable = componentConfig.leftPanel?.tableName;
|
||||||
|
|
||||||
|
// 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시
|
||||||
|
if (leftItem._originalItems && leftItem._originalItems.length > 0) {
|
||||||
|
console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length);
|
||||||
|
|
||||||
|
// 정렬 기준 컬럼 (복합키의 leftColumn들)
|
||||||
|
const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || [];
|
||||||
|
console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns);
|
||||||
|
|
||||||
|
// 정렬 함수
|
||||||
|
const sortByKeys = (data: any[]) => {
|
||||||
|
if (sortColumns.length === 0) return data;
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
for (const col of sortColumns) {
|
||||||
|
const aVal = String(a[col] || "");
|
||||||
|
const bVal = String(b[col] || "");
|
||||||
|
const cmp = aVal.localeCompare(bVal, "ko-KR");
|
||||||
|
if (cmp !== 0) return cmp;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우)
|
||||||
|
if (leftTable === rightTableName) {
|
||||||
|
const sortedData = sortByKeys(leftItem._originalItems);
|
||||||
|
console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length);
|
||||||
|
setRightData(sortedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 테이블인 경우: 원본 ID들로 조회
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const allResults: any[] = [];
|
||||||
|
|
||||||
|
// 각 원본 항목에 대해 조회
|
||||||
|
for (const originalItem of leftItem._originalItems) {
|
||||||
|
const searchConditions: Record<string, any> = {};
|
||||||
|
keys?.forEach((key: any) => {
|
||||||
|
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
|
||||||
|
searchConditions[key.rightColumn] = originalItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(searchConditions).length > 0) {
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
search: searchConditions,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
if (result.data) {
|
||||||
|
allResults.push(...result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬 적용
|
||||||
|
const sortedResults = sortByKeys(allResults);
|
||||||
|
console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length);
|
||||||
|
setRightData(sortedResults);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 복합키 지원
|
// 🆕 복합키 지원
|
||||||
if (keys && keys.length > 0 && leftTable) {
|
if (keys && keys.length > 0 && leftTable) {
|
||||||
// 복합키: 여러 조건으로 필터링
|
// 복합키: 여러 조건으로 필터링
|
||||||
|
|
@ -642,7 +1039,39 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const uniqueValues = new Set<string>();
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
leftData.forEach((item) => {
|
leftData.forEach((item) => {
|
||||||
const value = item[columnName];
|
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard)
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
if (columnName.includes(".")) {
|
||||||
|
// 조인 컬럼: getEntityJoinValue와 동일한 로직 적용
|
||||||
|
const [refTable, fieldName] = columnName.split(".");
|
||||||
|
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||||
|
|
||||||
|
// 정확한 키로 먼저 시도
|
||||||
|
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||||||
|
value = item[exactKey];
|
||||||
|
|
||||||
|
// 🆕 item_id 패턴 시도
|
||||||
|
if (value === undefined) {
|
||||||
|
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
|
||||||
|
value = item[idPatternKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
|
||||||
|
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
|
||||||
|
const aliasKey = `${inferredSourceColumn}_name`;
|
||||||
|
value = item[aliasKey];
|
||||||
|
// item_id_name 패턴도 시도
|
||||||
|
if (value === undefined) {
|
||||||
|
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
|
||||||
|
value = item[idAliasKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼
|
||||||
|
value = item[columnName];
|
||||||
|
}
|
||||||
|
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
// _name 필드 우선 사용 (category/entity type)
|
// _name 필드 우선 사용 (category/entity type)
|
||||||
const displayValue = item[`${columnName}_name`] || value;
|
const displayValue = item[`${columnName}_name`] || value;
|
||||||
|
|
@ -666,6 +1095,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const leftTableId = `split-panel-left-${component.id}`;
|
const leftTableId = `split-panel-left-${component.id}`;
|
||||||
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
|
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
|
||||||
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
||||||
|
|
||||||
|
// 🆕 설정에서 지정한 라벨 맵 생성
|
||||||
|
const configuredLabels: Record<string, string> = {};
|
||||||
|
configuredColumns.forEach((col: any) => {
|
||||||
|
if (typeof col === "object" && col.name && col.label) {
|
||||||
|
configuredLabels[col.name] = col.label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const displayColumns = configuredColumns
|
const displayColumns = configuredColumns
|
||||||
.map((col: any) => {
|
.map((col: any) => {
|
||||||
if (typeof col === "string") return col;
|
if (typeof col === "string") return col;
|
||||||
|
|
@ -683,7 +1121,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
tableName: leftTableName,
|
tableName: leftTableName,
|
||||||
columns: displayColumns.map((col: string) => ({
|
columns: displayColumns.map((col: string) => ({
|
||||||
columnName: col,
|
columnName: col,
|
||||||
columnLabel: leftColumnLabels[col] || col,
|
// 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명
|
||||||
|
columnLabel: configuredLabels[col] || leftColumnLabels[col] || col,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
visible: true,
|
visible: true,
|
||||||
width: 150,
|
width: 150,
|
||||||
|
|
@ -695,6 +1134,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
onColumnVisibilityChange: setLeftColumnVisibility,
|
onColumnVisibilityChange: setLeftColumnVisibility,
|
||||||
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
|
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
|
||||||
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
|
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
|
||||||
|
onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => unregisterTable(leftTableId);
|
return () => unregisterTable(leftTableId);
|
||||||
|
|
@ -1651,16 +2091,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
|
// 🆕 그룹별 합산된 데이터 사용
|
||||||
|
const dataSource = summedLeftData;
|
||||||
|
console.log(
|
||||||
|
"🔍 [테이블모드 렌더링] dataSource 개수:",
|
||||||
|
dataSource.length,
|
||||||
|
"leftGroupSumConfig:",
|
||||||
|
leftGroupSumConfig,
|
||||||
|
);
|
||||||
|
|
||||||
// 🔧 로컬 검색 필터 적용
|
// 🔧 로컬 검색 필터 적용
|
||||||
const filteredData = leftSearchQuery
|
const filteredData = leftSearchQuery
|
||||||
? leftData.filter((item) => {
|
? dataSource.filter((item) => {
|
||||||
const searchLower = leftSearchQuery.toLowerCase();
|
const searchLower = leftSearchQuery.toLowerCase();
|
||||||
return Object.entries(item).some(([key, value]) => {
|
return Object.entries(item).some(([key, value]) => {
|
||||||
if (value === null || value === undefined) return false;
|
if (value === null || value === undefined) return false;
|
||||||
return String(value).toLowerCase().includes(searchLower);
|
return String(value).toLowerCase().includes(searchLower);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
: leftData;
|
: dataSource;
|
||||||
|
|
||||||
// 🔧 가시성 처리된 컬럼 사용
|
// 🔧 가시성 처리된 컬럼 사용
|
||||||
const columnsToShow =
|
const columnsToShow =
|
||||||
|
|
@ -1737,7 +2186,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
item[col.name],
|
getEntityJoinValue(item, col.name),
|
||||||
leftCategoryMappings,
|
leftCategoryMappings,
|
||||||
col.format,
|
col.format,
|
||||||
)}
|
)}
|
||||||
|
|
@ -1796,7 +2245,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)}
|
{formatCellValue(
|
||||||
|
col.name,
|
||||||
|
getEntityJoinValue(item, col.name),
|
||||||
|
leftCategoryMappings,
|
||||||
|
col.format,
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1851,16 +2305,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
|
// 🆕 그룹별 합산된 데이터 사용
|
||||||
|
const dataToDisplay = summedLeftData;
|
||||||
|
console.log(
|
||||||
|
"🔍 [렌더링] dataToDisplay 개수:",
|
||||||
|
dataToDisplay.length,
|
||||||
|
"leftGroupSumConfig:",
|
||||||
|
leftGroupSumConfig,
|
||||||
|
);
|
||||||
|
|
||||||
// 검색 필터링 (클라이언트 사이드)
|
// 검색 필터링 (클라이언트 사이드)
|
||||||
const filteredLeftData = leftSearchQuery
|
const filteredLeftData = leftSearchQuery
|
||||||
? leftData.filter((item) => {
|
? dataToDisplay.filter((item) => {
|
||||||
const searchLower = leftSearchQuery.toLowerCase();
|
const searchLower = leftSearchQuery.toLowerCase();
|
||||||
return Object.entries(item).some(([key, value]) => {
|
return Object.entries(item).some(([key, value]) => {
|
||||||
if (value === null || value === undefined) return false;
|
if (value === null || value === undefined) return false;
|
||||||
return String(value).toLowerCase().includes(searchLower);
|
return String(value).toLowerCase().includes(searchLower);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
: leftData;
|
: dataToDisplay;
|
||||||
|
|
||||||
// 재귀 렌더링 함수
|
// 재귀 렌더링 함수
|
||||||
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
||||||
|
|
@ -2108,23 +2571,53 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (isTableMode) {
|
if (isTableMode) {
|
||||||
// 테이블 모드 렌더링
|
// 테이블 모드 렌더링
|
||||||
const displayColumns = componentConfig.rightPanel?.columns || [];
|
const displayColumns = componentConfig.rightPanel?.columns || [];
|
||||||
const columnsToShow =
|
|
||||||
displayColumns.length > 0
|
// 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시
|
||||||
? displayColumns.map((col) => ({
|
const relationKeys = componentConfig.rightPanel?.relation?.keys || [];
|
||||||
...col,
|
const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean);
|
||||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
const isGroupedMode = selectedLeftItem?._originalItems?.length > 0;
|
||||||
format: col.format, // 🆕 포맷 설정 유지
|
|
||||||
}))
|
let columnsToShow: any[] = [];
|
||||||
: Object.keys(filteredData[0] || {})
|
|
||||||
.filter((key) => shouldShowField(key))
|
if (displayColumns.length > 0) {
|
||||||
.slice(0, 5)
|
// 설정된 컬럼 사용
|
||||||
.map((key) => ({
|
columnsToShow = displayColumns.map((col) => ({
|
||||||
name: key,
|
...col,
|
||||||
label: rightColumnLabels[key] || key,
|
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||||
width: 150,
|
format: col.format,
|
||||||
align: "left" as const,
|
}));
|
||||||
format: undefined, // 🆕 기본값
|
|
||||||
}));
|
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
|
||||||
|
if (isGroupedMode && keyColumns.length > 0) {
|
||||||
|
const existingColNames = columnsToShow.map((c) => c.name);
|
||||||
|
const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k));
|
||||||
|
|
||||||
|
if (missingKeyColumns.length > 0) {
|
||||||
|
const keyColsToAdd = missingKeyColumns.map((colName: string) => ({
|
||||||
|
name: colName,
|
||||||
|
label: rightColumnLabels[colName] || colName,
|
||||||
|
width: 120,
|
||||||
|
align: "left" as const,
|
||||||
|
format: undefined,
|
||||||
|
_isKeyColumn: true, // 구분용 플래그
|
||||||
|
}));
|
||||||
|
columnsToShow = [...keyColsToAdd, ...columnsToShow];
|
||||||
|
console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 기본 컬럼 자동 생성
|
||||||
|
columnsToShow = Object.keys(filteredData[0] || {})
|
||||||
|
.filter((key) => shouldShowField(key))
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((key) => ({
|
||||||
|
name: key,
|
||||||
|
label: rightColumnLabels[key] || key,
|
||||||
|
width: 150,
|
||||||
|
align: "left" as const,
|
||||||
|
format: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|
@ -2150,11 +2643,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{!isDesignMode && (
|
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */}
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
{!isDesignMode &&
|
||||||
작업
|
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||||
</th>
|
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||||
)}
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
작업
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
|
@ -2169,43 +2665,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)}
|
{formatCellValue(
|
||||||
|
col.name,
|
||||||
|
getEntityJoinValue(item, col.name),
|
||||||
|
rightCategoryMappings,
|
||||||
|
col.format,
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{!isDesignMode && (
|
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}
|
||||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
{!isDesignMode &&
|
||||||
<div className="flex justify-end gap-1">
|
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||||
<Button
|
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
||||||
variant={
|
<div className="flex justify-end gap-1">
|
||||||
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
|
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||||
}
|
<Button
|
||||||
size="sm"
|
variant={
|
||||||
onClick={(e) => {
|
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
|
||||||
e.stopPropagation();
|
}
|
||||||
handleEditClick("right", item);
|
size="sm"
|
||||||
}}
|
onClick={(e) => {
|
||||||
className="h-7"
|
e.stopPropagation();
|
||||||
>
|
handleEditClick("right", item);
|
||||||
<Pencil className="mr-1 h-3 w-3" />
|
}}
|
||||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
className="h-7"
|
||||||
</Button>
|
>
|
||||||
)}
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
<button
|
</Button>
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||||
handleDeleteClick("right", item);
|
<button
|
||||||
}}
|
onClick={(e) => {
|
||||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
e.stopPropagation();
|
||||||
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
handleDeleteClick("right", item);
|
||||||
>
|
}}
|
||||||
<Trash2 className="h-4 w-4 text-red-600" />
|
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||||
</button>
|
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||||
)}
|
>
|
||||||
</div>
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
</td>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -2240,78 +2744,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
firstValues = rightColumns
|
firstValues = rightColumns
|
||||||
.slice(0, summaryCount)
|
.slice(0, summaryCount)
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
|
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
|
||||||
let value = item[col.name];
|
const value = getEntityJoinValue(item, col.name);
|
||||||
if (value === undefined && col.name.includes(".")) {
|
|
||||||
const columnName = col.name.split(".").pop();
|
|
||||||
// 1차: 컬럼명 그대로 (예: item_number)
|
|
||||||
value = item[columnName || ""];
|
|
||||||
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
|
|
||||||
if (value === undefined) {
|
|
||||||
const parts = col.name.split(".");
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const refTable = parts[0]; // item_info
|
|
||||||
const refColumn = parts[1]; // item_number 또는 item_name
|
|
||||||
// FK 컬럼명 추론: item_info → item_id
|
|
||||||
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
|
|
||||||
|
|
||||||
// 백엔드에서 반환하는 별칭 패턴:
|
|
||||||
// 1) item_id_name (기본 referenceColumn)
|
|
||||||
// 2) item_id_item_name (추가 컬럼)
|
|
||||||
if (
|
|
||||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
|
|
||||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
|
|
||||||
) {
|
|
||||||
// 기본 참조 컬럼 (item_number, customer_code 등)
|
|
||||||
const aliasKey = fkColumn + "_name";
|
|
||||||
value = item[aliasKey];
|
|
||||||
} else {
|
|
||||||
// 추가 컬럼 (item_name, customer_name 등)
|
|
||||||
const aliasKey = `${fkColumn}_${refColumn}`;
|
|
||||||
value = item[aliasKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [col.name, value, col.label] as [string, any, string];
|
return [col.name, value, col.label] as [string, any, string];
|
||||||
})
|
})
|
||||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||||
|
|
||||||
allValues = rightColumns
|
allValues = rightColumns
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
// 🆕 엔티티 조인 컬럼 처리
|
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
|
||||||
let value = item[col.name];
|
const value = getEntityJoinValue(item, col.name);
|
||||||
if (value === undefined && col.name.includes(".")) {
|
|
||||||
const columnName = col.name.split(".").pop();
|
|
||||||
// 1차: 컬럼명 그대로
|
|
||||||
value = item[columnName || ""];
|
|
||||||
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
|
|
||||||
if (value === undefined) {
|
|
||||||
const parts = col.name.split(".");
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const refTable = parts[0]; // item_info
|
|
||||||
const refColumn = parts[1]; // item_number 또는 item_name
|
|
||||||
// FK 컬럼명 추론: item_info → item_id
|
|
||||||
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
|
|
||||||
|
|
||||||
// 백엔드에서 반환하는 별칭 패턴:
|
|
||||||
// 1) item_id_name (기본 referenceColumn)
|
|
||||||
// 2) item_id_item_name (추가 컬럼)
|
|
||||||
if (
|
|
||||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
|
|
||||||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
|
|
||||||
) {
|
|
||||||
// 기본 참조 컬럼
|
|
||||||
const aliasKey = fkColumn + "_name";
|
|
||||||
value = item[aliasKey];
|
|
||||||
} else {
|
|
||||||
// 추가 컬럼
|
|
||||||
const aliasKey = `${fkColumn}_${refColumn}`;
|
|
||||||
value = item[aliasKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [col.name, value, col.label] as [string, any, string];
|
return [col.name, value, col.label] as [string, any, string];
|
||||||
})
|
})
|
||||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,13 @@ export type { SplitPanelLayoutConfig } from "./types";
|
||||||
// 컴포넌트 내보내기
|
// 컴포넌트 내보내기
|
||||||
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||||
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
||||||
|
|
||||||
|
// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용)
|
||||||
|
export {
|
||||||
|
SplitPanelProvider,
|
||||||
|
useSplitPanel,
|
||||||
|
useAdjustedPosition,
|
||||||
|
useSplitPanelAwarePosition,
|
||||||
|
useAdjustedComponentPosition,
|
||||||
|
} from "./SplitPanelContext";
|
||||||
|
export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext";
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -224,6 +224,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
isInModal: _isInModal,
|
isInModal: _isInModal,
|
||||||
isPreview: _isPreview,
|
isPreview: _isPreview,
|
||||||
originalData: _originalData,
|
originalData: _originalData,
|
||||||
|
_originalData: __originalData,
|
||||||
|
_initialData: __initialData,
|
||||||
|
_groupedData: __groupedData,
|
||||||
allComponents: _allComponents,
|
allComponents: _allComponents,
|
||||||
selectedRows: _selectedRows,
|
selectedRows: _selectedRows,
|
||||||
selectedRowsData: _selectedRowsData,
|
selectedRowsData: _selectedRowsData,
|
||||||
|
|
|
||||||
|
|
@ -126,11 +126,13 @@ export function UniversalFormModalComponent({
|
||||||
initialData: propInitialData,
|
initialData: propInitialData,
|
||||||
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
||||||
_initialData,
|
_initialData,
|
||||||
|
_originalData,
|
||||||
|
_groupedData,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
onChange,
|
onChange,
|
||||||
...restProps // 나머지 props는 DOM에 전달하지 않음
|
...restProps // 나머지 props는 DOM에 전달하지 않음
|
||||||
}: UniversalFormModalComponentProps & { _initialData?: any }) {
|
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
||||||
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
||||||
const initialData = propInitialData || _initialData;
|
const initialData = propInitialData || _initialData;
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
|
|
@ -198,29 +200,49 @@ export function UniversalFormModalComponent({
|
||||||
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
||||||
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
|
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
||||||
|
const lastInitializedId = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
// 초기화 - 최초 마운트 시에만 실행
|
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이미 초기화되었으면 스킵
|
// initialData에서 ID 값 추출 (id, ID, objid 등)
|
||||||
if (hasInitialized.current) {
|
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
|
||||||
|
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
|
||||||
|
|
||||||
|
// 이미 초기화되었고, ID가 동일하면 스킵
|
||||||
|
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
|
||||||
|
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
|
||||||
|
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
|
||||||
|
prevId: lastInitializedId.current,
|
||||||
|
newId: currentIdString,
|
||||||
|
initialData: initialData,
|
||||||
|
});
|
||||||
|
// 채번 플래그 초기화 (새 항목이므로)
|
||||||
|
numberingGeneratedRef.current = false;
|
||||||
|
isGeneratingRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
|
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
|
||||||
if (initialData && Object.keys(initialData).length > 0) {
|
if (initialData && Object.keys(initialData).length > 0) {
|
||||||
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
||||||
|
lastInitializedId.current = currentIdString;
|
||||||
|
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasInitialized.current = true;
|
hasInitialized.current = true;
|
||||||
initializeForm();
|
initializeForm();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
|
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
|
||||||
|
|
||||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
||||||
|
|
||||||
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
|
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
|
||||||
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
@ -228,7 +250,7 @@ export function UniversalFormModalComponent({
|
||||||
// 컴포넌트 unmount 시 채번 플래그 초기화
|
// 컴포넌트 unmount 시 채번 플래그 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
|
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
|
||||||
numberingGeneratedRef.current = false;
|
numberingGeneratedRef.current = false;
|
||||||
isGeneratingRef.current = false;
|
isGeneratingRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
@ -239,7 +261,7 @@ export function UniversalFormModalComponent({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeFormSave = (event: Event) => {
|
const handleBeforeFormSave = (event: Event) => {
|
||||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||||
|
|
||||||
// 설정에 정의된 필드 columnName 목록 수집
|
// 설정에 정의된 필드 columnName 목록 수집
|
||||||
const configuredFields = new Set<string>();
|
const configuredFields = new Set<string>();
|
||||||
config.sections.forEach((section) => {
|
config.sections.forEach((section) => {
|
||||||
|
|
@ -249,10 +271,10 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||||
|
|
||||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||||
|
|
@ -260,7 +282,7 @@ export function UniversalFormModalComponent({
|
||||||
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||||
const isConfiguredField = configuredFields.has(key);
|
const isConfiguredField = configuredFields.has(key);
|
||||||
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||||
|
|
||||||
if (isConfiguredField || isNumberingRuleId) {
|
if (isConfiguredField || isNumberingRuleId) {
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
event.detail.formData[key] = value;
|
event.detail.formData[key] = value;
|
||||||
|
|
@ -268,7 +290,7 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 반복 섹션 데이터도 병합 (필요한 경우)
|
// 반복 섹션 데이터도 병합 (필요한 경우)
|
||||||
if (Object.keys(repeatSections).length > 0) {
|
if (Object.keys(repeatSections).length > 0) {
|
||||||
for (const [sectionId, items] of Object.entries(repeatSections)) {
|
for (const [sectionId, items] of Object.entries(repeatSections)) {
|
||||||
|
|
@ -278,9 +300,9 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
};
|
};
|
||||||
|
|
@ -314,11 +336,18 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 폼 초기화
|
// 폼 초기화
|
||||||
const initializeForm = useCallback(async () => {
|
const initializeForm = useCallback(async () => {
|
||||||
console.log('[initializeForm] 시작');
|
console.log("[initializeForm] 시작");
|
||||||
|
|
||||||
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
||||||
const effectiveInitialData = capturedInitialData.current || initialData;
|
const effectiveInitialData = capturedInitialData.current || initialData;
|
||||||
|
|
||||||
|
console.log("[initializeForm] 초기 데이터:", {
|
||||||
|
capturedInitialData: capturedInitialData.current,
|
||||||
|
initialData: initialData,
|
||||||
|
effectiveInitialData: effectiveInitialData,
|
||||||
|
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
const newFormData: FormDataState = {};
|
const newFormData: FormDataState = {};
|
||||||
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||||
const newCollapsed = new Set<string>();
|
const newCollapsed = new Set<string>();
|
||||||
|
|
@ -366,9 +395,9 @@ export function UniversalFormModalComponent({
|
||||||
setOriginalData(effectiveInitialData || {});
|
setOriginalData(effectiveInitialData || {});
|
||||||
|
|
||||||
// 채번규칙 자동 생성
|
// 채번규칙 자동 생성
|
||||||
console.log('[initializeForm] generateNumberingValues 호출');
|
console.log("[initializeForm] generateNumberingValues 호출");
|
||||||
await generateNumberingValues(newFormData);
|
await generateNumberingValues(newFormData);
|
||||||
console.log('[initializeForm] 완료');
|
console.log("[initializeForm] 완료");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||||
|
|
||||||
|
|
@ -389,23 +418,23 @@ export function UniversalFormModalComponent({
|
||||||
// 채번규칙 자동 생성 (중복 호출 방지)
|
// 채번규칙 자동 생성 (중복 호출 방지)
|
||||||
const numberingGeneratedRef = useRef(false);
|
const numberingGeneratedRef = useRef(false);
|
||||||
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
|
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
|
||||||
|
|
||||||
const generateNumberingValues = useCallback(
|
const generateNumberingValues = useCallback(
|
||||||
async (currentFormData: FormDataState) => {
|
async (currentFormData: FormDataState) => {
|
||||||
// 이미 생성되었거나 진행 중이면 스킵
|
// 이미 생성되었거나 진행 중이면 스킵
|
||||||
if (numberingGeneratedRef.current) {
|
if (numberingGeneratedRef.current) {
|
||||||
console.log('[채번] 이미 생성됨 - 스킵');
|
console.log("[채번] 이미 생성됨 - 스킵");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGeneratingRef.current) {
|
if (isGeneratingRef.current) {
|
||||||
console.log('[채번] 생성 진행 중 - 스킵');
|
console.log("[채번] 생성 진행 중 - 스킵");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isGeneratingRef.current = true; // 진행 중 표시
|
isGeneratingRef.current = true; // 진행 중 표시
|
||||||
console.log('[채번] 미리보기 생성 시작');
|
console.log("[채번] 생성 시작");
|
||||||
|
|
||||||
const updatedData = { ...currentFormData };
|
const updatedData = { ...currentFormData };
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
|
|
@ -425,21 +454,23 @@ export function UniversalFormModalComponent({
|
||||||
const response = await previewNumberingCode(field.numberingRule.ruleId);
|
const response = await previewNumberingCode(field.numberingRule.ruleId);
|
||||||
if (response.success && response.data?.generatedCode) {
|
if (response.success && response.data?.generatedCode) {
|
||||||
updatedData[field.columnName] = response.data.generatedCode;
|
updatedData[field.columnName] = response.data.generatedCode;
|
||||||
|
|
||||||
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
|
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
|
||||||
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
||||||
updatedData[ruleIdKey] = field.numberingRule.ruleId;
|
updatedData[ruleIdKey] = field.numberingRule.ruleId;
|
||||||
|
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
numberingGeneratedRef.current = true; // 생성 완료 표시
|
numberingGeneratedRef.current = true; // 생성 완료 표시
|
||||||
console.log(`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`);
|
console.log(
|
||||||
|
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
|
||||||
|
);
|
||||||
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
|
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
|
||||||
|
|
||||||
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
|
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange({
|
onChange({
|
||||||
...updatedData,
|
...updatedData,
|
||||||
[ruleIdKey]: field.numberingRule.ruleId
|
[ruleIdKey]: field.numberingRule.ruleId,
|
||||||
});
|
});
|
||||||
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
|
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
|
||||||
}
|
}
|
||||||
|
|
@ -452,7 +483,7 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
isGeneratingRef.current = false; // 진행 완료
|
isGeneratingRef.current = false; // 진행 완료
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
setFormData(updatedData);
|
setFormData(updatedData);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -379,10 +379,41 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
||||||
*/
|
*/
|
||||||
|
private static saveCallCount = 0; // 🆕 호출 횟수 추적
|
||||||
|
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
|
||||||
|
|
||||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
this.saveCallCount++;
|
||||||
|
const callId = this.saveCallCount;
|
||||||
|
|
||||||
const { formData, originalData, tableName, screenId, onSave } = context;
|
const { formData, originalData, tableName, screenId, onSave } = context;
|
||||||
|
|
||||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
|
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
||||||
|
const formDataHash = JSON.stringify(Object.keys(formData).sort());
|
||||||
|
const lockKey = `${screenId}-${tableName}-${formDataHash}`;
|
||||||
|
const lastCallTime = this.saveLock.get(lockKey) || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = now - lastCallTime;
|
||||||
|
|
||||||
|
console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 });
|
||||||
|
|
||||||
|
if (timeDiff < 2000) {
|
||||||
|
console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, {
|
||||||
|
lockKey: lockKey.slice(0, 50),
|
||||||
|
timeDiff,
|
||||||
|
});
|
||||||
|
return true; // 중복 호출은 성공으로 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveLock.set(lockKey, now);
|
||||||
|
|
||||||
|
console.log(`💾 [handleSave #${callId}] 저장 시작:`, {
|
||||||
|
callId,
|
||||||
|
formDataKeys: Object.keys(formData),
|
||||||
|
tableName,
|
||||||
|
screenId,
|
||||||
|
hasOnSave: !!onSave,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
|
|
@ -681,13 +712,52 @@ export class ButtonActionExecutor {
|
||||||
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
|
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
|
||||||
|
|
||||||
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
|
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
|
||||||
const splitPanelData = context.splitPanelParentData || {};
|
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 병합해야 함
|
||||||
if (Object.keys(splitPanelData).length > 0) {
|
// 모든 필드를 병합하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
|
||||||
console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData);
|
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
|
||||||
|
const rawSplitPanelData = context.splitPanelParentData || {};
|
||||||
|
|
||||||
|
// INSERT 모드에서는 연결에 필요한 필드만 추출
|
||||||
|
const cleanedSplitPanelData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 필수 연결 필드: company_code (멀티테넌시)
|
||||||
|
if (rawSplitPanelData.company_code) {
|
||||||
|
cleanedSplitPanelData.company_code = rawSplitPanelData.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 필드 패턴으로 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
|
||||||
|
const linkFieldPatterns = ["_code", "_id"];
|
||||||
|
const excludeFields = [
|
||||||
|
"id",
|
||||||
|
"company_code",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(rawSplitPanelData)) {
|
||||||
|
if (excludeFields.includes(key)) continue;
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
|
||||||
|
// 연결 필드 패턴 확인
|
||||||
|
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||||
|
if (isLinkField) {
|
||||||
|
cleanedSplitPanelData[key] = value;
|
||||||
|
console.log(`🔗 [handleSave] INSERT 모드 - 연결 필드만 병합: ${key} = ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(rawSplitPanelData).length > 0) {
|
||||||
|
console.log("🧹 [handleSave] 원본 분할 패널 부모 데이터:", Object.keys(rawSplitPanelData));
|
||||||
|
console.log("🧹 [handleSave] 정리된 분할 패널 부모 데이터 (연결 필드만):", cleanedSplitPanelData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataWithUserInfo = {
|
const dataWithUserInfo = {
|
||||||
...splitPanelData, // 분할 패널 부모 데이터 먼저 적용
|
...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용
|
||||||
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
|
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
|
||||||
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||||
created_by: writerValue, // created_by는 항상 로그인한 사람
|
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||||
|
|
@ -695,6 +765,12 @@ export class ButtonActionExecutor {
|
||||||
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔧 formData에서도 id 제거 (신규 INSERT이므로)
|
||||||
|
if ("id" in dataWithUserInfo && !formData.id) {
|
||||||
|
console.log("🗑️ [handleSave] INSERT 모드 - dataWithUserInfo에서 id 제거:", dataWithUserInfo.id);
|
||||||
|
delete dataWithUserInfo.id;
|
||||||
|
}
|
||||||
|
|
||||||
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
|
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
|
||||||
for (const key of Object.keys(dataWithUserInfo)) {
|
for (const key of Object.keys(dataWithUserInfo)) {
|
||||||
if (key.endsWith("_numberingRuleId")) {
|
if (key.endsWith("_numberingRuleId")) {
|
||||||
|
|
@ -762,6 +838,107 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터)
|
||||||
|
// formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기
|
||||||
|
console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData));
|
||||||
|
console.log("🔎 [handleSave] formData 전체:", context.formData);
|
||||||
|
|
||||||
|
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
|
||||||
|
console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, {
|
||||||
|
type: typeof fieldValue,
|
||||||
|
isArray: Array.isArray(fieldValue),
|
||||||
|
valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue,
|
||||||
|
});
|
||||||
|
// JSON 문자열인 경우 파싱
|
||||||
|
let parsedData = fieldValue;
|
||||||
|
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(fieldValue);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리
|
||||||
|
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
|
||||||
|
|
||||||
|
const firstItem = parsedData[0];
|
||||||
|
const repeaterTargetTable = firstItem?._targetTable;
|
||||||
|
|
||||||
|
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
|
||||||
|
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
|
||||||
|
|
||||||
|
console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey} → ${repeaterTargetTable}`, {
|
||||||
|
itemCount: parsedData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of parsedData) {
|
||||||
|
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||||||
|
|
||||||
|
const {
|
||||||
|
_targetTable: _,
|
||||||
|
_isNewItem,
|
||||||
|
_existingRecord: __,
|
||||||
|
_originalItemIds: ___,
|
||||||
|
_deletedItemIds: ____,
|
||||||
|
...dataToSave
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
// 🆕 빈 id 필드 제거 (새 항목인 경우)
|
||||||
|
if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) {
|
||||||
|
delete dataToSave.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정보 추가
|
||||||
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
|
...dataToSave,
|
||||||
|
created_by: context.userId,
|
||||||
|
updated_by: context.userId,
|
||||||
|
company_code: context.companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우
|
||||||
|
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
|
||||||
|
|
||||||
|
if (isNewRecord) {
|
||||||
|
// INSERT (새 항목)
|
||||||
|
// id 필드 완전히 제거 (자동 생성되도록)
|
||||||
|
delete dataWithMeta.id;
|
||||||
|
// 빈 문자열 id도 제거
|
||||||
|
if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) {
|
||||||
|
delete dataWithMeta.id;
|
||||||
|
}
|
||||||
|
console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta);
|
||||||
|
const insertResult = await apiClient.post(
|
||||||
|
`/table-management/tables/${repeaterTargetTable}/add`,
|
||||||
|
dataWithMeta,
|
||||||
|
);
|
||||||
|
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||||||
|
} else if (item.id) {
|
||||||
|
// UPDATE (기존 항목)
|
||||||
|
const originalData = { id: item.id };
|
||||||
|
const updatedData = { ...dataWithMeta, id: item.id };
|
||||||
|
console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", {
|
||||||
|
id: item.id,
|
||||||
|
table: repeaterTargetTable,
|
||||||
|
});
|
||||||
|
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
|
||||||
|
originalData,
|
||||||
|
updatedData,
|
||||||
|
});
|
||||||
|
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: unknown }; message?: string };
|
||||||
|
console.error(
|
||||||
|
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
|
||||||
|
error.response?.data || error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
||||||
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
||||||
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
||||||
|
|
@ -769,11 +946,36 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
||||||
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
||||||
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
|
|
||||||
|
// 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용)
|
||||||
|
const repeaterFieldGroupTables: string[] = [];
|
||||||
|
for (const [, fieldValue] of Object.entries(context.formData)) {
|
||||||
|
let parsedData = fieldValue;
|
||||||
|
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(fieldValue);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) {
|
||||||
|
repeaterFieldGroupTables.push(parsedData[0]._targetTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
|
||||||
|
const shouldSkipMainSave =
|
||||||
|
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
|
||||||
|
|
||||||
if (shouldSkipMainSave) {
|
if (shouldSkipMainSave) {
|
||||||
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
|
console.log(
|
||||||
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
|
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
||||||
|
{
|
||||||
|
repeatScreenModalTables,
|
||||||
|
repeaterFieldGroupTables,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
||||||
} else {
|
} else {
|
||||||
saveResult = await DynamicFormApi.saveFormData({
|
saveResult = await DynamicFormApi.saveFormData({
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -1578,14 +1780,16 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 액션 처리
|
* 모달 액션 처리
|
||||||
|
* 🔧 modal 액션은 항상 신규 등록(INSERT) 모드로 동작
|
||||||
|
* edit 액션만 수정(UPDATE) 모드로 동작해야 함
|
||||||
*/
|
*/
|
||||||
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
// 모달 열기 로직
|
// 모달 열기 로직
|
||||||
console.log("모달 열기:", {
|
console.log("모달 열기 (신규 등록 모드):", {
|
||||||
title: config.modalTitle,
|
title: config.modalTitle,
|
||||||
size: config.modalSize,
|
size: config.modalSize,
|
||||||
targetScreenId: config.targetScreenId,
|
targetScreenId: config.targetScreenId,
|
||||||
selectedRowsData: context.selectedRowsData,
|
// 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.targetScreenId) {
|
if (config.targetScreenId) {
|
||||||
|
|
@ -1602,10 +1806,11 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 선택된 행 데이터 수집
|
// 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음
|
||||||
const selectedData = context.selectedRowsData || [];
|
// selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨
|
||||||
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
// edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작
|
||||||
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
|
console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음");
|
||||||
|
console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData);
|
||||||
|
|
||||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||||
const modalEvent = new CustomEvent("openScreenModal", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
|
|
@ -1614,10 +1819,11 @@ export class ButtonActionExecutor {
|
||||||
title: config.modalTitle || "화면",
|
title: config.modalTitle || "화면",
|
||||||
description: description,
|
description: description,
|
||||||
size: config.modalSize || "md",
|
size: config.modalSize || "md",
|
||||||
// 🆕 선택된 행 데이터 전달
|
// 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음
|
||||||
selectedData: selectedData,
|
// edit 액션에서만 이 데이터를 사용
|
||||||
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
selectedData: [],
|
||||||
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
|
selectedIds: [],
|
||||||
|
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용)
|
||||||
splitPanelParentData: context.splitPanelParentData || {},
|
splitPanelParentData: context.splitPanelParentData || {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -2624,6 +2830,7 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 저장 후 제어 실행 (After Timing)
|
* 저장 후 제어 실행 (After Timing)
|
||||||
* EditModal 등 외부에서도 호출 가능하도록 public으로 변경
|
* EditModal 등 외부에서도 호출 가능하도록 public으로 변경
|
||||||
|
* 다중 제어 순차 실행 지원
|
||||||
*/
|
*/
|
||||||
public static async executeAfterSaveControl(
|
public static async executeAfterSaveControl(
|
||||||
config: ButtonActionConfig,
|
config: ButtonActionConfig,
|
||||||
|
|
@ -2635,12 +2842,6 @@ export class ButtonActionExecutor {
|
||||||
dataflowTiming: config.dataflowTiming,
|
dataflowTiming: config.dataflowTiming,
|
||||||
});
|
});
|
||||||
|
|
||||||
// dataflowTiming이 'after'가 아니면 실행하지 않음
|
|
||||||
if (config.dataflowTiming && config.dataflowTiming !== "after") {
|
|
||||||
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 제어 데이터 소스 결정
|
// 제어 데이터 소스 결정
|
||||||
let controlDataSource = config.dataflowConfig?.controlDataSource;
|
let controlDataSource = config.dataflowConfig?.controlDataSource;
|
||||||
if (!controlDataSource) {
|
if (!controlDataSource) {
|
||||||
|
|
@ -2654,9 +2855,117 @@ export class ButtonActionExecutor {
|
||||||
controlDataSource,
|
controlDataSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔥 다중 제어 지원 (flowControls 배열)
|
||||||
|
const flowControls = config.dataflowConfig?.flowControls || [];
|
||||||
|
if (flowControls.length > 0) {
|
||||||
|
console.log(`🎯 다중 제어 순차 실행 시작: ${flowControls.length}개`);
|
||||||
|
|
||||||
|
// 순서대로 정렬
|
||||||
|
const sortedControls = [...flowControls].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
|
||||||
|
|
||||||
|
// 노드 플로우 실행 API
|
||||||
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
|
// 데이터 소스 준비
|
||||||
|
const sourceData: any = context.formData || {};
|
||||||
|
|
||||||
|
let allSuccess = true;
|
||||||
|
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedControls.length; i++) {
|
||||||
|
const control = sortedControls[i];
|
||||||
|
|
||||||
|
// 유효하지 않은 flowId 스킵
|
||||||
|
if (!control.flowId || control.flowId <= 0) {
|
||||||
|
console.warn(`⚠️ [${i + 1}/${sortedControls.length}] 유효하지 않은 flowId, 스킵:`, control);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// executionTiming 체크 (after만 실행)
|
||||||
|
if (control.executionTiming && control.executionTiming !== "after") {
|
||||||
|
console.log(
|
||||||
|
`⏭️ [${i + 1}/${sortedControls.length}] executionTiming이 'after'가 아님, 스킵:`,
|
||||||
|
control.executionTiming,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n📍 [${i + 1}/${sortedControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeNodeFlow(control.flowId, {
|
||||||
|
dataSourceType: controlDataSource,
|
||||||
|
sourceData,
|
||||||
|
context: extendedContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
flowId: control.flowId,
|
||||||
|
flowName: control.flowName,
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`✅ [${i + 1}/${sortedControls.length}] 제어 성공: ${control.flowName}`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실패: ${control.flowName} - ${result.message}`);
|
||||||
|
allSuccess = false;
|
||||||
|
// 이전 제어 실패 시 다음 제어 실행 중단
|
||||||
|
console.warn("⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실행 오류: ${control.flowName}`, error);
|
||||||
|
results.push({
|
||||||
|
flowId: control.flowId,
|
||||||
|
flowName: control.flowName,
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
allSuccess = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 요약
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failCount = results.filter((r) => !r.success).length;
|
||||||
|
console.log("\n📊 다중 제어 실행 완료:", {
|
||||||
|
total: sortedControls.length,
|
||||||
|
executed: results.length,
|
||||||
|
success: successCount,
|
||||||
|
failed: failCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allSuccess) {
|
||||||
|
toast.success(`${successCount}개 제어 실행 완료`);
|
||||||
|
} else {
|
||||||
|
toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 기존 단일 제어 실행 (하위 호환성)
|
||||||
|
// dataflowTiming이 'after'가 아니면 실행하지 않음
|
||||||
|
if (config.dataflowTiming && config.dataflowTiming !== "after") {
|
||||||
|
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
|
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
|
||||||
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
|
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
|
||||||
if (hasFlowConfig) {
|
if (hasFlowConfig) {
|
||||||
|
// executionTiming 체크
|
||||||
|
const flowTiming = config.dataflowConfig.flowConfig.executionTiming;
|
||||||
|
if (flowTiming && flowTiming !== "after") {
|
||||||
|
console.log("⏭️ flowConfig.executionTiming이 'after'가 아니므로 제어 실행 건너뜀:", flowTiming);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
|
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
|
||||||
|
|
||||||
const { flowId } = config.dataflowConfig.flowConfig;
|
const { flowId } = config.dataflowConfig.flowConfig;
|
||||||
|
|
@ -2666,7 +2975,7 @@ export class ButtonActionExecutor {
|
||||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
// 데이터 소스 준비
|
// 데이터 소스 준비
|
||||||
let sourceData: any = context.formData || {};
|
const sourceData: any = context.formData || {};
|
||||||
|
|
||||||
// repeat-screen-modal 데이터가 있으면 병합
|
// repeat-screen-modal 데이터가 있으면 병합
|
||||||
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
|
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
|
||||||
|
|
@ -3967,39 +4276,80 @@ export class ButtonActionExecutor {
|
||||||
try {
|
try {
|
||||||
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
|
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
|
||||||
|
|
||||||
// 추적 중인지 확인
|
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
|
||||||
if (!this.trackingIntervalId) {
|
const isTrackingActive = !!this.trackingIntervalId;
|
||||||
toast.warning("진행 중인 위치 추적이 없습니다.");
|
|
||||||
return false;
|
if (!isTrackingActive) {
|
||||||
|
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
|
||||||
|
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
|
||||||
|
} else {
|
||||||
|
// 타이머 정리 (추적 중인 경우에만)
|
||||||
|
clearInterval(this.trackingIntervalId);
|
||||||
|
this.trackingIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 타이머 정리
|
|
||||||
clearInterval(this.trackingIntervalId);
|
|
||||||
this.trackingIntervalId = null;
|
|
||||||
|
|
||||||
const tripId = this.currentTripId;
|
const tripId = this.currentTripId;
|
||||||
|
|
||||||
// 마지막 위치 저장 (trip_status를 completed로)
|
// 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용)
|
||||||
const departure =
|
let dbDeparture: string | null = null;
|
||||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
|
let dbArrival: string | null = null;
|
||||||
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
let dbVehicleId: string | null = null;
|
||||||
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
|
|
||||||
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
|
const userId = context.userId || this.trackingUserId;
|
||||||
const vehicleId =
|
if (userId) {
|
||||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles";
|
||||||
|
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
|
||||||
|
|
||||||
|
// DB에서 현재 차량 정보 조회
|
||||||
|
const vehicleResponse = await apiClient.post(
|
||||||
|
`/table-management/tables/${statusTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: { [keyField]: userId },
|
||||||
|
autoFilter: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
|
||||||
|
if (vehicleData) {
|
||||||
|
dbDeparture = vehicleData.departure || null;
|
||||||
|
dbArrival = vehicleData.arrival || null;
|
||||||
|
dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null;
|
||||||
|
console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId });
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.saveLocationToHistory(
|
// 마지막 위치 저장 (추적 중이었던 경우에만)
|
||||||
tripId,
|
if (isTrackingActive) {
|
||||||
departure,
|
// DB 값 우선, 없으면 formData 사용
|
||||||
arrival,
|
const departure = dbDeparture ||
|
||||||
departureName,
|
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
|
||||||
destinationName,
|
const arrival = dbArrival ||
|
||||||
vehicleId,
|
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
||||||
"completed",
|
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
|
||||||
);
|
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
|
||||||
|
const vehicleId = dbVehicleId ||
|
||||||
|
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
|
||||||
|
|
||||||
// 🆕 거리/시간 계산 및 저장
|
await this.saveLocationToHistory(
|
||||||
if (tripId) {
|
tripId,
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
vehicleId,
|
||||||
|
"completed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만)
|
||||||
|
if (isTrackingActive && tripId) {
|
||||||
try {
|
try {
|
||||||
const tripStats = await this.calculateTripStats(tripId);
|
const tripStats = await this.calculateTripStats(tripId);
|
||||||
console.log("📊 운행 통계:", tripStats);
|
console.log("📊 운행 통계:", tripStats);
|
||||||
|
|
@ -4111,9 +4461,9 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 변경 (vehicles 테이블 등)
|
// 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용
|
||||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config;
|
||||||
const effectiveContext = context.userId ? context : this.trackingContext;
|
const effectiveContext = context.userId ? context : this.trackingContext || context;
|
||||||
|
|
||||||
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
|
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tiptap/core": "^3.13.0",
|
"@tiptap/core": "^2.27.1",
|
||||||
"@tiptap/extension-placeholder": "^2.27.1",
|
"@tiptap/extension-placeholder": "^2.27.1",
|
||||||
"@tiptap/pm": "^2.11.5",
|
"@tiptap/pm": "^2.27.1",
|
||||||
"@tiptap/react": "^2.27.1",
|
"@tiptap/react": "^2.27.1",
|
||||||
"@tiptap/starter-kit": "^2.27.1",
|
"@tiptap/starter-kit": "^2.27.1",
|
||||||
"@turf/buffer": "^7.2.0",
|
"@turf/buffer": "^7.2.0",
|
||||||
|
|
@ -3302,16 +3302,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.13.0",
|
"version": "2.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tiptap/pm": "^3.13.0"
|
"@tiptap/pm": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-blockquote": {
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
|
@ -3700,19 +3700,6 @@
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/core": {
|
|
||||||
"version": "2.27.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
|
||||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@tiptap/pm": "^2.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@turf/along": {
|
"node_modules/@turf/along": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
||||||
|
|
@ -6084,7 +6071,7 @@
|
||||||
"version": "20.19.24",
|
"version": "20.19.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||||
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
|
|
@ -6122,7 +6109,7 @@
|
||||||
"version": "19.2.2",
|
"version": "19.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
|
|
@ -12538,6 +12525,13 @@
|
||||||
"react-dom": ">=16"
|
"react-dom": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
|
||||||
|
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
|
@ -14197,7 +14191,7 @@
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tiptap/core": "^3.13.0",
|
"@tiptap/core": "^2.27.1",
|
||||||
"@tiptap/extension-placeholder": "^2.27.1",
|
"@tiptap/extension-placeholder": "^2.27.1",
|
||||||
"@tiptap/pm": "^2.11.5",
|
"@tiptap/pm": "^2.27.1",
|
||||||
"@tiptap/react": "^2.27.1",
|
"@tiptap/react": "^2.27.1",
|
||||||
"@tiptap/starter-kit": "^2.27.1",
|
"@tiptap/starter-kit": "^2.27.1",
|
||||||
"@turf/buffer": "^7.2.0",
|
"@turf/buffer": "^7.2.0",
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,21 @@
|
||||||
/**
|
/**
|
||||||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||||
*/
|
*/
|
||||||
export type RepeaterFieldType =
|
export type RepeaterFieldType =
|
||||||
| "text" // 텍스트
|
| "text" // 텍스트
|
||||||
| "number" // 숫자
|
| "number" // 숫자
|
||||||
| "textarea" // 텍스트영역
|
| "textarea" // 텍스트영역
|
||||||
| "date" // 날짜
|
| "date" // 날짜
|
||||||
| "select" // 선택박스
|
| "select" // 선택박스
|
||||||
| "checkbox" // 체크박스
|
| "checkbox" // 체크박스
|
||||||
| "radio" // 라디오
|
| "radio" // 라디오
|
||||||
| "category" // 카테고리
|
| "category" // 카테고리
|
||||||
| "entity" // 엔티티 참조
|
| "entity" // 엔티티 참조
|
||||||
| "code" // 공통코드
|
| "code" // 공통코드
|
||||||
| "image" // 이미지
|
| "image" // 이미지
|
||||||
| "direct" // 직접입력
|
| "direct" // 직접입력
|
||||||
| "calculated" // 계산식 필드
|
| "calculated" // 계산식 필드
|
||||||
| string; // 기타 커스텀 타입 허용
|
| string; // 기타 커스텀 타입 허용
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계산식 연산자
|
* 계산식 연산자
|
||||||
|
|
@ -32,11 +32,11 @@ export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor
|
||||||
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
||||||
*/
|
*/
|
||||||
export interface CalculationFormula {
|
export interface CalculationFormula {
|
||||||
field1: string; // 첫 번째 필드명
|
field1: string; // 첫 번째 필드명
|
||||||
operator: CalculationOperator; // 연산자
|
operator: CalculationOperator; // 연산자
|
||||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
|
||||||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||||
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
||||||
|
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
|
||||||
minItems?: number; // 최소 항목 수
|
minItems?: number; // 최소 항목 수
|
||||||
maxItems?: number; // 최대 항목 수
|
maxItems?: number; // 최대 항목 수
|
||||||
addButtonText?: string; // 추가 버튼 텍스트
|
addButtonText?: string; // 추가 버튼 텍스트
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,21 @@
|
||||||
*/
|
*/
|
||||||
export interface TableFilter {
|
export interface TableFilter {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
operator:
|
operator: "equals" | "contains" | "startsWith" | "endsWith" | "gt" | "lt" | "gte" | "lte" | "notEquals";
|
||||||
| "equals"
|
|
||||||
| "contains"
|
|
||||||
| "startsWith"
|
|
||||||
| "endsWith"
|
|
||||||
| "gt"
|
|
||||||
| "lt"
|
|
||||||
| "gte"
|
|
||||||
| "lte"
|
|
||||||
| "notEquals";
|
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
|
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
|
||||||
width?: number; // 필터 입력 필드 너비 (px)
|
width?: number; // 필터 입력 필드 너비 (px)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹별 합산 설정
|
||||||
|
*/
|
||||||
|
export interface GroupSumConfig {
|
||||||
|
enabled: boolean; // 그룹핑 활성화 여부
|
||||||
|
groupByColumn: string; // 그룹 기준 컬럼
|
||||||
|
groupByColumnLabel?: string; // 그룹 기준 컬럼 라벨 (UI 표시용)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컬럼 표시 설정
|
* 컬럼 표시 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -60,7 +60,8 @@ export interface TableRegistration {
|
||||||
onFilterChange: (filters: TableFilter[]) => void;
|
onFilterChange: (filters: TableFilter[]) => void;
|
||||||
onGroupChange: (groups: string[]) => void;
|
onGroupChange: (groups: string[]) => void;
|
||||||
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
||||||
|
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경
|
||||||
|
|
||||||
// 데이터 조회 함수 (선택 타입 필터용)
|
// 데이터 조회 함수 (선택 타입 필터용)
|
||||||
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
||||||
}
|
}
|
||||||
|
|
@ -77,4 +78,3 @@ export interface TableOptionsContextValue {
|
||||||
selectedTableId: string | null;
|
selectedTableId: string | null;
|
||||||
setSelectedTableId: (tableId: string | null) => void;
|
setSelectedTableId: (tableId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1684,3 +1684,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -531,3 +531,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,3 +518,4 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue