chpark-sync #425
|
|
@ -50,6 +50,9 @@ export class EntityJoinController {
|
|||
// search가 문자열인 경우 JSON 파싱
|
||||
searchConditions =
|
||||
typeof search === "string" ? JSON.parse(search) : search;
|
||||
|
||||
// 🔍 디버그: 파싱된 검색 조건 로깅
|
||||
logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn("검색 조건 파싱 오류:", error);
|
||||
searchConditions = {};
|
||||
|
|
|
|||
|
|
@ -47,8 +47,13 @@ export interface SplitPanelConfig {
|
|||
columns: Array<{ name: string; label: string; width?: number }>;
|
||||
relation?: {
|
||||
type: string;
|
||||
foreignKey: string;
|
||||
leftColumn: string;
|
||||
foreignKey?: string;
|
||||
leftColumn?: string;
|
||||
// 복합키 지원 (새로운 방식)
|
||||
keys?: Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -210,8 +215,21 @@ class MasterDetailExcelService {
|
|||
}
|
||||
|
||||
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||||
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||
let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
||||
let masterKeyColumn: string | undefined;
|
||||
let detailFkColumn: string | undefined;
|
||||
|
||||
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
||||
if (relationKeys && relationKeys.length > 0) {
|
||||
// keys 배열에서 첫 번째 키 사용
|
||||
masterKeyColumn = relationKeys[0].leftColumn;
|
||||
detailFkColumn = relationKeys[0].rightColumn;
|
||||
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
||||
} else {
|
||||
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
||||
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||
}
|
||||
|
||||
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||||
if (!masterKeyColumn || !detailFkColumn) {
|
||||
|
|
@ -726,7 +744,9 @@ class MasterDetailExcelService {
|
|||
result.masterInserted = 1;
|
||||
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
||||
|
||||
// 4. 디테일 레코드들 생성
|
||||
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
||||
const insertedDetailRows: Record<string, any>[] = [];
|
||||
|
||||
for (const row of detailData) {
|
||||
try {
|
||||
const detailRowData: Record<string, any> = {
|
||||
|
|
@ -746,11 +766,18 @@ class MasterDetailExcelService {
|
|||
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
||||
const detailValues = detailCols.map(k => detailRowData[k]);
|
||||
|
||||
await client.query(
|
||||
// RETURNING *로 삽입된 데이터 반환받기
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${detailPlaceholders.join(", ")}, NOW())`,
|
||||
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
||||
RETURNING *`,
|
||||
detailValues
|
||||
);
|
||||
|
||||
if (insertResult.rows && insertResult.rows[0]) {
|
||||
insertedDetailRows.push(insertResult.rows[0]);
|
||||
}
|
||||
|
||||
result.detailInserted++;
|
||||
} catch (error: any) {
|
||||
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||
|
|
@ -758,6 +785,8 @@ class MasterDetailExcelService {
|
|||
}
|
||||
}
|
||||
|
||||
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
||||
|
||||
await client.query("COMMIT");
|
||||
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
||||
|
||||
|
|
@ -779,7 +808,7 @@ class MasterDetailExcelService {
|
|||
try {
|
||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||
|
||||
// 마스터 데이터를 제어에 전달
|
||||
// 마스터 데이터 구성
|
||||
const masterData = {
|
||||
...masterFieldValues,
|
||||
[relation!.masterKeyColumn]: result.generatedKey,
|
||||
|
|
@ -791,17 +820,28 @@ class MasterDetailExcelService {
|
|||
// 순서대로 제어 실행
|
||||
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
||||
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
||||
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
||||
|
||||
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
||||
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
||||
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
||||
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flow.flowId),
|
||||
{
|
||||
sourceData: [masterData],
|
||||
dataSourceType: "formData",
|
||||
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
||||
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
||||
buttonId: "excel-upload-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: masterData,
|
||||
// 추가 컨텍스트: 마스터/디테일 정보
|
||||
masterData: masterData,
|
||||
detailData: insertedDetailRows,
|
||||
masterTable: relation!.masterTable,
|
||||
detailTable: relation!.detailTable,
|
||||
masterKeyColumn: relation!.masterKeyColumn,
|
||||
detailFkColumn: relation!.detailFkColumn,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -4446,6 +4446,8 @@ export class NodeFlowExecutionService {
|
|||
|
||||
/**
|
||||
* 산술 연산 계산
|
||||
* 다중 연산 지원: (leftOperand operator rightOperand) 이후 additionalOperations 순차 적용
|
||||
* 예: (width * height) / 1000000 * qty
|
||||
*/
|
||||
private static evaluateArithmetic(
|
||||
arithmetic: any,
|
||||
|
|
@ -4472,27 +4474,67 @@ export class NodeFlowExecutionService {
|
|||
const leftNum = Number(left) || 0;
|
||||
const rightNum = Number(right) || 0;
|
||||
|
||||
switch (arithmetic.operator) {
|
||||
// 기본 연산 수행
|
||||
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
|
||||
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추가 연산 처리 (다중 연산 지원)
|
||||
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const operandValue = this.getOperandValue(
|
||||
addOp.operand,
|
||||
sourceRow,
|
||||
targetRow,
|
||||
resultValues
|
||||
);
|
||||
const operandNum = Number(operandValue) || 0;
|
||||
|
||||
result = this.applyOperator(result, addOp.operator, operandNum);
|
||||
|
||||
if (result === null) {
|
||||
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 연산자 적용
|
||||
*/
|
||||
private static applyOperator(
|
||||
left: number,
|
||||
operator: string,
|
||||
right: number
|
||||
): number | null {
|
||||
switch (operator) {
|
||||
case "+":
|
||||
return leftNum + rightNum;
|
||||
return left + right;
|
||||
case "-":
|
||||
return leftNum - rightNum;
|
||||
return left - right;
|
||||
case "*":
|
||||
return leftNum * rightNum;
|
||||
return left * right;
|
||||
case "/":
|
||||
if (rightNum === 0) {
|
||||
if (right === 0) {
|
||||
logger.warn(`⚠️ 0으로 나누기 시도`);
|
||||
return null;
|
||||
}
|
||||
return leftNum / rightNum;
|
||||
return left / right;
|
||||
case "%":
|
||||
if (rightNum === 0) {
|
||||
if (right === 0) {
|
||||
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
||||
return null;
|
||||
}
|
||||
return leftNum % rightNum;
|
||||
return left % right;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
|
||||
throw new Error(`지원하지 않는 연산자: ${operator}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -187,71 +187,68 @@ class TableCategoryValueService {
|
|||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
}
|
||||
|
||||
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
||||
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||
query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 카테고리 값 조회");
|
||||
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
|
||||
if (menuObjid && siblingObjids.length > 0) {
|
||||
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
|
||||
params = [tableName, columnName, siblingObjids];
|
||||
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
|
||||
} else if (menuObjid) {
|
||||
query = baseSelect + ` AND menu_objid = $3`;
|
||||
params = [tableName, columnName, menuObjid];
|
||||
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
|
||||
} else {
|
||||
// menuObjid 없으면 모든 값 조회 (중복 가능)
|
||||
query = baseSelect;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
|
||||
}
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 조회
|
||||
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||
query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회", { companyCode });
|
||||
// 일반 회사: 자신의 회사 + menuObjid로 필터링
|
||||
if (menuObjid && siblingObjids.length > 0) {
|
||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
|
||||
params = [tableName, columnName, companyCode, siblingObjids];
|
||||
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
|
||||
} else if (menuObjid) {
|
||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
|
||||
params = [tableName, columnName, companyCode, menuObjid];
|
||||
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
|
||||
} else {
|
||||
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
|
||||
query = baseSelect + ` AND company_code = $3`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ export interface ExcelUploadModalProps {
|
|||
};
|
||||
// 🆕 마스터-디테일 엑셀 업로드 설정
|
||||
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||
// 🆕 단일 테이블 채번 설정
|
||||
numberingRuleId?: string;
|
||||
numberingTargetColumn?: string;
|
||||
// 🆕 업로드 후 제어 실행 설정
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
}
|
||||
|
||||
interface ColumnMapping {
|
||||
|
|
@ -103,6 +108,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
isMasterDetail = false,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
// 단일 테이블 채번 설정
|
||||
numberingRuleId,
|
||||
numberingTargetColumn,
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows,
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
|
|
@ -698,10 +708,29 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 단일 테이블 채번 설정 확인
|
||||
const hasNumbering = numberingRuleId && numberingTargetColumn;
|
||||
|
||||
for (const row of filteredData) {
|
||||
try {
|
||||
let dataToSave = { ...row };
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출
|
||||
if (hasNumbering && uploadMode === "insert") {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
|
||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||
if (numberingResponse.data?.success && generatedCode) {
|
||||
dataToSave[numberingTargetColumn] = generatedCode;
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadMode === "insert") {
|
||||
const formData = { screenId: 0, tableName, data: row };
|
||||
const formData = { screenId: 0, tableName, data: dataToSave };
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
|
|
@ -714,6 +743,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 업로드 후 제어 실행
|
||||
if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
|
||||
console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
// 순서대로 실행
|
||||
const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order);
|
||||
for (const flow of sortedFlows) {
|
||||
await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, {
|
||||
sourceData: { tableName, uploadedCount: successCount },
|
||||
});
|
||||
console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`);
|
||||
}
|
||||
} catch (controlError) {
|
||||
console.error("제어 실행 오류:", controlError);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||
|
|
|
|||
|
|
@ -175,13 +175,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
if (editData) {
|
||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||
|
||||
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
|
||||
// 🆕 배열인 경우 두 가지 데이터를 설정:
|
||||
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
|
||||
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
|
||||
if (Array.isArray(editData)) {
|
||||
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
|
||||
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
|
||||
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
|
||||
const firstRecord = editData[0] || {};
|
||||
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, {
|
||||
formData: "첫 번째 레코드 (일반 입력 필드용)",
|
||||
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
|
||||
});
|
||||
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
|
||||
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
|
||||
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
|
||||
} else {
|
||||
setFormData(editData);
|
||||
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
|
||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ const OPERATOR_LABELS: Record<string, string> = {
|
|||
"%": "%",
|
||||
};
|
||||
|
||||
// 피연산자를 문자열로 변환
|
||||
function getOperandStr(operand: any): string {
|
||||
if (!operand) return "?";
|
||||
if (operand.type === "static") return String(operand.value || "?");
|
||||
if (operand.fieldLabel) return operand.fieldLabel;
|
||||
return operand.field || operand.resultField || "?";
|
||||
}
|
||||
|
||||
// 수식 요약 생성
|
||||
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||
|
|
@ -35,11 +43,19 @@ function getFormulaSummary(transformation: FormulaTransformNodeData["transformat
|
|||
switch (formulaType) {
|
||||
case "arithmetic": {
|
||||
if (!arithmetic) return "미설정";
|
||||
const left = arithmetic.leftOperand;
|
||||
const right = arithmetic.rightOperand;
|
||||
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
|
||||
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
|
||||
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
const leftStr = getOperandStr(arithmetic.leftOperand);
|
||||
const rightStr = getOperandStr(arithmetic.rightOperand);
|
||||
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
|
||||
// 추가 연산 표시
|
||||
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const opStr = getOperandStr(addOp.operand);
|
||||
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formula;
|
||||
}
|
||||
case "function": {
|
||||
if (!func) return "미설정";
|
||||
|
|
|
|||
|
|
@ -797,6 +797,85 @@ export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPro
|
|||
index,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가 연산 목록 */}
|
||||
{trans.arithmetic.additionalOperations && trans.arithmetic.additionalOperations.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<Label className="text-xs text-gray-500">추가 연산</Label>
|
||||
{trans.arithmetic.additionalOperations.map((addOp: any, addIndex: number) => (
|
||||
<div key={addIndex} className="flex items-center gap-2 rounded bg-orange-50 p-2">
|
||||
<Select
|
||||
value={addOp.operator}
|
||||
onValueChange={(value) => {
|
||||
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
|
||||
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operator: value };
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ARITHMETIC_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
{renderOperandSelector(
|
||||
addOp.operand,
|
||||
(updates) => {
|
||||
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
|
||||
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operand: updates };
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const newAdditionalOps = trans.arithmetic!.additionalOperations!.filter(
|
||||
(_: any, i: number) => i !== addIndex
|
||||
);
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 연산 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => {
|
||||
const newAdditionalOps = [
|
||||
...(trans.arithmetic!.additionalOperations || []),
|
||||
{ operator: "*", operand: { type: "static" as const, value: "" } },
|
||||
];
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연산 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -42,7 +43,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -100,11 +101,7 @@ const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
|
|||
const isDisabled = !parentValue || loading;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(newValue) => onChange?.(newValue)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
|
||||
<SelectTrigger className={className}>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -188,6 +185,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
|
||||
|
||||
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
|
||||
const searchParams = useSearchParams();
|
||||
const menuObjid = useMemo(() => {
|
||||
// 1. ScreenContext에서 가져오기
|
||||
if (screenContext?.menuObjid) return screenContext.menuObjid;
|
||||
// 2. URL 쿼리에서 가져오기
|
||||
const urlMenuObjid = searchParams.get("menuObjid");
|
||||
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
|
||||
}, [screenContext?.menuObjid, searchParams]);
|
||||
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
|
|
@ -236,7 +243,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||
|
||||
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
|
||||
const [categoryMappings, setCategoryMappings] = useState<
|
||||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({});
|
||||
|
||||
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
|
||||
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// 테이블 등록 (Context에 등록)
|
||||
const tableId = `datatable-${component.id}`;
|
||||
|
|
@ -365,8 +377,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
for (const col of categoryColumns) {
|
||||
try {
|
||||
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
|
||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${component.tableName}/${col.columnName}/values`
|
||||
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
|
|
@ -379,7 +393,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
});
|
||||
mappings[col.columnName] = mapping;
|
||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
|
||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
|
||||
|
|
@ -394,7 +408,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
|
||||
loadCategoryMappings();
|
||||
}, [component.tableName, component.columns, getColumnWebType]);
|
||||
}, [component.tableName, component.columns, getColumnWebType, menuObjid]);
|
||||
|
||||
// 파일 상태 확인 함수
|
||||
const checkFileStatus = useCallback(
|
||||
|
|
@ -701,13 +715,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||||
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
||||
(filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
|
||||
filter.targetColumn === component.tableName
|
||||
(filter) =>
|
||||
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
|
||||
);
|
||||
|
||||
// 좌측 데이터 선택 여부 확인
|
||||
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
|
||||
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||
hasSelectedLeftData =
|
||||
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||
|
||||
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
||||
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||||
|
|
@ -741,7 +755,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
|
||||
// 🆕 RelatedDataButtons 필터 적용
|
||||
let relatedButtonFilterValues: Record<string, any> = {};
|
||||
const relatedButtonFilterValues: Record<string, any> = {};
|
||||
if (relatedButtonFilter) {
|
||||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
||||
}
|
||||
|
|
@ -773,7 +787,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
tableName: component.tableName,
|
||||
dataLength: result.data.length,
|
||||
total: result.total,
|
||||
page: result.page
|
||||
page: result.page,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
|
|
@ -781,6 +795,45 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setTotalPages(result.totalPages);
|
||||
setCurrentPage(result.page);
|
||||
|
||||
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
|
||||
const detectAndLoadCategoryLabels = async () => {
|
||||
const categoryCodes = new Set<string>();
|
||||
result.data.forEach((row: Record<string, any>) => {
|
||||
Object.values(row).forEach((value) => {
|
||||
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
|
||||
categoryCodes.add(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
|
||||
|
||||
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
|
||||
const newCodes = Array.from(categoryCodes);
|
||||
|
||||
if (newCodes.length > 0) {
|
||||
try {
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
|
||||
if (response.data.success && response.data.data) {
|
||||
setCategoryCodeLabels((prev) => {
|
||||
const newLabels = {
|
||||
...prev,
|
||||
...response.data.data,
|
||||
};
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
|
||||
return newLabels;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
detectAndLoadCategoryLabels();
|
||||
|
||||
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
||||
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
||||
const primaryKeyField = Object.keys(rowData)[0];
|
||||
|
|
@ -918,12 +971,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setTableColumns(columns);
|
||||
|
||||
// 🆕 전체 컬럼 목록 설정
|
||||
const columnNames = columns.map(col => col.columnName);
|
||||
const columnNames = columns.map((col) => col.columnName);
|
||||
setAllAvailableColumns(columnNames);
|
||||
|
||||
// 🆕 컬럼명 -> 라벨 매핑 생성
|
||||
const labels: Record<string, string> = {};
|
||||
columns.forEach(col => {
|
||||
columns.forEach((col) => {
|
||||
labels[col.columnName] = col.displayName || col.columnName;
|
||||
});
|
||||
setColumnLabels(labels);
|
||||
|
|
@ -983,28 +1036,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
);
|
||||
|
||||
// 행 선택 핸들러
|
||||
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
|
||||
setSelectedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (isSelected) {
|
||||
newSet.add(rowIndex);
|
||||
} else {
|
||||
newSet.delete(rowIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
const handleRowSelect = useCallback(
|
||||
(rowIndex: number, isSelected: boolean) => {
|
||||
setSelectedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (isSelected) {
|
||||
newSet.add(rowIndex);
|
||||
} else {
|
||||
newSet.delete(rowIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
|
||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (isSelected && data[rowIndex]) {
|
||||
splitPanelContext.setSelectedLeftData(data[rowIndex]);
|
||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
|
||||
} else if (!isSelected) {
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
|
||||
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
|
||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (isSelected && data[rowIndex]) {
|
||||
splitPanelContext.setSelectedLeftData(data[rowIndex]);
|
||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
|
||||
} else if (!isSelected) {
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data, splitPanelContext, splitPanelPosition]);
|
||||
},
|
||||
[data, splitPanelContext, splitPanelPosition],
|
||||
);
|
||||
|
||||
// 전체 선택/해제 핸들러
|
||||
const handleSelectAll = useCallback(
|
||||
|
|
@ -1713,7 +1769,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
case "category": {
|
||||
// 카테고리 셀렉트 (동적 import)
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const {
|
||||
CategorySelectComponent,
|
||||
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
return (
|
||||
<div>
|
||||
<CategorySelectComponent
|
||||
|
|
@ -2013,7 +2071,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
case "category": {
|
||||
// 카테고리 셀렉트 (동적 import)
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const {
|
||||
CategorySelectComponent,
|
||||
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
return (
|
||||
<div>
|
||||
<CategorySelectComponent
|
||||
|
|
@ -2151,8 +2211,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const actualWebType = getColumnWebType(column.columnName);
|
||||
|
||||
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
||||
const isFileColumn =
|
||||
actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
||||
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
||||
|
||||
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
||||
if (isFileColumn && rowData) {
|
||||
|
|
@ -2214,7 +2273,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
|
|
@ -2255,8 +2314,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
default: {
|
||||
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
|
||||
const strValue = String(value);
|
||||
if (strValue.startsWith("CATEGORY_")) {
|
||||
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
|
||||
for (const columnName of Object.keys(categoryMappings)) {
|
||||
const mapping = categoryMappings[columnName];
|
||||
const categoryData = mapping?.[strValue];
|
||||
if (categoryData) {
|
||||
// 색상이 있으면 배지로, 없으면 텍스트로 표시
|
||||
if (categoryData.color && categoryData.color !== "none") {
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: categoryData.color,
|
||||
borderColor: categoryData.color,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{categoryData.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm">{categoryData.label}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
|
||||
const cachedLabel = categoryCodeLabels[strValue];
|
||||
if (cachedLabel) {
|
||||
return <span className="text-sm">{cachedLabel}</span>;
|
||||
}
|
||||
}
|
||||
return strValue;
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
|
|
@ -2392,15 +2484,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
||||
<Table style={{ tableLayout: 'fixed' }}>
|
||||
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b">
|
||||
<TableRow>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableHead
|
||||
className="px-4"
|
||||
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||
>
|
||||
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
|
||||
<Checkbox
|
||||
checked={selectedRows.size === data.length && data.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
|
|
@ -2414,18 +2503,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<TableHead
|
||||
key={column.id}
|
||||
ref={(el) => (columnRefs.current[column.id] = el)}
|
||||
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
|
||||
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none"
|
||||
style={{
|
||||
width: columnWidth ? `${columnWidth}px` : undefined,
|
||||
userSelect: 'none'
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
{/* 리사이즈 핸들 */}
|
||||
{columnIndex < visibleColumns.length - 1 && (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
||||
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -2440,8 +2529,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const startWidth = columnWidth || thElement.offsetWidth;
|
||||
|
||||
// 드래그 중 텍스트 선택 방지
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
|
|
@ -2459,24 +2548,24 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
// 최종 너비를 state에 저장
|
||||
if (thElement) {
|
||||
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
|
||||
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
|
||||
}
|
||||
|
||||
// 텍스트 선택 복원
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
|
||||
// 약간의 지연 후 리사이즈 플래그 해제
|
||||
setTimeout(() => {
|
||||
isResizingRef.current = false;
|
||||
}, 100);
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -2504,10 +2593,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell
|
||||
className="px-4"
|
||||
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||
>
|
||||
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(rowIndex)}
|
||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||
|
|
@ -2519,8 +2605,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
return (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{ textAlign: isNumeric ? 'right' : 'left' }}
|
||||
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900"
|
||||
style={{ textAlign: isNumeric ? "right" : "left" }}
|
||||
>
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { Label } from "@/components/ui/label";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -2026,7 +2025,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
{/* 엑셀 업로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_upload" && (
|
||||
<ExcelUploadConfigSection config={config} onUpdateProperty={onUpdateProperty} allComponents={allComponents} />
|
||||
<ExcelUploadConfigSection
|
||||
config={config}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
allComponents={allComponents}
|
||||
currentTableName={currentTableName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 바코드 스캔 액션 설정 */}
|
||||
|
|
@ -3311,7 +3315,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
onUpdateProperty: (path: string, value: any) => void;
|
||||
allComponents: ComponentData[];
|
||||
}> = ({ config, onUpdateProperty, allComponents }) => {
|
||||
const [numberingRules, setNumberingRules] = useState<any[]>([]);
|
||||
const [relationInfo, setRelationInfo] = useState<{
|
||||
masterTable: string;
|
||||
detailTable: string;
|
||||
|
|
@ -3319,7 +3322,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
detailFkColumn: string;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [numberingRuleOpen, setNumberingRuleOpen] = useState(false);
|
||||
const [masterColumns, setMasterColumns] = useState<
|
||||
Array<{
|
||||
columnName: string;
|
||||
|
|
@ -3357,22 +3359,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
const masterTable = splitPanelInfo?.leftPanel?.tableName || "";
|
||||
const detailTable = splitPanelInfo?.rightPanel?.tableName || "";
|
||||
|
||||
// 채번 규칙 로드
|
||||
useEffect(() => {
|
||||
const loadNumberingRules = async () => {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/numbering-rules");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setNumberingRules(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 마스터 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!masterTable) {
|
||||
|
|
@ -3547,86 +3533,12 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 채번 규칙 선택 - 유일하게 사용자가 설정하는 항목 */}
|
||||
{/* 마스터 키 자동 생성 안내 */}
|
||||
{relationInfo && (
|
||||
<div>
|
||||
<Label className="text-xs">채번 규칙</Label>
|
||||
<Popover open={numberingRuleOpen} onOpenChange={setNumberingRuleOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={numberingRuleOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{masterDetailConfig.numberingRuleId
|
||||
? numberingRules.find(
|
||||
(rule) => String(rule.rule_id || rule.ruleId) === String(masterDetailConfig.numberingRuleId),
|
||||
)?.rule_name ||
|
||||
numberingRules.find(
|
||||
(rule) => String(rule.rule_id || rule.ruleId) === String(masterDetailConfig.numberingRuleId),
|
||||
)?.ruleName ||
|
||||
"선택됨"
|
||||
: "채번 없음 (수동 입력)"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">검색 결과 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
updateMasterDetailConfig({ numberingRuleId: undefined });
|
||||
setNumberingRuleOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!masterDetailConfig.numberingRuleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
채번 없음 (수동 입력)
|
||||
</CommandItem>
|
||||
{numberingRules
|
||||
.filter((rule) => rule.table_name === masterTable || !rule.table_name)
|
||||
.map((rule, idx) => {
|
||||
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
|
||||
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={ruleId}
|
||||
value={ruleName}
|
||||
onSelect={() => {
|
||||
updateMasterDetailConfig({ numberingRuleId: ruleId });
|
||||
setNumberingRuleOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
String(masterDetailConfig.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{ruleName}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값을 자동 생성합니다
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground border-t pt-2 text-xs">
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 위에서 설정한 채번 규칙으로 자동
|
||||
생성됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */}
|
||||
|
|
@ -3726,14 +3638,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
<p className="text-muted-foreground text-xs">참조 테이블에서 사용자에게 표시할 컬럼을 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 후 제어 실행 설정 */}
|
||||
<AfterUploadControlConfig
|
||||
config={config}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
masterDetailConfig={masterDetailConfig}
|
||||
updateMasterDetailConfig={updateMasterDetailConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -3741,23 +3645,181 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
};
|
||||
|
||||
/**
|
||||
* 업로드 후 제어 실행 설정 컴포넌트
|
||||
* 여러 개의 제어를 순서대로 실행할 수 있도록 지원
|
||||
* 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
|
||||
*/
|
||||
const AfterUploadControlConfig: React.FC<{
|
||||
config: any;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
masterDetailConfig: any;
|
||||
updateMasterDetailConfig: (updates: any) => void;
|
||||
}> = ({ masterDetailConfig, updateMasterDetailConfig }) => {
|
||||
const [nodeFlows, setNodeFlows] = useState<
|
||||
Array<{ flowId: number; flowName: string; flowDescription?: string }>
|
||||
>([]);
|
||||
const ExcelNumberingRuleConfig: React.FC<{
|
||||
config: { numberingRuleId?: string; numberingTargetColumn?: string };
|
||||
updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void;
|
||||
tableName?: string; // 단일 테이블인 경우 테이블명
|
||||
hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일)
|
||||
}> = ({ config, updateConfig, tableName, hasSplitPanel }) => {
|
||||
const [numberingRules, setNumberingRules] = useState<any[]>([]);
|
||||
const [ruleSelectOpen, setRuleSelectOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tableColumns, setTableColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 채번 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
const loadNumberingRules = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/numbering-rules");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setNumberingRules(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 단일 테이블인 경우 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!tableName || hasSplitPanel) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data?.success && response.data?.data?.columns) {
|
||||
const cols = response.data.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
}));
|
||||
setTableColumns(cols);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [tableName, hasSplitPanel]);
|
||||
|
||||
const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId));
|
||||
|
||||
return (
|
||||
<div className="border-t pt-3">
|
||||
<Label className="text-xs">채번 규칙</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요.
|
||||
</p>
|
||||
|
||||
<Popover open={ruleSelectOpen} onOpenChange={setRuleSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={ruleSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "로딩 중..." : selectedRule?.rule_name || selectedRule?.ruleName || "채번 없음"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">검색 결과 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined });
|
||||
setRuleSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", !config.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
||||
채번 없음
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule, idx) => {
|
||||
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
|
||||
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={ruleId}
|
||||
value={ruleName}
|
||||
onSelect={() => {
|
||||
updateConfig({ numberingRuleId: ruleId });
|
||||
setRuleSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
String(config.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{ruleName}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */}
|
||||
{config.numberingRuleId && !hasSplitPanel && tableName && (
|
||||
<div className="mt-2">
|
||||
<Label className="text-xs">채번 적용 컬럼</Label>
|
||||
<Select
|
||||
value={config.numberingTargetColumn || ""}
|
||||
onValueChange={(value) => updateConfig({ numberingTargetColumn: value || undefined })}
|
||||
disabled={columnsLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={columnsLoading ? "로딩 중..." : "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">채번 값이 입력될 컬럼을 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분할 패널인 경우 안내 메시지 */}
|
||||
{config.numberingRuleId && hasSplitPanel && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 후 제어 실행 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
|
||||
*/
|
||||
const ExcelAfterUploadControlConfig: React.FC<{
|
||||
config: { afterUploadFlows?: Array<{ flowId: string; order: number }> };
|
||||
updateConfig: (updates: { afterUploadFlows?: Array<{ flowId: string; order: number }> }) => void;
|
||||
}> = ({ config, updateConfig }) => {
|
||||
const [nodeFlows, setNodeFlows] = useState<Array<{ flowId: number; flowName: string; flowDescription?: string }>>([]);
|
||||
const [flowSelectOpen, setFlowSelectOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 선택된 제어 목록 (배열로 관리)
|
||||
const selectedFlows: Array<{ flowId: string; order: number }> = masterDetailConfig.afterUploadFlows || [];
|
||||
const selectedFlows = config.afterUploadFlows || [];
|
||||
|
||||
// 노드 플로우 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -3779,53 +3841,39 @@ const AfterUploadControlConfig: React.FC<{
|
|||
loadNodeFlows();
|
||||
}, []);
|
||||
|
||||
// 제어 추가
|
||||
const addFlow = (flowId: string) => {
|
||||
if (selectedFlows.some((f) => f.flowId === flowId)) return;
|
||||
const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }];
|
||||
updateMasterDetailConfig({ afterUploadFlows: newFlows });
|
||||
updateConfig({ afterUploadFlows: newFlows });
|
||||
setFlowSelectOpen(false);
|
||||
};
|
||||
|
||||
// 제어 제거
|
||||
const removeFlow = (flowId: string) => {
|
||||
const newFlows = selectedFlows
|
||||
.filter((f) => f.flowId !== flowId)
|
||||
.map((f, idx) => ({ ...f, order: idx + 1 }));
|
||||
updateMasterDetailConfig({ afterUploadFlows: newFlows });
|
||||
const newFlows = selectedFlows.filter((f) => f.flowId !== flowId).map((f, idx) => ({ ...f, order: idx + 1 }));
|
||||
updateConfig({ afterUploadFlows: newFlows });
|
||||
};
|
||||
|
||||
// 순서 변경 (위로)
|
||||
const moveUp = (index: number) => {
|
||||
if (index === 0) return;
|
||||
const newFlows = [...selectedFlows];
|
||||
[newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]];
|
||||
updateMasterDetailConfig({
|
||||
afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })),
|
||||
});
|
||||
updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) });
|
||||
};
|
||||
|
||||
// 순서 변경 (아래로)
|
||||
const moveDown = (index: number) => {
|
||||
if (index === selectedFlows.length - 1) return;
|
||||
const newFlows = [...selectedFlows];
|
||||
[newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]];
|
||||
updateMasterDetailConfig({
|
||||
afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })),
|
||||
});
|
||||
updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) });
|
||||
};
|
||||
|
||||
// 선택되지 않은 플로우만 필터링
|
||||
const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId)));
|
||||
|
||||
return (
|
||||
<div className="border-t pt-3">
|
||||
<Label className="text-xs">업로드 후 제어 실행</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
엑셀 업로드 완료 후 순서대로 실행할 제어를 추가하세요.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-xs">엑셀 업로드 완료 후 순서대로 실행할 제어를 추가하세요.</p>
|
||||
|
||||
{/* 선택된 제어 목록 */}
|
||||
{selectedFlows.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{selectedFlows.map((selected, index) => {
|
||||
|
|
@ -3852,7 +3900,12 @@ const AfterUploadControlConfig: React.FC<{
|
|||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-red-500" onClick={() => removeFlow(selected.flowId)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 text-red-500"
|
||||
onClick={() => removeFlow(selected.flowId)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -3861,7 +3914,6 @@ const AfterUploadControlConfig: React.FC<{
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 제어 추가 버튼 */}
|
||||
<Popover open={flowSelectOpen} onOpenChange={setFlowSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -3917,7 +3969,126 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
config: any;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
allComponents: ComponentData[];
|
||||
}> = ({ config, onUpdateProperty, allComponents }) => {
|
||||
currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달)
|
||||
}> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => {
|
||||
// 엑셀 업로드 설정 상태 관리
|
||||
const [excelUploadConfig, setExcelUploadConfig] = useState<{
|
||||
numberingRuleId?: string;
|
||||
numberingTargetColumn?: string;
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
}>({
|
||||
numberingRuleId: config.action?.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
||||
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
||||
});
|
||||
|
||||
// 분할 패널 감지
|
||||
const splitPanelInfo = useMemo(() => {
|
||||
const findSplitPanel = (components: any[]): any => {
|
||||
for (const comp of components) {
|
||||
const compId = comp.componentId || comp.componentType;
|
||||
if (compId === "split-panel-layout") {
|
||||
return comp.componentConfig;
|
||||
}
|
||||
if (comp.children && comp.children.length > 0) {
|
||||
const found = findSplitPanel(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findSplitPanel(allComponents as any[]);
|
||||
}, [allComponents]);
|
||||
|
||||
const hasSplitPanel = !!splitPanelInfo;
|
||||
|
||||
// 단일 테이블 감지 (props 우선, 없으면 컴포넌트에서 탐색)
|
||||
const singleTableName = useMemo(() => {
|
||||
if (hasSplitPanel) return undefined;
|
||||
|
||||
// props로 전달된 테이블명 우선 사용
|
||||
if (propTableName) return propTableName;
|
||||
|
||||
// 컴포넌트에서 테이블명 탐색
|
||||
const findTableName = (components: any[]): string | undefined => {
|
||||
for (const comp of components) {
|
||||
const compId = comp.componentId || comp.componentType;
|
||||
const compConfig = comp.componentConfig || comp.config || comp;
|
||||
|
||||
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기
|
||||
if (
|
||||
compId === "table-panel" ||
|
||||
compId === "data-table" ||
|
||||
compId === "table-list" ||
|
||||
compId === "simple-table"
|
||||
) {
|
||||
const tableName = compConfig?.tableName || compConfig?.table;
|
||||
if (tableName) return tableName;
|
||||
}
|
||||
|
||||
// 폼 컴포넌트에서 테이블명 찾기
|
||||
if (compId === "form-panel" || compId === "input-form" || compId === "form" || compId === "detail-form") {
|
||||
const tableName = compConfig?.tableName || compConfig?.table;
|
||||
if (tableName) return tableName;
|
||||
}
|
||||
|
||||
// 범용적으로 tableName 속성이 있는 컴포넌트 찾기
|
||||
if (compConfig?.tableName) {
|
||||
return compConfig.tableName;
|
||||
}
|
||||
|
||||
if (comp.children && comp.children.length > 0) {
|
||||
const found = findTableName(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return findTableName(allComponents as any[]);
|
||||
}, [allComponents, hasSplitPanel, propTableName]);
|
||||
|
||||
// 디버깅: 감지된 테이블명 로그
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
"[ExcelUploadConfigSection] 분할 패널:",
|
||||
hasSplitPanel,
|
||||
"단일 테이블:",
|
||||
singleTableName,
|
||||
"(props:",
|
||||
propTableName,
|
||||
")",
|
||||
);
|
||||
}, [hasSplitPanel, singleTableName, propTableName]);
|
||||
|
||||
// 설정 업데이트 함수
|
||||
const updateExcelUploadConfig = (updates: Partial<typeof excelUploadConfig>) => {
|
||||
const newConfig = { ...excelUploadConfig, ...updates };
|
||||
setExcelUploadConfig(newConfig);
|
||||
|
||||
if (updates.numberingRuleId !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId);
|
||||
}
|
||||
if (updates.numberingTargetColumn !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn);
|
||||
}
|
||||
if (updates.afterUploadFlows !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows);
|
||||
}
|
||||
};
|
||||
|
||||
// config 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setExcelUploadConfig({
|
||||
numberingRuleId: config.action?.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
||||
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
||||
});
|
||||
}, [
|
||||
config.action?.excelNumberingRuleId,
|
||||
config.action?.excelNumberingTargetColumn,
|
||||
config.action?.excelAfterUploadFlows,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">엑셀 업로드 설정</h4>
|
||||
|
|
@ -3955,6 +4126,17 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 규칙 설정 (항상 표시) */}
|
||||
<ExcelNumberingRuleConfig
|
||||
config={excelUploadConfig}
|
||||
updateConfig={updateExcelUploadConfig}
|
||||
tableName={singleTableName}
|
||||
hasSplitPanel={hasSplitPanel}
|
||||
/>
|
||||
|
||||
{/* 업로드 후 제어 실행 (항상 표시) */}
|
||||
<ExcelAfterUploadControlConfig config={excelUploadConfig} updateConfig={updateExcelUploadConfig} />
|
||||
|
||||
{/* 마스터-디테일 설정 (분할 패널 자동 감지) */}
|
||||
<MasterDetailExcelUploadConfig
|
||||
config={config}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
|
||||
import { UnifiedColumnInfo } from "@/types/table-management";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
|
@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps {
|
|||
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||
}
|
||||
|
||||
/**
|
||||
* 접을 수 있는 필터 항목 컴포넌트
|
||||
*/
|
||||
interface FilterItemCollapsibleProps {
|
||||
filter: ColumnFilter;
|
||||
index: number;
|
||||
filterSummary: string;
|
||||
onRemove: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
|
||||
filter,
|
||||
index,
|
||||
filterSummary,
|
||||
onRemove,
|
||||
children,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="rounded-lg border p-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
|
||||
{/* 상단: 필터 번호 + 삭제 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs font-medium">필터 {index + 1}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 shrink-0 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 하단: 필터 요약 (전체 너비 사용) */}
|
||||
<div className="mt-1 pl-4">
|
||||
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
|
||||
{filterSummary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터 필터 설정 패널
|
||||
* 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용
|
||||
|
|
@ -42,7 +104,7 @@ export function DataFilterConfigPanel({
|
|||
enabled: false,
|
||||
filters: [],
|
||||
matchType: "all",
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
|
||||
|
|
@ -69,7 +131,7 @@ export function DataFilterConfigPanel({
|
|||
return; // 이미 로드되었거나 로딩 중이면 스킵
|
||||
}
|
||||
|
||||
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
|
||||
setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
|
||||
|
||||
try {
|
||||
console.log("🔍 카테고리 값 로드 시작:", {
|
||||
|
|
@ -82,7 +144,7 @@ export function DataFilterConfigPanel({
|
|||
tableName,
|
||||
columnName,
|
||||
false, // includeInactive
|
||||
menuObjid // 🆕 메뉴 OBJID 전달
|
||||
menuObjid, // 🆕 메뉴 OBJID 전달
|
||||
);
|
||||
|
||||
console.log("📦 카테고리 값 로드 응답:", response);
|
||||
|
|
@ -94,14 +156,14 @@ export function DataFilterConfigPanel({
|
|||
}));
|
||||
|
||||
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
|
||||
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
|
||||
setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
|
||||
} else {
|
||||
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
|
||||
} finally {
|
||||
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
|
||||
setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -145,9 +207,7 @@ export function DataFilterConfigPanel({
|
|||
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((filter) =>
|
||||
filter.id === filterId ? { ...filter, [field]: value } : filter
|
||||
),
|
||||
filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
|
|
@ -178,7 +238,7 @@ export function DataFilterConfigPanel({
|
|||
<>
|
||||
{/* 테이블명 표시 */}
|
||||
{tableName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
테이블: <span className="font-medium">{tableName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -200,235 +260,127 @@ export function DataFilterConfigPanel({
|
|||
)}
|
||||
|
||||
{/* 필터 목록 */}
|
||||
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
{localConfig.filters.map((filter, index) => (
|
||||
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
필터 {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleRemoveFilter(filter.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[600px] space-y-2 overflow-y-auto pr-2">
|
||||
{localConfig.filters.map((filter, index) => {
|
||||
// 연산자 표시 텍스트
|
||||
const operatorLabels: Record<string, string> = {
|
||||
equals: "=",
|
||||
not_equals: "!=",
|
||||
greater_than: ">",
|
||||
less_than: "<",
|
||||
greater_than_or_equal: ">=",
|
||||
less_than_or_equal: "<=",
|
||||
between: "BETWEEN",
|
||||
in: "IN",
|
||||
not_in: "NOT IN",
|
||||
contains: "LIKE",
|
||||
starts_with: "시작",
|
||||
ends_with: "끝",
|
||||
is_null: "IS NULL",
|
||||
is_not_null: "IS NOT NULL",
|
||||
date_range_contains: "기간 내",
|
||||
};
|
||||
|
||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||
{filter.operator !== "date_range_contains" && (
|
||||
<div>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
// 컬럼 라벨 찾기
|
||||
const columnLabel =
|
||||
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName;
|
||||
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
// 필터 요약 텍스트 생성
|
||||
const filterSummary = filter.columnName
|
||||
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${
|
||||
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value
|
||||
? ` ${filter.value}`
|
||||
: ""
|
||||
}`
|
||||
: "설정 필요";
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, columnName: value, valueType, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({col.input_type})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연산자</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value: any) => {
|
||||
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
||||
if (value === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
handleFilterChange(filter.id, "operator", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
||||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||
<SelectItem value="is_null">NULL</SelectItem>
|
||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
💡 날짜 범위 필터링 규칙:
|
||||
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
||||
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
||||
<br />• 둘 다 있으면 → 기간 내 데이터만
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<FilterItemCollapsible
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
index={index}
|
||||
filterSummary={filterSummary}
|
||||
onRemove={() => handleRemoveFilter(filter.id)}
|
||||
>
|
||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||
{filter.operator !== "date_range_contains" && (
|
||||
<div>
|
||||
<Label className="text-xs">시작일 컬럼</Label>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.startColumn || ""}
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: value,
|
||||
endColumn: filter.rangeConfig?.endColumn || "",
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시작일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.filter(col =>
|
||||
col.dataType?.toLowerCase().includes('date') ||
|
||||
col.dataType?.toLowerCase().includes('time')
|
||||
).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종료일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.endColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: filter.rangeConfig?.startColumn || "",
|
||||
endColumn: value,
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="종료일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.filter(col =>
|
||||
col.dataType?.toLowerCase().includes('date') ||
|
||||
col.dataType?.toLowerCase().includes('time')
|
||||
).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
||||
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
||||
<div>
|
||||
<Label className="text-xs">값 타입</Label>
|
||||
<Select
|
||||
value={filter.valueType}
|
||||
onValueChange={(value: any) => {
|
||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "TODAY" }
|
||||
: f
|
||||
f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연산자</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value: any) => {
|
||||
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
||||
if (value === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f,
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
handleFilterChange(filter.id, "operator", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -436,106 +388,240 @@ export function DataFilterConfigPanel({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">직접 입력</SelectItem>
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||
)}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
<>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
</>
|
||||
)}
|
||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
||||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||
<SelectItem value="is_null">NULL</SelectItem>
|
||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||
{filter.operator !== "is_null" &&
|
||||
filter.operator !== "is_not_null" &&
|
||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs">
|
||||
💡 날짜 범위 필터링 규칙:
|
||||
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
||||
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
||||
<br />• 둘 다 있으면 → 기간 내 데이터만
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">시작일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.startColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: value,
|
||||
endColumn: filter.rangeConfig?.endColumn || "",
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시작일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.dataType?.toLowerCase().includes("date") ||
|
||||
col.dataType?.toLowerCase().includes("time"),
|
||||
)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종료일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.endColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: filter.rangeConfig?.startColumn || "",
|
||||
endColumn: value,
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="종료일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.dataType?.toLowerCase().includes("date") ||
|
||||
col.dataType?.toLowerCase().includes("time"),
|
||||
)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
||||
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
||||
<div>
|
||||
<Label className="text-xs">값 타입</Label>
|
||||
<Select
|
||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||
value={filter.valueType}
|
||||
onValueChange={(value: any) => {
|
||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={
|
||||
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
|
||||
} />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues[filter.columnName].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="static">직접 입력</SelectItem>
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||
)}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
<>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split(",").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values);
|
||||
}}
|
||||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : filter.operator === "between" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split("~").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
|
||||
}}
|
||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName]
|
||||
? "카테고리 값을 선택하세요"
|
||||
: filter.operator === "in" || filter.operator === "not_in"
|
||||
? "여러 값은 쉼표(,)로 구분하세요"
|
||||
: filter.operator === "between"
|
||||
? "시작과 종료 값을 ~로 구분하세요"
|
||||
: filter.operator === "date_range_contains"
|
||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||
: "필터링할 값을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<p className="text-[10px] text-blue-700">
|
||||
ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||
{filter.operator !== "is_null" &&
|
||||
filter.operator !== "is_not_null" &&
|
||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||
<Select
|
||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue
|
||||
placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues[filter.columnName].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split(",").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values);
|
||||
}}
|
||||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : filter.operator === "between" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split("~").map((v) => v.trim());
|
||||
handleFilterChange(
|
||||
filter.id,
|
||||
"value",
|
||||
values.length === 2 ? values : [values[0] || "", ""],
|
||||
);
|
||||
}}
|
||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||
placeholder={
|
||||
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
|
||||
}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
{filter.valueType === "category" && categoryValues[filter.columnName]
|
||||
? "카테고리 값을 선택하세요"
|
||||
: filter.operator === "in" || filter.operator === "not_in"
|
||||
? "여러 값은 쉼표(,)로 구분하세요"
|
||||
: filter.operator === "between"
|
||||
? "시작과 종료 값을 ~로 구분하세요"
|
||||
: filter.operator === "date_range_contains"
|
||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||
: "필터링할 값을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<p className="text-[10px] text-blue-700">오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</FilterItemCollapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 필터 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
|
||||
className="h-8 w-full text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleAddFilter}
|
||||
disabled={columns.length === 0}
|
||||
>
|
||||
|
|
@ -544,13 +630,10 @@ export function DataFilterConfigPanel({
|
|||
</Button>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
테이블을 먼저 선택해주세요
|
||||
</p>
|
||||
<p className="text-muted-foreground text-center text-xs">테이블을 먼저 선택해주세요</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||
|
||||
// 기본 색상 팔레트
|
||||
|
|
@ -51,6 +52,7 @@ export const CategoryValueAddDialog: React.FC<
|
|||
const [valueLabel, setValueLabel] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [color, setColor] = useState("none");
|
||||
const [continuousAdd, setContinuousAdd] = useState(false); // 연속 입력 체크박스
|
||||
|
||||
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
|
||||
const generateCode = (): string => {
|
||||
|
|
@ -60,6 +62,12 @@ export const CategoryValueAddDialog: React.FC<
|
|||
return `CATEGORY_${timestamp}${random}`;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setValueLabel("");
|
||||
setDescription("");
|
||||
setColor("none");
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!valueLabel.trim()) {
|
||||
return;
|
||||
|
|
@ -77,14 +85,28 @@ export const CategoryValueAddDialog: React.FC<
|
|||
isDefault: false,
|
||||
} as TableCategoryValue);
|
||||
|
||||
// 초기화
|
||||
setValueLabel("");
|
||||
setDescription("");
|
||||
setColor("none");
|
||||
// 연속 입력 체크되어 있으면 폼만 초기화하고 모달 유지
|
||||
if (continuousAdd) {
|
||||
resetForm();
|
||||
} else {
|
||||
// 연속 입력 아니면 모달 닫기
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
|
|
@ -165,24 +187,42 @@ export const CategoryValueAddDialog: React.FC<
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!valueLabel.trim()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
<DialogFooter className="flex-col gap-3 sm:flex-row sm:gap-0">
|
||||
{/* 연속 입력 체크박스 */}
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto sm:mr-auto">
|
||||
<Checkbox
|
||||
id="continuousAdd"
|
||||
checked={continuousAdd}
|
||||
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="continuousAdd"
|
||||
className="text-xs sm:text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
연속 입력
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!valueLabel.trim()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
|||
|
||||
if (response.success && response.data) {
|
||||
await loadCategoryValues();
|
||||
setIsAddDialogOpen(false);
|
||||
// 모달 닫기는 CategoryValueAddDialog에서 연속 입력 체크박스로 제어
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "카테고리 값이 추가되었습니다",
|
||||
|
|
@ -142,7 +142,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
|||
title: "오류",
|
||||
description: error.message || "카테고리 값 추가에 실패했습니다",
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
|||
interface ScreenContextValue {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
|
|
@ -39,6 +40,7 @@ const ScreenContext = createContext<ScreenContextValue | null>(null);
|
|||
interface ScreenContextProviderProps {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
menuObjid?: number; // 메뉴 OBJID
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
@ -49,6 +51,7 @@ interface ScreenContextProviderProps {
|
|||
export function ScreenContextProvider({
|
||||
screenId,
|
||||
tableName,
|
||||
menuObjid,
|
||||
splitPanelPosition,
|
||||
children,
|
||||
}: ScreenContextProviderProps) {
|
||||
|
|
@ -112,6 +115,7 @@ export function ScreenContextProvider({
|
|||
() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
menuObjid,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
|
|
@ -127,6 +131,7 @@ export function ScreenContextProvider({
|
|||
[
|
||||
screenId,
|
||||
tableName,
|
||||
menuObjid,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
|
|
|
|||
|
|
@ -281,10 +281,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
|
||||
let currentValue;
|
||||
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
|
||||
// EditModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
if (componentType === "modal-repeater-table" ||
|
||||
componentType === "repeat-screen-modal" ||
|
||||
componentType === "selected-items-detail-input") {
|
||||
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
|||
// 🆕 연관 데이터 버튼 컴포넌트
|
||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||
|
||||
// 🆕 피벗 그리드 컴포넌트
|
||||
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,213 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 컨텍스트 메뉴 컴포넌트
|
||||
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
ArrowUpAZ,
|
||||
ArrowDownAZ,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { PivotFieldConfig, AggregationType } from "../types";
|
||||
|
||||
interface PivotContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
// 현재 컨텍스트 정보
|
||||
cellType: "header" | "data" | "rowHeader" | "columnHeader";
|
||||
field?: PivotFieldConfig;
|
||||
rowPath?: string[];
|
||||
columnPath?: string[];
|
||||
value?: any;
|
||||
// 콜백
|
||||
onSort?: (field: string, direction: "asc" | "desc") => void;
|
||||
onFilter?: (field: string) => void;
|
||||
onExpand?: (path: string[]) => void;
|
||||
onCollapse?: (path: string[]) => void;
|
||||
onExpandAll?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
onCopy?: (value: any) => void;
|
||||
onHideField?: (field: string) => void;
|
||||
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
|
||||
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
|
||||
}
|
||||
|
||||
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
|
||||
children,
|
||||
cellType,
|
||||
field,
|
||||
rowPath,
|
||||
columnPath,
|
||||
value,
|
||||
onSort,
|
||||
onFilter,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
onCopy,
|
||||
onHideField,
|
||||
onChangeSummary,
|
||||
onDrillDown,
|
||||
}) => {
|
||||
const handleCopy = () => {
|
||||
if (value !== undefined && value !== null) {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
onCopy?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
{/* 정렬 옵션 (헤더에서만) */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
정렬
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
|
||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
||||
오름차순
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
|
||||
<ArrowDownAZ className="mr-2 h-4 w-4" />
|
||||
내림차순
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확장/축소 옵션 */}
|
||||
{(cellType === "rowHeader" || cellType === "columnHeader") && (
|
||||
<>
|
||||
{rowPath && rowPath.length > 0 && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
축소
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={onExpandAll}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />
|
||||
전체 확장
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onCollapseAll}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
전체 축소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필터 옵션 */}
|
||||
{field && onFilter && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onFilter(field.field)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
필터
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 집계 함수 변경 (데이터 필드에서만) */}
|
||||
{cellType === "data" && field && onChangeSummary && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
집계 함수
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "sum")}
|
||||
>
|
||||
합계
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "count")}
|
||||
>
|
||||
개수
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "avg")}
|
||||
>
|
||||
평균
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "min")}
|
||||
>
|
||||
최소
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onChangeSummary(field.field, "max")}
|
||||
>
|
||||
최대
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 드릴다운 (데이터 셀에서만) */}
|
||||
{cellType === "data" && rowPath && columnPath && onDrillDown && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세 데이터 보기
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필드 숨기기 */}
|
||||
{field && onHideField && (
|
||||
<ContextMenuItem onClick={() => onHideField(field.field)}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
필드 숨기기
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{/* 복사 */}
|
||||
<ContextMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotContextMenu;
|
||||
|
||||
|
|
@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
|||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||
];
|
||||
|
||||
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "그룹 없음" },
|
||||
{ value: "year", label: "년" },
|
||||
{ value: "quarter", label: "분기" },
|
||||
{ value: "month", label: "월" },
|
||||
{ value: "week", label: "주" },
|
||||
{ value: "day", label: "일" },
|
||||
];
|
||||
|
||||
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
string: <Type className="h-3.5 w-3.5" />,
|
||||
number: <Hash className="h-3.5 w-3.5" />,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
/**
|
||||
* FieldPanel 컴포넌트
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터)
|
||||
* 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터)
|
||||
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||
*/
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
|
||||
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
|
||||
"transition-colors duration-200",
|
||||
config.color,
|
||||
isOver && "border-primary bg-primary/5"
|
||||
|
|
@ -255,7 +255,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
|||
data-area={area}
|
||||
>
|
||||
{/* 영역 헤더 */}
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{areaFields.length > 0 && (
|
||||
|
|
@ -267,9 +267,9 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
|||
|
||||
{/* 필드 목록 */}
|
||||
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||
<div className="flex flex-wrap gap-1 min-h-[22px]">
|
||||
{areaFields.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50 italic">
|
||||
<span className="text-[10px] text-muted-foreground/50 italic">
|
||||
필드를 여기로 드래그
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -443,16 +443,42 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
|||
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||
: null;
|
||||
|
||||
// 각 영역의 필드 수 계산
|
||||
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
|
||||
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
|
||||
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
|
||||
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{filterCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Filter className="h-3 w-3" />
|
||||
필터 {filterCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Columns className="h-3 w-3" />
|
||||
열 {columnCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Rows className="h-3 w-3" />
|
||||
행 {rowCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
데이터 {dataCount}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs"
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
필드 패널 펼치기
|
||||
필드 설정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -466,9 +492,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
|||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="border-b border-border bg-muted/20 p-3">
|
||||
{/* 2x2 그리드로 영역 배치 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="border-b border-border bg-muted/20 p-2">
|
||||
{/* 4개 영역 배치: 2x2 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 필터 영역 */}
|
||||
<DroppableArea
|
||||
area="filter"
|
||||
|
|
@ -516,12 +542,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
|||
|
||||
{/* 접기 버튼 */}
|
||||
{onToggleCollapse && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<div className="flex justify-center mt-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-xs h-6"
|
||||
className="text-xs h-5 px-2"
|
||||
>
|
||||
필드 패널 접기
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
|
|||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ export interface PivotFieldConfig {
|
|||
// 계층 관련
|
||||
displayFolder?: string; // 필드 선택기에서 폴더 구조
|
||||
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
|
||||
|
||||
// 계산 필드
|
||||
isCalculated?: boolean; // 계산 필드 여부
|
||||
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
|
||||
}
|
||||
|
||||
// ==================== 데이터 소스 설정 ====================
|
||||
|
|
@ -140,11 +144,13 @@ export interface PivotTotalsConfig {
|
|||
showRowGrandTotals?: boolean; // 행 총합계 표시
|
||||
showRowTotals?: boolean; // 행 소계 표시
|
||||
rowTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
|
||||
|
||||
// 열 총합계
|
||||
showColumnGrandTotals?: boolean; // 열 총합계 표시
|
||||
showColumnTotals?: boolean; // 열 소계 표시
|
||||
columnTotalsPosition?: "first" | "last"; // 소계 위치
|
||||
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
|
||||
}
|
||||
|
||||
// 필드 선택기 설정
|
||||
|
|
@ -214,6 +220,7 @@ export interface PivotStyleConfig {
|
|||
alternateRowColors?: boolean;
|
||||
highlightTotals?: boolean; // 총합계 강조
|
||||
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
|
||||
mergeCells?: boolean; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
|
|
|||
|
|
@ -298,11 +298,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
warehouseName: fieldMapping.warehouseNameField
|
||||
? formData[fieldMapping.warehouseNameField]
|
||||
: undefined,
|
||||
// 카테고리 값은 라벨로 변환
|
||||
// 카테고리 값은 라벨로 변환 (화면 표시용)
|
||||
floor: getCategoryLabel(rawFloor?.toString()),
|
||||
zone: getCategoryLabel(rawZone),
|
||||
locationType: getCategoryLabel(rawLocationType),
|
||||
status: getCategoryLabel(rawStatus),
|
||||
// 카테고리 코드 원본값 (DB 쿼리/저장용)
|
||||
floorCode: rawFloor?.toString(),
|
||||
zoneCode: rawZone?.toString(),
|
||||
locationTypeCode: rawLocationType?.toString(),
|
||||
statusCode: rawStatus?.toString(),
|
||||
};
|
||||
|
||||
console.log("🏗️ [RackStructure] context 생성:", {
|
||||
|
|
@ -399,8 +404,12 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
|
||||
// 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지)
|
||||
const warehouseCodeForQuery = context.warehouseCode;
|
||||
const floorForQuery = context.floor; // 라벨 값 (예: "1층")
|
||||
const zoneForQuery = context.zone; // 라벨 값 (예: "A구역")
|
||||
// DB 쿼리 시에는 카테고리 코드 사용 (코드로 통일)
|
||||
const floorForQuery = (context as any).floorCode || context.floor;
|
||||
const zoneForQuery = (context as any).zoneCode || context.zone;
|
||||
// 화면 표시용 라벨
|
||||
const floorLabel = context.floor;
|
||||
const zoneLabel = context.zone;
|
||||
|
||||
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
|
||||
useEffect(() => {
|
||||
|
|
@ -426,7 +435,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
|
||||
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
|
||||
const searchParams = {
|
||||
warehouse_id: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
warehouse_code: { value: warehouseCodeForQuery, operator: "equals" },
|
||||
floor: { value: floorForQuery, operator: "equals" },
|
||||
zone: { value: zoneForQuery, operator: "equals" },
|
||||
};
|
||||
|
|
@ -597,18 +606,20 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
for (let level = 1; level <= cond.levels; level++) {
|
||||
const { code, name } = generateLocationCode(row, level);
|
||||
// 테이블 컬럼명과 동일하게 생성
|
||||
// DB 저장 시에는 카테고리 코드 사용 (코드로 통일)
|
||||
const ctxAny = context as any;
|
||||
locations.push({
|
||||
row_num: String(row),
|
||||
level_num: String(level),
|
||||
location_code: code,
|
||||
location_name: name,
|
||||
location_type: context?.locationType || "선반",
|
||||
status: context?.status || "사용",
|
||||
// 추가 필드 (테이블 컬럼명과 동일)
|
||||
location_type: ctxAny?.locationTypeCode || context?.locationType || "선반",
|
||||
status: ctxAny?.statusCode || context?.status || "사용",
|
||||
// 추가 필드 (테이블 컬럼명과 동일) - 카테고리 코드 사용
|
||||
warehouse_code: context?.warehouseCode,
|
||||
warehouse_name: context?.warehouseName,
|
||||
floor: context?.floor,
|
||||
zone: context?.zone,
|
||||
floor: ctxAny?.floorCode || context?.floor,
|
||||
zone: ctxAny?.zoneCode || context?.zone,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -930,13 +941,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
||||
<TableCell>{loc.location_name}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
|
||||
{/* 미리보기에서는 카테고리 코드를 라벨로 변환하여 표시 */}
|
||||
<TableCell className="text-center">{getCategoryLabel(loc.floor) || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{getCategoryLabel(loc.zone) || context?.zone || "A"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{loc.row_num.padStart(2, "0")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
<TableCell className="text-center">{loc.location_type}</TableCell>
|
||||
<TableCell className="text-center">{getCategoryLabel(loc.location_type) || loc.location_type}</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -72,10 +72,15 @@ export interface RackStructureContext {
|
|||
warehouseId?: string; // 창고 ID
|
||||
warehouseCode?: string; // 창고 코드 (예: WH001)
|
||||
warehouseName?: string; // 창고명 (예: 제1창고)
|
||||
floor?: string; // 층 (예: 1)
|
||||
zone?: string; // 구역 (예: A)
|
||||
locationType?: string; // 위치 유형 (예: 선반)
|
||||
status?: string; // 사용 여부 (예: 사용)
|
||||
floor?: string; // 층 라벨 (예: 1층) - 화면 표시용
|
||||
zone?: string; // 구역 라벨 (예: A구역) - 화면 표시용
|
||||
locationType?: string; // 위치 유형 라벨 (예: 선반)
|
||||
status?: string; // 사용 여부 라벨 (예: 사용)
|
||||
// 카테고리 코드 (DB 저장/쿼리용)
|
||||
floorCode?: string; // 층 카테고리 코드 (예: CATEGORY_767659DCUF)
|
||||
zoneCode?: string; // 구역 카테고리 코드 (예: CATEGORY_82925656Q8)
|
||||
locationTypeCode?: string; // 위치 유형 카테고리 코드
|
||||
statusCode?: string; // 사용 여부 카테고리 코드
|
||||
}
|
||||
|
||||
// 컴포넌트 Props
|
||||
|
|
|
|||
|
|
@ -285,11 +285,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
|
||||
// onChange 호출하여 부모에게 알림
|
||||
if (onChange && items.length > 0) {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: targetTable,
|
||||
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
|
||||
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
}
|
||||
|
|
@ -388,10 +391,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// onChange 호출 (effectiveTargetTable 사용)
|
||||
if (onChange) {
|
||||
if (items.length > 0) {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
_existingRecord: !!item.id,
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
} else {
|
||||
|
|
@ -673,26 +679,25 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||
const handleRepeaterChange = useCallback(
|
||||
(newValue: any[]) => {
|
||||
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||
let valueWithMeta = newValue;
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
|
||||
|
||||
if (isRightPanel && effectiveTargetTable) {
|
||||
valueWithMeta = newValue.map((item: any) => {
|
||||
const itemWithMeta = {
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
};
|
||||
// 🆕 모든 항목에 메타데이터 추가
|
||||
let valueWithMeta = newValue.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable || targetTable,
|
||||
_existingRecord: !!item.id,
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
}));
|
||||
|
||||
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||
if (fkColumn && fkValue && item._isNewItem) {
|
||||
itemWithMeta[fkColumn] = fkValue;
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||
fkColumn,
|
||||
fkValue,
|
||||
});
|
||||
// 🆕 분할 패널에서 우측인 경우, FK 값 추가
|
||||
if (isRightPanel && fkColumn && fkValue) {
|
||||
valueWithMeta = valueWithMeta.map((item: any) => {
|
||||
if (item._isNewItem) {
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { fkColumn, fkValue });
|
||||
return { ...item, [fkColumn]: fkValue };
|
||||
}
|
||||
|
||||
return itemWithMeta;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -754,6 +759,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
screenContext?.updateFormData,
|
||||
isRightPanel,
|
||||
effectiveTargetTable,
|
||||
targetTable,
|
||||
fkColumn,
|
||||
fkValue,
|
||||
fieldName,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
screenId,
|
||||
...props
|
||||
}) => {
|
||||
// 🆕 groupedData 추출 (DynamicComponentRenderer에서 전달)
|
||||
const groupedData = (props as any).groupedData || (props as any)._groupedData;
|
||||
// 🆕 URL 파라미터에서 dataSourceId 읽기
|
||||
const searchParams = useSearchParams();
|
||||
const urlDataSourceId = searchParams?.get("dataSourceId") || undefined;
|
||||
|
|
@ -225,24 +227,32 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
|
||||
useEffect(() => {
|
||||
// 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면)
|
||||
// 🆕 수정 모드: groupedData 또는 formData에서 데이터 로드 (URL에 mode=edit이 있으면)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get("mode");
|
||||
|
||||
if (mode === "edit" && formData) {
|
||||
// 🔧 데이터 소스 우선순위: groupedData > formData (배열) > formData (객체)
|
||||
const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0
|
||||
? groupedData
|
||||
: formData;
|
||||
|
||||
if (mode === "edit" && sourceData) {
|
||||
// 배열인지 단일 객체인지 확인
|
||||
const isArray = Array.isArray(formData);
|
||||
const dataArray = isArray ? formData : [formData];
|
||||
const isArray = Array.isArray(sourceData);
|
||||
const dataArray = isArray ? sourceData : [sourceData];
|
||||
|
||||
if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) {
|
||||
console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음");
|
||||
console.warn("⚠️ [SelectedItemsDetailInput] 데이터가 비어있음");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? "그룹 레코드" : "단일 레코드"} (${dataArray.length}개)`,
|
||||
);
|
||||
console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2));
|
||||
console.log("📝 [SelectedItemsDetailInput] 데이터 소스:", {
|
||||
fromGroupedData: groupedData && Array.isArray(groupedData) && groupedData.length > 0,
|
||||
dataArray: JSON.stringify(dataArray, null, 2),
|
||||
});
|
||||
|
||||
const groups = componentConfig.fieldGroups || [];
|
||||
const additionalFields = componentConfig.additionalFields || [];
|
||||
|
|
@ -423,7 +433,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalData, component.id, componentConfig.fieldGroups, formData]); // formData 의존성 추가
|
||||
}, [modalData, component.id, componentConfig.fieldGroups, formData, groupedData]); // groupedData 의존성 추가
|
||||
|
||||
// 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성)
|
||||
const generateCartesianProduct = useCallback(
|
||||
|
|
|
|||
|
|
@ -185,6 +185,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [rightCategoryMappings, setRightCategoryMappings] = useState<
|
||||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({}); // 우측 카테고리 매핑
|
||||
|
||||
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
|
||||
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// 추가 모달 상태
|
||||
|
|
@ -713,6 +717,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
|
||||
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
|
||||
const cachedLabel = categoryCodeLabels[value];
|
||||
if (cachedLabel) {
|
||||
return <span className="text-sm">{cachedLabel}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
|
||||
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
|
||||
return formatDateValue(value, "YYYY-MM-DD");
|
||||
|
|
@ -734,7 +746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 일반 값
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue],
|
||||
[formatDateValue, formatNumberValue, categoryCodeLabels],
|
||||
);
|
||||
|
||||
// 좌측 데이터 로드
|
||||
|
|
@ -1014,10 +1026,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 추가 dataFilter 적용
|
||||
let filteredData = result.data || [];
|
||||
const dataFilter = componentConfig.rightPanel?.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
// filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
|
||||
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
|
||||
if (dataFilter?.enabled && filterConditions.length > 0) {
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
return filterConditions.every((cond: any) => {
|
||||
// columnName 또는 column 지원
|
||||
const columnName = cond.columnName || cond.column;
|
||||
const value = item[columnName];
|
||||
const condValue = cond.value;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
|
|
@ -1026,6 +1042,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return value !== condValue;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
case "is_null":
|
||||
case "NULL":
|
||||
return value === null || value === undefined || value === "";
|
||||
case "is_not_null":
|
||||
case "NOT NULL":
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1079,11 +1101,74 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
],
|
||||
);
|
||||
|
||||
// 🆕 카테고리 코드 라벨 로드 (rightData 변경 시)
|
||||
useEffect(() => {
|
||||
const loadCategoryCodeLabels = async () => {
|
||||
if (!rightData) return;
|
||||
|
||||
const categoryCodes = new Set<string>();
|
||||
|
||||
// rightData가 배열인 경우 (조인 모드)
|
||||
const dataArray = Array.isArray(rightData) ? rightData : [rightData];
|
||||
|
||||
dataArray.forEach((row: Record<string, any>) => {
|
||||
if (row) {
|
||||
Object.values(row).forEach((value) => {
|
||||
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
|
||||
categoryCodes.add(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
|
||||
const newCodes = Array.from(categoryCodes).filter((code) => !categoryCodeLabels[code]);
|
||||
|
||||
if (newCodes.length > 0) {
|
||||
try {
|
||||
console.log("🏷️ [SplitPanel] 카테고리 코드 라벨 조회:", newCodes);
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
|
||||
if (response.data.success && response.data.data) {
|
||||
console.log("🏷️ [SplitPanel] 카테고리 라벨 조회 결과:", response.data.data);
|
||||
setCategoryCodeLabels((prev) => ({
|
||||
...prev,
|
||||
...response.data.data,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryCodeLabels();
|
||||
}, [rightData]);
|
||||
|
||||
// 🆕 추가 탭 데이터 로딩 함수
|
||||
const loadTabData = useCallback(
|
||||
async (tabIndex: number, leftItem: any) => {
|
||||
console.log(`📥 loadTabData 호출됨: tabIndex=${tabIndex}`, {
|
||||
leftItem: leftItem ? Object.keys(leftItem) : null,
|
||||
additionalTabs: componentConfig.rightPanel?.additionalTabs?.length,
|
||||
isDesignMode,
|
||||
});
|
||||
|
||||
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||
if (!tabConfig || !leftItem || isDesignMode) return;
|
||||
|
||||
console.log(`📥 tabConfig:`, {
|
||||
tabIndex,
|
||||
configIndex: tabIndex - 1,
|
||||
tabConfig: tabConfig ? {
|
||||
tableName: tabConfig.tableName,
|
||||
relation: tabConfig.relation,
|
||||
dataFilter: tabConfig.dataFilter
|
||||
} : null,
|
||||
});
|
||||
|
||||
if (!tabConfig || !leftItem || isDesignMode) {
|
||||
console.log(`⚠️ loadTabData 중단:`, { hasTabConfig: !!tabConfig, hasLeftItem: !!leftItem, isDesignMode });
|
||||
return;
|
||||
}
|
||||
|
||||
const tabTableName = tabConfig.tableName;
|
||||
if (!tabTableName) return;
|
||||
|
|
@ -1095,6 +1180,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||
|
||||
console.log(`🔑 [추가탭 ${tabIndex}] 조인 키 분석:`, {
|
||||
hasRelation: !!tabConfig.relation,
|
||||
keys,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
willUseJoin: !!(leftColumn && rightColumn),
|
||||
});
|
||||
|
||||
let resultData: any[] = [];
|
||||
|
||||
if (leftColumn && rightColumn) {
|
||||
|
|
@ -1106,14 +1199,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 복합키
|
||||
keys.forEach((key) => {
|
||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
||||
// operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색)
|
||||
searchConditions[key.rightColumn] = {
|
||||
value: leftItem[key.leftColumn],
|
||||
operator: "equals",
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 단일키
|
||||
const leftValue = leftItem[leftColumn];
|
||||
if (leftValue !== undefined) {
|
||||
searchConditions[rightColumn] = leftValue;
|
||||
// operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색)
|
||||
searchConditions[rightColumn] = {
|
||||
value: leftValue,
|
||||
operator: "equals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1128,33 +1229,68 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
resultData = result.data || [];
|
||||
} else {
|
||||
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
|
||||
console.log(`📋 [추가탭 ${tabIndex}] 조인 없이 전체 데이터 조회: ${tabTableName}`);
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||
enableEntityJoin: true,
|
||||
size: 1000,
|
||||
});
|
||||
resultData = result.data || [];
|
||||
console.log(`📋 [추가탭 ${tabIndex}] 전체 데이터 조회 결과:`, resultData.length);
|
||||
}
|
||||
|
||||
// 데이터 필터 적용
|
||||
const dataFilter = tabConfig.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
// filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
|
||||
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
|
||||
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 설정:`, {
|
||||
enabled: dataFilter?.enabled,
|
||||
filterConditions,
|
||||
dataBeforeFilter: resultData.length,
|
||||
});
|
||||
|
||||
if (dataFilter?.enabled && filterConditions.length > 0) {
|
||||
const beforeCount = resultData.length;
|
||||
resultData = resultData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
return filterConditions.every((cond: any) => {
|
||||
// columnName 또는 column 지원
|
||||
const columnName = cond.columnName || cond.column;
|
||||
const value = item[columnName];
|
||||
const condValue = cond.value;
|
||||
|
||||
let result = true;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === condValue;
|
||||
result = value === condValue;
|
||||
break;
|
||||
case "notEquals":
|
||||
return value !== condValue;
|
||||
result = value !== condValue;
|
||||
break;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
result = String(value).includes(String(condValue));
|
||||
break;
|
||||
case "is_null":
|
||||
case "NULL":
|
||||
result = value === null || value === undefined || value === "";
|
||||
break;
|
||||
case "is_not_null":
|
||||
case "NOT NULL":
|
||||
result = value !== null && value !== undefined && value !== "";
|
||||
break;
|
||||
default:
|
||||
return true;
|
||||
result = true;
|
||||
}
|
||||
|
||||
// 첫 5개 항목만 로그 출력
|
||||
if (resultData.indexOf(item) < 5) {
|
||||
console.log(` 필터 체크: ${columnName}=${value}, operator=${cond.operator}, result=${result}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 적용 후: ${beforeCount} → ${resultData.length}`);
|
||||
}
|
||||
|
||||
// 중복 제거 적용
|
||||
|
|
@ -1226,6 +1362,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 🆕 탭 변경 핸들러
|
||||
const handleTabChange = useCallback(
|
||||
(newTabIndex: number) => {
|
||||
console.log(`🔄 탭 변경: ${activeTabIndex} → ${newTabIndex}`, {
|
||||
selectedLeftItem: !!selectedLeftItem,
|
||||
tabsData: Object.keys(tabsData),
|
||||
hasTabData: !!tabsData[newTabIndex],
|
||||
});
|
||||
|
||||
setActiveTabIndex(newTabIndex);
|
||||
|
||||
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
|
||||
|
|
@ -1236,14 +1378,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
} else {
|
||||
// 추가 탭: 해당 탭 데이터가 없으면 로드
|
||||
if (!tabsData[newTabIndex]) {
|
||||
loadTabData(newTabIndex, selectedLeftItem);
|
||||
}
|
||||
// 추가 탭: 항상 새로 로드 (필터 설정 변경 반영을 위해)
|
||||
console.log(`🔄 추가 탭 ${newTabIndex} 데이터 로드 (항상 새로고침)`);
|
||||
loadTabData(newTabIndex, selectedLeftItem);
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ 좌측 항목이 선택되지 않아 탭 데이터를 로드하지 않음`);
|
||||
}
|
||||
},
|
||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, activeTabIndex],
|
||||
);
|
||||
|
||||
// 우측 항목 확장/축소 토글
|
||||
|
|
|
|||
|
|
@ -237,7 +237,12 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
// 탭 업데이트 헬퍼
|
||||
const updateTab = (updates: Partial<AdditionalTabConfig>) => {
|
||||
const newTabs = [...(config.rightPanel?.additionalTabs || [])];
|
||||
newTabs[tabIndex] = { ...tab, ...updates };
|
||||
// undefined 값도 명시적으로 덮어쓰기 위해 Object.assign 대신 직접 처리
|
||||
const updatedTab = { ...tab };
|
||||
Object.keys(updates).forEach((key) => {
|
||||
(updatedTab as any)[key] = (updates as any)[key];
|
||||
});
|
||||
newTabs[tabIndex] = updatedTab;
|
||||
updateRightPanel({ additionalTabs: newTabs });
|
||||
};
|
||||
|
||||
|
|
@ -393,21 +398,31 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">좌측 컬럼</Label>
|
||||
<Select
|
||||
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || ""}
|
||||
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"}
|
||||
onValueChange={(value) => {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
|
||||
},
|
||||
});
|
||||
if (value === "__none__") {
|
||||
// 선택 안 함 - 조인 키 제거
|
||||
updateTab({
|
||||
relation: undefined,
|
||||
});
|
||||
} else {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터)</span>
|
||||
</SelectItem>
|
||||
{leftTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
|
|
@ -419,21 +434,31 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">우측 컬럼</Label>
|
||||
<Select
|
||||
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || ""}
|
||||
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"}
|
||||
onValueChange={(value) => {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
|
||||
},
|
||||
});
|
||||
if (value === "__none__") {
|
||||
// 선택 안 함 - 조인 키 제거
|
||||
updateTab({
|
||||
relation: undefined,
|
||||
});
|
||||
} else {
|
||||
updateTab({
|
||||
relation: {
|
||||
...tab.relation,
|
||||
type: "join",
|
||||
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터)</span>
|
||||
</SelectItem>
|
||||
{tabColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// 숨김 상태 (props에서 전달받은 값 우선 사용)
|
||||
const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false;
|
||||
|
||||
// 수정 모드 여부 확인 (originalData가 있으면 수정 모드)
|
||||
const originalData = props.originalData || (props as any)._originalData;
|
||||
const isEditMode = originalData && Object.keys(originalData).length > 0;
|
||||
|
||||
// 자동생성된 값 상태
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||
|
||||
|
|
@ -99,6 +103,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 수정 모드일 때는 채번 규칙 스킵 (기존 값 유지)
|
||||
if (isEditMode) {
|
||||
console.log("⏭️ 수정 모드 - 채번 규칙 스킵:", {
|
||||
columnName: component.columnName,
|
||||
originalValue: originalData?.[component.columnName],
|
||||
});
|
||||
hasGeneratedRef.current = true; // 생성 완료로 표시하여 재실행 방지
|
||||
return;
|
||||
}
|
||||
|
||||
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
|
||||
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
|
||||
const currentFormValue = formData?.[component.columnName];
|
||||
|
|
@ -171,7 +185,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
};
|
||||
|
||||
generateAutoValue();
|
||||
}, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive]);
|
||||
}, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive, isEditMode]);
|
||||
|
||||
// 실제 화면에서 숨김 처리된 컴포넌트는 렌더링하지 않음
|
||||
if (isHidden && !isDesignMode) {
|
||||
|
|
|
|||
|
|
@ -398,6 +398,9 @@ export function TableSectionRenderer({
|
|||
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
|
||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// 카테고리 타입 컬럼의 옵션 (column.type === "category")
|
||||
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||
|
||||
// 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정
|
||||
useEffect(() => {
|
||||
// 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵
|
||||
|
|
@ -511,6 +514,46 @@ export function TableSectionRenderer({
|
|||
loadColumnLabels();
|
||||
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
|
||||
|
||||
// 카테고리 타입 컬럼의 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryOptions = async () => {
|
||||
const sourceTableName = tableConfig.source.tableName;
|
||||
if (!sourceTableName) return;
|
||||
if (!tableConfig.columns) return;
|
||||
|
||||
// 카테고리 타입인 컬럼만 필터링
|
||||
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
|
||||
if (categoryColumns.length === 0) return;
|
||||
|
||||
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
||||
|
||||
for (const col of categoryColumns) {
|
||||
// 소스 필드 또는 필드명으로 카테고리 값 조회
|
||||
const actualColumnName = col.sourceField || col.field;
|
||||
if (!actualColumnName) continue;
|
||||
|
||||
try {
|
||||
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
|
||||
|
||||
if (result && result.success && Array.isArray(result.data)) {
|
||||
const options = result.data.map((item: any) => ({
|
||||
value: item.valueCode || item.value_code || item.value || "",
|
||||
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
|
||||
}));
|
||||
newOptionsMap[col.field] = options;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 옵션 로드 실패 (${col.field}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
||||
};
|
||||
|
||||
loadCategoryOptions();
|
||||
}, [tableConfig.source.tableName, tableConfig.columns]);
|
||||
|
||||
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
||||
useEffect(() => {
|
||||
if (!isConditionalMode) return;
|
||||
|
|
@ -952,9 +995,15 @@ export function TableSectionRenderer({
|
|||
baseColumn.selectOptions = dynamicSelectOptionsMap[col.field];
|
||||
}
|
||||
|
||||
// 카테고리 타입인 경우 옵션 적용 및 select 타입으로 변환
|
||||
if (col.type === "category" && categoryOptionsMap[col.field]) {
|
||||
baseColumn.type = "select"; // RepeaterTable에서 select로 렌더링
|
||||
baseColumn.selectOptions = categoryOptionsMap[col.field];
|
||||
}
|
||||
|
||||
return baseColumn;
|
||||
});
|
||||
}, [tableConfig.columns, dynamicSelectOptionsMap]);
|
||||
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
||||
|
||||
// 원본 계산 규칙 (조건부 계산 포함)
|
||||
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||
|
|
|
|||
|
|
@ -308,12 +308,29 @@ export function UniversalFormModalConfigPanel({
|
|||
column_comment?: string;
|
||||
inputType?: string;
|
||||
input_type?: string;
|
||||
}) => ({
|
||||
name: c.columnName || c.column_name || "",
|
||||
type: c.dataType || c.data_type || "text",
|
||||
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
|
||||
inputType: c.inputType || c.input_type || "text",
|
||||
}),
|
||||
isNullable?: string;
|
||||
is_nullable?: string;
|
||||
}) => {
|
||||
const colName = c.columnName || c.column_name || "";
|
||||
const dataType = c.dataType || c.data_type || "text";
|
||||
const inputType = c.inputType || c.input_type || "text";
|
||||
const displayName = c.displayName || c.columnComment || c.column_comment || colName;
|
||||
const isNullable = c.isNullable || c.is_nullable || "YES";
|
||||
|
||||
return {
|
||||
// camelCase (기존 호환성)
|
||||
name: colName,
|
||||
type: dataType,
|
||||
label: displayName,
|
||||
inputType: inputType,
|
||||
// snake_case (TableSectionSettingsModal 호환성)
|
||||
column_name: colName,
|
||||
data_type: dataType,
|
||||
is_nullable: isNullable,
|
||||
comment: displayName,
|
||||
input_type: inputType,
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,12 +48,12 @@ interface TableColumnSettingsModalProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
column: TableColumnConfig;
|
||||
sourceTableName: string; // 소스 테이블명
|
||||
sourceTableColumns: { column_name: string; data_type: string; comment?: string }[];
|
||||
sourceTableColumns: { column_name: string; data_type: string; comment?: string; input_type?: string }[];
|
||||
formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함)
|
||||
sections: { id: string; title: string }[]; // 섹션 목록
|
||||
onSave: (updatedColumn: TableColumnConfig) => void;
|
||||
tables: { table_name: string; comment?: string }[];
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]>;
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +103,18 @@ export function TableColumnSettingsModal({
|
|||
return tableColumns[externalTableName] || [];
|
||||
}, [tableColumns, externalTableName]);
|
||||
|
||||
// 소스 필드 기준으로 카테고리 타입인지 확인
|
||||
const actualSourceField = localColumn.sourceField || localColumn.field;
|
||||
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
|
||||
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
|
||||
|
||||
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
|
||||
useEffect(() => {
|
||||
if (isCategoryColumn && localColumn.type !== "category") {
|
||||
updateColumn({ type: "category" });
|
||||
}
|
||||
}, [isCategoryColumn, localColumn.type]);
|
||||
|
||||
// 컬럼 업데이트 함수
|
||||
const updateColumn = (updates: Partial<TableColumnConfig>) => {
|
||||
setLocalColumn((prev) => ({ ...prev, ...updates }));
|
||||
|
|
@ -574,10 +586,11 @@ export function TableColumnSettingsModal({
|
|||
<div>
|
||||
<Label className="text-xs">타입</Label>
|
||||
<Select
|
||||
value={localColumn.type}
|
||||
value={isCategoryColumn ? "category" : localColumn.type}
|
||||
onValueChange={(value: any) => updateColumn({ type: value })}
|
||||
disabled={isCategoryColumn}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<SelectTrigger className={cn("h-8 text-xs mt-1", isCategoryColumn && "opacity-70 cursor-not-allowed")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -588,6 +601,9 @@ export function TableColumnSettingsModal({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCategoryColumn && (
|
||||
<p className="text-[10px] text-blue-600 mt-0.5">테이블 타입 관리에서 카테고리로 설정됨</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">너비</Label>
|
||||
|
|
|
|||
|
|
@ -706,15 +706,15 @@ interface ColumnSettingItemProps {
|
|||
col: TableColumnConfig;
|
||||
index: number;
|
||||
totalCount: number;
|
||||
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[];
|
||||
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[];
|
||||
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
|
||||
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
|
||||
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]; // 소스 테이블 컬럼
|
||||
sourceTableName: string; // 소스 테이블명
|
||||
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 외부 데이터 테이블 컬럼
|
||||
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]; // 외부 데이터 테이블 컬럼
|
||||
externalTableName?: string; // 외부 데이터 테이블명
|
||||
externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
|
||||
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>; // 테이블별 컬럼
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]>; // 테이블별 컬럼
|
||||
sections: { id: string; title: string }[]; // 섹션 목록
|
||||
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
|
||||
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
|
||||
|
|
@ -755,6 +755,18 @@ function ColumnSettingItem({
|
|||
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
|
||||
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 소스 필드 기준으로 카테고리 타입인지 확인
|
||||
const actualSourceField = col.sourceField || col.field;
|
||||
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
|
||||
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
|
||||
|
||||
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
|
||||
useEffect(() => {
|
||||
if (isCategoryColumn && col.type !== "category") {
|
||||
onUpdate({ type: "category" });
|
||||
}
|
||||
}, [isCategoryColumn, col.type, onUpdate]);
|
||||
|
||||
// 조회 옵션 추가
|
||||
const addLookupOption = () => {
|
||||
const newOption: LookupOption = {
|
||||
|
|
@ -1117,8 +1129,12 @@ function ColumnSettingItem({
|
|||
{/* 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">타입</Label>
|
||||
<Select value={col.type} onValueChange={(value: any) => onUpdate({ type: value })}>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<Select
|
||||
value={isCategoryColumn ? "category" : col.type}
|
||||
onValueChange={(value: any) => onUpdate({ type: value })}
|
||||
disabled={isCategoryColumn}
|
||||
>
|
||||
<SelectTrigger className={cn("h-8 text-xs mt-1", isCategoryColumn && "opacity-70 cursor-not-allowed")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1129,6 +1145,9 @@ function ColumnSettingItem({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCategoryColumn && (
|
||||
<p className="text-[10px] text-blue-600 mt-0.5">테이블 타입 관리에서 카테고리로 설정됨</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
|
|
|
|||
|
|
@ -899,6 +899,7 @@ export const TABLE_COLUMN_TYPE_OPTIONS = [
|
|||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "select", label: "선택(드롭다운)" },
|
||||
{ value: "category", label: "카테고리" },
|
||||
] as const;
|
||||
|
||||
// 값 매핑 타입 옵션
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ export interface ButtonActionConfig {
|
|||
// 엑셀 업로드 관련
|
||||
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
|
||||
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
|
||||
excelNumberingRuleId?: string; // 채번 규칙 ID (단일 테이블용)
|
||||
excelNumberingTargetColumn?: string; // 채번 적용 컬럼 (단일 테이블용)
|
||||
excelAfterUploadFlows?: Array<{ flowId: string; order: number }>; // 업로드 후 제어 실행
|
||||
|
||||
// 바코드 스캔 관련
|
||||
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
||||
|
|
@ -687,6 +690,151 @@ export class ButtonActionExecutor {
|
|||
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
|
||||
}
|
||||
|
||||
// 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리
|
||||
// formData에 JSON 배열 문자열이 저장된 경우 처리 (반복_필드_그룹 등)
|
||||
const repeaterJsonKeys = Object.keys(context.formData).filter((key) => {
|
||||
const value = context.formData[key];
|
||||
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) && parsed.length > 0 && parsed[0]._targetTable;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (repeaterJsonKeys.length > 0) {
|
||||
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
|
||||
|
||||
// 🆕 상단 폼 데이터(마스터 정보) 추출
|
||||
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
|
||||
const masterFields: Record<string, any> = {};
|
||||
Object.keys(context.formData).forEach((fieldKey) => {
|
||||
// 제외 조건
|
||||
if (fieldKey.startsWith("comp_")) return;
|
||||
if (fieldKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) return;
|
||||
if (fieldKey.endsWith("_label") || fieldKey.endsWith("_value_label")) return;
|
||||
|
||||
const value = context.formData[fieldKey];
|
||||
|
||||
// JSON 배열 문자열 제외 (RepeaterFieldGroup 데이터)
|
||||
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) return;
|
||||
|
||||
// 객체 타입인 경우 (범용_폼_모달 등) 내부 필드를 펼쳐서 추가
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.entries(value).forEach(([innerKey, innerValue]) => {
|
||||
if (innerKey.endsWith("_label") || innerKey.endsWith("_value_label")) return;
|
||||
if (innerValue !== undefined && innerValue !== null && innerValue !== "") {
|
||||
masterFields[innerKey] = innerValue;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효한 값만 포함
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
masterFields[fieldKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("📋 [handleSave] 상단 마스터 정보 (모든 품목에 적용):", masterFields);
|
||||
|
||||
for (const key of repeaterJsonKeys) {
|
||||
try {
|
||||
const parsedData = JSON.parse(context.formData[key]);
|
||||
const repeaterTargetTable = parsedData[0]?._targetTable;
|
||||
|
||||
if (!repeaterTargetTable) {
|
||||
console.warn(`⚠️ [handleSave] RepeaterFieldGroup targetTable 없음 (key: ${key})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📦 [handleSave] RepeaterFieldGroup 저장 시작: ${repeaterTargetTable}, ${parsedData.length}건`);
|
||||
|
||||
// 🆕 품목 고유 필드 목록 (RepeaterFieldGroup 설정에서 가져옴)
|
||||
// 첫 번째 아이템의 _repeaterFields에서 추출
|
||||
const repeaterFields: string[] = parsedData[0]?._repeaterFields || [];
|
||||
const itemOnlyFields = new Set([...repeaterFields, 'id']); // id는 항상 포함
|
||||
|
||||
console.log("📋 [handleSave] RepeaterFieldGroup 품목 필드:", repeaterFields);
|
||||
|
||||
for (const item of parsedData) {
|
||||
// 메타 필드 제거
|
||||
const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item;
|
||||
|
||||
// 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반)
|
||||
const itemOnlyData: Record<string, any> = {};
|
||||
Object.keys(itemData).forEach((field) => {
|
||||
if (itemOnlyFields.has(field)) {
|
||||
itemOnlyData[field] = itemData[field];
|
||||
}
|
||||
});
|
||||
|
||||
// 🔧 마스터 정보 + 품목 고유 정보 병합
|
||||
// masterFields: 상단 폼에서 수정한 최신 마스터 정보
|
||||
// itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등)
|
||||
const dataWithMeta: Record<string, unknown> = {
|
||||
...masterFields, // 상단 마스터 정보 (최신)
|
||||
...itemOnlyData, // 품목 고유 필드만
|
||||
created_by: context.userId,
|
||||
updated_by: context.userId,
|
||||
company_code: context.companyCode,
|
||||
};
|
||||
|
||||
// 불필요한 필드 제거
|
||||
Object.keys(dataWithMeta).forEach((field) => {
|
||||
if (field.endsWith("_label") || field.endsWith("_value_label") || field.endsWith("_numberingRuleId")) {
|
||||
delete dataWithMeta[field];
|
||||
}
|
||||
});
|
||||
|
||||
// 새 레코드 vs 기존 레코드 판단
|
||||
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
|
||||
|
||||
console.log(`📦 [handleSave] 저장할 데이터 (${isNewRecord ? 'INSERT' : 'UPDATE'}):`, {
|
||||
id: item.id,
|
||||
dataWithMeta,
|
||||
});
|
||||
|
||||
if (isNewRecord) {
|
||||
// INSERT - DynamicFormApi 사용하여 제어관리 실행
|
||||
delete dataWithMeta.id;
|
||||
|
||||
const insertResult = await DynamicFormApi.saveFormData({
|
||||
screenId: context.screenId || 0,
|
||||
tableName: repeaterTargetTable,
|
||||
data: dataWithMeta as Record<string, any>,
|
||||
});
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||||
} else if (item.id && _existingRecord === true) {
|
||||
// UPDATE - 기존 레코드
|
||||
const originalData = { id: item.id };
|
||||
const updatedData = { ...dataWithMeta, id: item.id };
|
||||
|
||||
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
|
||||
originalData,
|
||||
updatedData,
|
||||
});
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ [handleSave] RepeaterFieldGroup 저장 실패 (key: ${key}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// RepeaterFieldGroup 저장 완료 후 새로고침
|
||||
console.log("✅ [handleSave] RepeaterFieldGroup 저장 완료");
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
|
||||
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
|
||||
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
|
||||
|
|
@ -1464,11 +1612,12 @@ export class ButtonActionExecutor {
|
|||
console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone });
|
||||
|
||||
try {
|
||||
// search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨)
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
filters: {
|
||||
warehouse_code: warehouseCode,
|
||||
floor: floor,
|
||||
zone: zone,
|
||||
search: {
|
||||
warehouse_code: { value: warehouseCode, operator: "equals" },
|
||||
floor: { value: floor, operator: "equals" },
|
||||
zone: { value: zone, operator: "equals" },
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
|
|
@ -4832,15 +4981,7 @@ export class ButtonActionExecutor {
|
|||
*/
|
||||
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📤 엑셀 업로드 모달 열기:", {
|
||||
config,
|
||||
context,
|
||||
userId: context.userId,
|
||||
tableName: context.tableName,
|
||||
screenId: context.screenId,
|
||||
});
|
||||
|
||||
// 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
|
||||
// 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
|
||||
let isMasterDetail = false;
|
||||
let masterDetailRelation: any = null;
|
||||
let masterDetailExcelConfig: any = undefined;
|
||||
|
|
@ -4855,6 +4996,12 @@ export class ButtonActionExecutor {
|
|||
masterDetailRelation = relationResponse.data;
|
||||
|
||||
// 버튼 설정에서 채번 규칙 등 추가 설정 가져오기
|
||||
// 업로드 후 제어: excelAfterUploadFlows를 우선 사용 (통합된 설정)
|
||||
// masterDetailExcel.afterUploadFlows는 레거시 호환성을 위해 fallback으로만 사용
|
||||
const afterUploadFlows = config.excelAfterUploadFlows?.length > 0
|
||||
? config.excelAfterUploadFlows
|
||||
: config.masterDetailExcel?.afterUploadFlows;
|
||||
|
||||
if (config.masterDetailExcel) {
|
||||
masterDetailExcelConfig = {
|
||||
...config.masterDetailExcel,
|
||||
|
|
@ -4863,6 +5010,10 @@ export class ButtonActionExecutor {
|
|||
detailTable: relationResponse.data.detailTable,
|
||||
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
|
||||
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
|
||||
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
|
||||
afterUploadFlows,
|
||||
};
|
||||
} else {
|
||||
// 버튼 설정이 없으면 분할 패널 정보만 사용
|
||||
|
|
@ -4872,6 +5023,10 @@ export class ButtonActionExecutor {
|
|||
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||
simpleMode: true, // 기본값으로 간단 모드 사용
|
||||
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
|
||||
numberingRuleId: config.excelNumberingRuleId,
|
||||
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
|
||||
afterUploadFlows,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -4909,7 +5064,7 @@ export class ButtonActionExecutor {
|
|||
savedSize: localStorage.getItem(storageKey),
|
||||
});
|
||||
|
||||
root.render(
|
||||
root.render(
|
||||
React.createElement(ExcelUploadModal, {
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
|
|
@ -4931,6 +5086,11 @@ export class ButtonActionExecutor {
|
|||
isMasterDetail,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
// 🆕 단일 테이블 채번 설정
|
||||
numberingRuleId: config.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.excelNumberingTargetColumn,
|
||||
// 🆕 업로드 후 제어 실행 설정
|
||||
afterUploadFlows: config.excelAfterUploadFlows,
|
||||
onSuccess: () => {
|
||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||
context.onRefresh?.();
|
||||
|
|
|
|||
|
|
@ -231,6 +231,17 @@ export interface FormulaTransformNodeData {
|
|||
value?: string | number;
|
||||
resultField?: string;
|
||||
};
|
||||
// 추가 연산 (다중 연산 지원: (left op right) op1 val1 op2 val2 ...)
|
||||
additionalOperations?: Array<{
|
||||
operator: "+" | "-" | "*" | "/" | "%";
|
||||
operand: {
|
||||
type: "source" | "target" | "static" | "result";
|
||||
field?: string;
|
||||
fieldLabel?: string;
|
||||
value?: string | number;
|
||||
resultField?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
// 함수 (formulaType === "function")
|
||||
|
|
|
|||
Loading…
Reference in New Issue