Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal
This commit is contained in:
parent
4e2209bd5d
commit
f2bee41336
|
|
@ -73,20 +73,4 @@ router.get("/categories/:categoryCode/options", (req, res) =>
|
||||||
commonCodeController.getCodeOptions(req, res)
|
commonCodeController.getCodeOptions(req, res)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 계층 구조 코드 조회 (트리 형태)
|
|
||||||
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
|
|
||||||
commonCodeController.getCodesHierarchy(req, res)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 자식 코드 조회 (연쇄 선택용)
|
|
||||||
router.get("/categories/:categoryCode/children", (req, res) =>
|
|
||||||
commonCodeController.getChildCodes(req, res)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리 → 공통코드 호환 API (레거시 지원)
|
|
||||||
// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작
|
|
||||||
router.get("/category-options/:tableName/:columnName", (req, res) =>
|
|
||||||
commonCodeController.getCategoryOptionsAsCode(req, res)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface CreateCategoryValueInput {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
targetCompanyCode?: string; // 최고 관리자가 특정 회사를 선택할 때 사용
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 값 수정 입력
|
// 카테고리 값 수정 입력
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,10 @@ class NumberingRuleService {
|
||||||
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
|
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
|
||||||
if (menuObjid) {
|
if (menuObjid) {
|
||||||
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
|
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
|
||||||
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
|
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", {
|
||||||
|
menuObjid,
|
||||||
|
menuAndChildObjids,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// menuObjid가 없으면 global 규칙만 반환
|
// menuObjid가 없으면 global 규칙만 반환
|
||||||
|
|
@ -333,7 +336,7 @@ class NumberingRuleService {
|
||||||
|
|
||||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||||
queryPreview: query.substring(0, 200),
|
queryPreview: query.substring(0, 200),
|
||||||
paramsTypes: params.map(p => typeof p),
|
paramsTypes: params.map((p) => typeof p),
|
||||||
paramsValues: params,
|
paramsValues: params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -841,7 +844,7 @@ class NumberingRuleService {
|
||||||
companyCode,
|
companyCode,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
updates
|
updates,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -913,12 +916,17 @@ class NumberingRuleService {
|
||||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||||
|
|
||||||
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
||||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
if (
|
||||||
|
autoConfig.useColumnValue &&
|
||||||
|
autoConfig.sourceColumnName &&
|
||||||
|
formData
|
||||||
|
) {
|
||||||
const columnValue = formData[autoConfig.sourceColumnName];
|
const columnValue = formData[autoConfig.sourceColumnName];
|
||||||
if (columnValue) {
|
if (columnValue) {
|
||||||
const dateValue = columnValue instanceof Date
|
const dateValue =
|
||||||
? columnValue
|
columnValue instanceof Date
|
||||||
: new Date(columnValue);
|
? columnValue
|
||||||
|
: new Date(columnValue);
|
||||||
|
|
||||||
if (!isNaN(dateValue.getTime())) {
|
if (!isNaN(dateValue.getTime())) {
|
||||||
return this.formatDate(dateValue, dateFormat);
|
return this.formatDate(dateValue, dateFormat);
|
||||||
|
|
@ -940,7 +948,10 @@ class NumberingRuleService {
|
||||||
const categoryMappings = autoConfig.categoryMappings || [];
|
const categoryMappings = autoConfig.categoryMappings || [];
|
||||||
|
|
||||||
if (!categoryKey || !formData) {
|
if (!categoryKey || !formData) {
|
||||||
logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
|
logger.warn("카테고리 키 또는 폼 데이터 없음", {
|
||||||
|
categoryKey,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -957,34 +968,36 @@ class NumberingRuleService {
|
||||||
columnName,
|
columnName,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
formDataKeys: Object.keys(formData),
|
formDataKeys: Object.keys(formData),
|
||||||
mappingsCount: categoryMappings.length
|
mappingsCount: categoryMappings.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!selectedValue) {
|
if (!selectedValue) {
|
||||||
logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
|
logger.warn("카테고리 값이 선택되지 않음", {
|
||||||
|
columnName,
|
||||||
|
formDataKeys: Object.keys(formData),
|
||||||
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
||||||
const selectedValueStr = String(selectedValue);
|
const selectedValueStr = String(selectedValue);
|
||||||
const mapping = categoryMappings.find(
|
const mapping = categoryMappings.find((m: any) => {
|
||||||
(m: any) => {
|
// ID로 매칭
|
||||||
// ID로 매칭
|
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
return true;
|
||||||
// 라벨로 매칭
|
// 라벨로 매칭
|
||||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||||
// valueCode로 매칭 (라벨과 동일할 수 있음)
|
// valueCode로 매칭 (라벨과 동일할 수 있음)
|
||||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (mapping) {
|
if (mapping) {
|
||||||
logger.info("카테고리 매핑 적용", {
|
logger.info("카테고리 매핑 적용", {
|
||||||
selectedValue,
|
selectedValue,
|
||||||
format: mapping.format,
|
format: mapping.format,
|
||||||
categoryValueLabel: mapping.categoryValueLabel
|
categoryValueLabel: mapping.categoryValueLabel,
|
||||||
});
|
});
|
||||||
return mapping.format || "";
|
return mapping.format || "";
|
||||||
}
|
}
|
||||||
|
|
@ -993,8 +1006,8 @@ class NumberingRuleService {
|
||||||
selectedValue,
|
selectedValue,
|
||||||
availableMappings: categoryMappings.map((m: any) => ({
|
availableMappings: categoryMappings.map((m: any) => ({
|
||||||
id: m.categoryValueId,
|
id: m.categoryValueId,
|
||||||
label: m.categoryValueLabel
|
label: m.categoryValueLabel,
|
||||||
}))
|
})),
|
||||||
});
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -1006,7 +1019,12 @@ class NumberingRuleService {
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewCode = parts.join(rule.separator || "");
|
const previewCode = parts.join(rule.separator || "");
|
||||||
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData });
|
logger.info("코드 미리보기 생성", {
|
||||||
|
ruleId,
|
||||||
|
previewCode,
|
||||||
|
companyCode,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
});
|
||||||
return previewCode;
|
return previewCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1033,7 +1051,9 @@ class NumberingRuleService {
|
||||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||||
|
|
||||||
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
|
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
|
||||||
const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual");
|
const manualParts = rule.parts.filter(
|
||||||
|
(p: any) => p.generationMethod === "manual"
|
||||||
|
);
|
||||||
let extractedManualValues: string[] = [];
|
let extractedManualValues: string[] = [];
|
||||||
|
|
||||||
if (manualParts.length > 0 && userInputCode) {
|
if (manualParts.length > 0 && userInputCode) {
|
||||||
|
|
@ -1074,13 +1094,12 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedValueStr = String(selectedValue);
|
const selectedValueStr = String(selectedValue);
|
||||||
const mapping = categoryMappings.find(
|
const mapping = categoryMappings.find((m: any) => {
|
||||||
(m: any) => {
|
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
return true;
|
||||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return mapping?.format || "CATEGORY";
|
return mapping?.format || "CATEGORY";
|
||||||
}
|
}
|
||||||
|
|
@ -1110,9 +1129,13 @@ class NumberingRuleService {
|
||||||
if (suffix) {
|
if (suffix) {
|
||||||
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
|
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
|
||||||
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
||||||
const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length;
|
const manualEndIndex = suffixStart
|
||||||
|
? remainingCode.indexOf(suffixStart)
|
||||||
|
: remainingCode.length;
|
||||||
if (manualEndIndex > 0) {
|
if (manualEndIndex > 0) {
|
||||||
extractedManualValues.push(remainingCode.slice(0, manualEndIndex));
|
extractedManualValues.push(
|
||||||
|
remainingCode.slice(0, manualEndIndex)
|
||||||
|
);
|
||||||
remainingCode = remainingCode.slice(manualEndIndex);
|
remainingCode = remainingCode.slice(manualEndIndex);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1121,7 +1144,9 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`);
|
logger.info(
|
||||||
|
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let manualPartIndex = 0;
|
let manualPartIndex = 0;
|
||||||
|
|
@ -1130,7 +1155,10 @@ class NumberingRuleService {
|
||||||
.map((part: any) => {
|
.map((part: any) => {
|
||||||
if (part.generationMethod === "manual") {
|
if (part.generationMethod === "manual") {
|
||||||
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
||||||
const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || "";
|
const manualValue =
|
||||||
|
extractedManualValues[manualPartIndex] ||
|
||||||
|
part.manualConfig?.value ||
|
||||||
|
"";
|
||||||
manualPartIndex++;
|
manualPartIndex++;
|
||||||
return manualValue;
|
return manualValue;
|
||||||
}
|
}
|
||||||
|
|
@ -1157,13 +1185,18 @@ class NumberingRuleService {
|
||||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||||
|
|
||||||
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
||||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
if (
|
||||||
|
autoConfig.useColumnValue &&
|
||||||
|
autoConfig.sourceColumnName &&
|
||||||
|
formData
|
||||||
|
) {
|
||||||
const columnValue = formData[autoConfig.sourceColumnName];
|
const columnValue = formData[autoConfig.sourceColumnName];
|
||||||
if (columnValue) {
|
if (columnValue) {
|
||||||
// 날짜 문자열 또는 Date 객체를 Date로 변환
|
// 날짜 문자열 또는 Date 객체를 Date로 변환
|
||||||
const dateValue = columnValue instanceof Date
|
const dateValue =
|
||||||
? columnValue
|
columnValue instanceof Date
|
||||||
: new Date(columnValue);
|
? columnValue
|
||||||
|
: new Date(columnValue);
|
||||||
|
|
||||||
if (!isNaN(dateValue.getTime())) {
|
if (!isNaN(dateValue.getTime())) {
|
||||||
logger.info("컬럼 기준 날짜 생성", {
|
logger.info("컬럼 기준 날짜 생성", {
|
||||||
|
|
@ -1201,7 +1234,10 @@ class NumberingRuleService {
|
||||||
const categoryMappings = autoConfig.categoryMappings || [];
|
const categoryMappings = autoConfig.categoryMappings || [];
|
||||||
|
|
||||||
if (!categoryKey || !formData) {
|
if (!categoryKey || !formData) {
|
||||||
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
|
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", {
|
||||||
|
categoryKey,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1218,31 +1254,33 @@ class NumberingRuleService {
|
||||||
columnName,
|
columnName,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
formDataKeys: Object.keys(formData),
|
formDataKeys: Object.keys(formData),
|
||||||
mappingsCount: categoryMappings.length
|
mappingsCount: categoryMappings.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!selectedValue) {
|
if (!selectedValue) {
|
||||||
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
|
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", {
|
||||||
|
columnName,
|
||||||
|
formDataKeys: Object.keys(formData),
|
||||||
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||||
const selectedValueStr = String(selectedValue);
|
const selectedValueStr = String(selectedValue);
|
||||||
const mapping = categoryMappings.find(
|
const mapping = categoryMappings.find((m: any) => {
|
||||||
(m: any) => {
|
// ID로 매칭
|
||||||
// ID로 매칭
|
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
return true;
|
||||||
// 라벨로 매칭
|
// 라벨로 매칭
|
||||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (mapping) {
|
if (mapping) {
|
||||||
logger.info("allocateCode: 카테고리 매핑 적용", {
|
logger.info("allocateCode: 카테고리 매핑 적용", {
|
||||||
selectedValue,
|
selectedValue,
|
||||||
format: mapping.format,
|
format: mapping.format,
|
||||||
categoryValueLabel: mapping.categoryValueLabel
|
categoryValueLabel: mapping.categoryValueLabel,
|
||||||
});
|
});
|
||||||
return mapping.format || "";
|
return mapping.format || "";
|
||||||
}
|
}
|
||||||
|
|
@ -1251,8 +1289,8 @@ class NumberingRuleService {
|
||||||
selectedValue,
|
selectedValue,
|
||||||
availableMappings: categoryMappings.map((m: any) => ({
|
availableMappings: categoryMappings.map((m: any) => ({
|
||||||
id: m.categoryValueId,
|
id: m.categoryValueId,
|
||||||
label: m.categoryValueLabel
|
label: m.categoryValueLabel,
|
||||||
}))
|
})),
|
||||||
});
|
});
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -1344,7 +1382,10 @@ class NumberingRuleService {
|
||||||
menuObjid?: number
|
menuObjid?: number
|
||||||
): Promise<NumberingRuleConfig[]> {
|
): Promise<NumberingRuleConfig[]> {
|
||||||
try {
|
try {
|
||||||
logger.info("[테스트] 채번 규칙 목록 조회 시작", { companyCode, menuObjid });
|
logger.info("[테스트] 채번 규칙 목록 조회 시작", {
|
||||||
|
companyCode,
|
||||||
|
menuObjid,
|
||||||
|
});
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
|
|
@ -1508,7 +1549,10 @@ class NumberingRuleService {
|
||||||
WHERE rule_id = $1 AND company_code = $2
|
WHERE rule_id = $1 AND company_code = $2
|
||||||
ORDER BY part_order
|
ORDER BY part_order
|
||||||
`;
|
`;
|
||||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
const partsResult = await pool.query(partsQuery, [
|
||||||
|
rule.ruleId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = partsResult.rows;
|
||||||
|
|
||||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||||
|
|
@ -1556,7 +1600,10 @@ class NumberingRuleService {
|
||||||
SELECT rule_id FROM numbering_rules
|
SELECT rule_id FROM numbering_rules
|
||||||
WHERE rule_id = $1 AND company_code = $2
|
WHERE rule_id = $1 AND company_code = $2
|
||||||
`;
|
`;
|
||||||
const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]);
|
const existingResult = await client.query(existingQuery, [
|
||||||
|
config.ruleId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
if (existingResult.rows.length > 0) {
|
if (existingResult.rows.length > 0) {
|
||||||
// 업데이트
|
// 업데이트
|
||||||
|
|
@ -1671,7 +1718,10 @@ class NumberingRuleService {
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
||||||
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode });
|
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", {
|
||||||
|
ruleId,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
// 파트 먼저 삭제
|
// 파트 먼저 삭제
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -1779,7 +1829,10 @@ class NumberingRuleService {
|
||||||
WHERE rule_id = $1 AND company_code = $2
|
WHERE rule_id = $1 AND company_code = $2
|
||||||
ORDER BY part_order
|
ORDER BY part_order
|
||||||
`;
|
`;
|
||||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
const partsResult = await pool.query(partsQuery, [
|
||||||
|
rule.ruleId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = partsResult.rows;
|
||||||
|
|
||||||
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
||||||
|
|
@ -1814,7 +1867,11 @@ class NumberingRuleService {
|
||||||
AND r.category_value_id IS NULL
|
AND r.category_value_id IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]);
|
const defaultResult = await pool.query(defaultQuery, [
|
||||||
|
companyCode,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
]);
|
||||||
|
|
||||||
if (defaultResult.rows.length > 0) {
|
if (defaultResult.rows.length > 0) {
|
||||||
const rule = defaultResult.rows[0];
|
const rule = defaultResult.rows[0];
|
||||||
|
|
@ -1831,7 +1888,10 @@ class NumberingRuleService {
|
||||||
WHERE rule_id = $1 AND company_code = $2
|
WHERE rule_id = $1 AND company_code = $2
|
||||||
ORDER BY part_order
|
ORDER BY part_order
|
||||||
`;
|
`;
|
||||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
const partsResult = await pool.query(partsQuery, [
|
||||||
|
rule.ruleId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = partsResult.rows;
|
||||||
|
|
||||||
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
||||||
|
|
@ -1891,7 +1951,11 @@ class NumberingRuleService {
|
||||||
AND r.column_name = $3
|
AND r.column_name = $3
|
||||||
ORDER BY r.category_value_id NULLS FIRST, r.created_at
|
ORDER BY r.category_value_id NULLS FIRST, r.created_at
|
||||||
`;
|
`;
|
||||||
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
const result = await pool.query(query, [
|
||||||
|
companyCode,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
]);
|
||||||
|
|
||||||
// 각 규칙의 파트 정보 조회
|
// 각 규칙의 파트 정보 조회
|
||||||
for (const rule of result.rows) {
|
for (const rule of result.rows) {
|
||||||
|
|
@ -1907,7 +1971,10 @@ class NumberingRuleService {
|
||||||
WHERE rule_id = $1 AND company_code = $2
|
WHERE rule_id = $1 AND company_code = $2
|
||||||
ORDER BY part_order
|
ORDER BY part_order
|
||||||
`;
|
`;
|
||||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
const partsResult = await pool.query(partsQuery, [
|
||||||
|
rule.ruleId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = partsResult.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1928,11 +1995,21 @@ class NumberingRuleService {
|
||||||
async copyRulesForCompany(
|
async copyRulesForCompany(
|
||||||
sourceCompanyCode: string,
|
sourceCompanyCode: string,
|
||||||
targetCompanyCode: string
|
targetCompanyCode: string
|
||||||
): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record<string, string> }> {
|
): Promise<{
|
||||||
|
copiedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
details: string[];
|
||||||
|
ruleIdMap: Record<string, string>;
|
||||||
|
}> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record<string, string> };
|
const result = {
|
||||||
|
copiedCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
details: [] as string[],
|
||||||
|
ruleIdMap: {} as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
@ -1952,7 +2029,7 @@ class NumberingRuleService {
|
||||||
if (deleteResult.rowCount && deleteResult.rowCount > 0) {
|
if (deleteResult.rowCount && deleteResult.rowCount > 0) {
|
||||||
logger.info("기존 채번규칙 삭제", {
|
logger.info("기존 채번규칙 삭제", {
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
deletedCount: deleteResult.rowCount
|
deletedCount: deleteResult.rowCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1964,7 +2041,7 @@ class NumberingRuleService {
|
||||||
|
|
||||||
logger.info("원본 채번규칙 조회", {
|
logger.info("원본 채번규칙 조회", {
|
||||||
sourceCompanyCode,
|
sourceCompanyCode,
|
||||||
count: sourceRulesResult.rowCount
|
count: sourceRulesResult.rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 각 채번규칙 복제
|
// 2. 각 채번규칙 복제
|
||||||
|
|
@ -2041,7 +2118,7 @@ class NumberingRuleService {
|
||||||
logger.info("채번규칙 복제 완료", {
|
logger.info("채번규칙 복제 완료", {
|
||||||
ruleName: rule.rule_name,
|
ruleName: rule.rule_name,
|
||||||
oldRuleId: rule.rule_id,
|
oldRuleId: rule.rule_id,
|
||||||
newRuleId
|
newRuleId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2049,7 +2126,7 @@ class NumberingRuleService {
|
||||||
if (Object.keys(result.ruleIdMap).length > 0) {
|
if (Object.keys(result.ruleIdMap).length > 0) {
|
||||||
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
|
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
mappingCount: Object.keys(result.ruleIdMap).length
|
mappingCount: Object.keys(result.ruleIdMap).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 대상 회사의 모든 화면 레이아웃 조회
|
// 대상 회사의 모든 화면 레이아웃 조회
|
||||||
|
|
@ -2069,9 +2146,13 @@ class NumberingRuleService {
|
||||||
let updated = false;
|
let updated = false;
|
||||||
|
|
||||||
// 각 매핑에 대해 치환
|
// 각 매핑에 대해 치환
|
||||||
for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) {
|
for (const [oldRuleId, newRuleId] of Object.entries(
|
||||||
|
result.ruleIdMap
|
||||||
|
)) {
|
||||||
if (propsStr.includes(`"${oldRuleId}"`)) {
|
if (propsStr.includes(`"${oldRuleId}"`)) {
|
||||||
propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`);
|
propsStr = propsStr
|
||||||
|
.split(`"${oldRuleId}"`)
|
||||||
|
.join(`"${newRuleId}"`);
|
||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2087,9 +2168,11 @@ class NumberingRuleService {
|
||||||
|
|
||||||
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
|
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
updatedLayouts
|
updatedLayouts,
|
||||||
});
|
});
|
||||||
result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`);
|
result.details.push(
|
||||||
|
`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
|
|
@ -2099,13 +2182,17 @@ class NumberingRuleService {
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
copiedCount: result.copiedCount,
|
copiedCount: result.copiedCount,
|
||||||
skippedCount: result.skippedCount,
|
skippedCount: result.skippedCount,
|
||||||
ruleIdMapCount: Object.keys(result.ruleIdMap).length
|
ruleIdMapCount: Object.keys(result.ruleIdMap).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode });
|
logger.error("회사별 채번규칙 복제 실패", {
|
||||||
|
error,
|
||||||
|
sourceCompanyCode,
|
||||||
|
targetCompanyCode,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
|
|
|
||||||
|
|
@ -3869,6 +3869,7 @@ export class TableManagementService {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
|
inputType?: string;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||||
|
|
|
||||||
|
|
@ -249,8 +249,18 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
layers.forEach((layer) => {
|
layers.forEach((layer) => {
|
||||||
if (layer.type === "conditional" && layer.condition) {
|
if (layer.type === "conditional" && layer.condition) {
|
||||||
const { targetComponentId, operator, value } = layer.condition;
|
const { targetComponentId, operator, value } = layer.condition;
|
||||||
// 컴포넌트 ID를 키로 데이터 조회 - columnName 매핑이 필요할 수 있음
|
|
||||||
const targetValue = finalFormData[targetComponentId];
|
// 1. 컴포넌트 ID로 대상 컴포넌트 찾기
|
||||||
|
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||||||
|
|
||||||
|
// 2. 컴포넌트의 columnName으로 formData에서 값 조회
|
||||||
|
// columnName이 없으면 컴포넌트 ID로 폴백
|
||||||
|
const fieldKey =
|
||||||
|
(targetComponent as any)?.columnName ||
|
||||||
|
(targetComponent as any)?.componentConfig?.columnName ||
|
||||||
|
targetComponentId;
|
||||||
|
|
||||||
|
const targetValue = finalFormData[fieldKey];
|
||||||
|
|
||||||
let isMatch = false;
|
let isMatch = false;
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
|
|
@ -272,7 +282,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [finalFormData, layers, handleLayerAction]);
|
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||||
|
|
||||||
// 개선된 검증 시스템 (선택적 활성화)
|
// 개선된 검증 시스템 (선택적 활성화)
|
||||||
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Loader2, AlertCircle, Check, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
|
||||||
|
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
|
||||||
|
|
||||||
|
interface LayerConditionPanelProps {
|
||||||
|
layer: LayerDefinition;
|
||||||
|
components: ComponentData[]; // 화면의 모든 컴포넌트
|
||||||
|
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건 연산자 옵션
|
||||||
|
const OPERATORS = [
|
||||||
|
{ value: "eq", label: "같음 (=)" },
|
||||||
|
{ value: "neq", label: "같지 않음 (≠)" },
|
||||||
|
{ value: "in", label: "포함 (in)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type OperatorType = "eq" | "neq" | "in";
|
||||||
|
|
||||||
|
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
|
layer,
|
||||||
|
components,
|
||||||
|
onUpdateCondition,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
// 조건 설정 상태
|
||||||
|
const [targetComponentId, setTargetComponentId] = useState<string>(
|
||||||
|
layer.condition?.targetComponentId || ""
|
||||||
|
);
|
||||||
|
const [operator, setOperator] = useState<OperatorType>(
|
||||||
|
(layer.condition?.operator as OperatorType) || "eq"
|
||||||
|
);
|
||||||
|
const [value, setValue] = useState<string>(
|
||||||
|
layer.condition?.value?.toString() || ""
|
||||||
|
);
|
||||||
|
const [multiValues, setMultiValues] = useState<string[]>(
|
||||||
|
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// 코드 목록 로딩 상태
|
||||||
|
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
|
||||||
|
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
||||||
|
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
|
||||||
|
const triggerableComponents = useMemo(() => {
|
||||||
|
return components.filter((comp) => {
|
||||||
|
const componentType = (comp.componentType || "").toLowerCase();
|
||||||
|
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
||||||
|
const webType = ((comp as any).webType || "").toLowerCase();
|
||||||
|
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
||||||
|
|
||||||
|
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
||||||
|
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
|
||||||
|
const isTriggerType = triggerTypes.some((type) =>
|
||||||
|
componentType.includes(type) ||
|
||||||
|
widgetType.includes(type) ||
|
||||||
|
webType.includes(type) ||
|
||||||
|
inputType.includes(type)
|
||||||
|
);
|
||||||
|
|
||||||
|
return isTriggerType;
|
||||||
|
});
|
||||||
|
}, [components]);
|
||||||
|
|
||||||
|
// 선택된 컴포넌트 정보
|
||||||
|
const selectedComponent = useMemo(() => {
|
||||||
|
return components.find((c) => c.id === targetComponentId);
|
||||||
|
}, [components, targetComponentId]);
|
||||||
|
|
||||||
|
// 선택된 컴포넌트의 코드 카테고리
|
||||||
|
const codeCategory = useMemo(() => {
|
||||||
|
if (!selectedComponent) return null;
|
||||||
|
|
||||||
|
// codeCategory 확인 (다양한 위치에 있을 수 있음)
|
||||||
|
const category =
|
||||||
|
(selectedComponent as any).codeCategory ||
|
||||||
|
(selectedComponent as any).componentConfig?.codeCategory ||
|
||||||
|
(selectedComponent as any).webTypeConfig?.codeCategory;
|
||||||
|
|
||||||
|
return category || null;
|
||||||
|
}, [selectedComponent]);
|
||||||
|
|
||||||
|
// 컴포넌트 선택 시 코드 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!codeCategory) {
|
||||||
|
setCodeOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCodes = async () => {
|
||||||
|
setIsLoadingCodes(true);
|
||||||
|
setCodeLoadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const codes = await getCodesByCategory(codeCategory);
|
||||||
|
setCodeOptions(codes);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("코드 목록 로드 실패:", error);
|
||||||
|
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
|
||||||
|
setCodeOptions([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCodes(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCodes();
|
||||||
|
}, [codeCategory]);
|
||||||
|
|
||||||
|
// 조건 저장
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!targetComponentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition: LayerCondition = {
|
||||||
|
targetComponentId,
|
||||||
|
operator,
|
||||||
|
value: operator === "in" ? multiValues : value,
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateCondition(condition);
|
||||||
|
onClose?.();
|
||||||
|
}, [targetComponentId, operator, value, multiValues, onUpdateCondition, onClose]);
|
||||||
|
|
||||||
|
// 조건 삭제
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onUpdateCondition(undefined);
|
||||||
|
setTargetComponentId("");
|
||||||
|
setOperator("eq");
|
||||||
|
setValue("");
|
||||||
|
setMultiValues([]);
|
||||||
|
onClose?.();
|
||||||
|
}, [onUpdateCondition, onClose]);
|
||||||
|
|
||||||
|
// in 연산자용 다중 값 토글
|
||||||
|
const toggleMultiValue = useCallback((val: string) => {
|
||||||
|
setMultiValues((prev) =>
|
||||||
|
prev.includes(val)
|
||||||
|
? prev.filter((v) => v !== val)
|
||||||
|
: [...prev, val]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 컴포넌트 라벨 가져오기
|
||||||
|
const getComponentLabel = (comp: ComponentData) => {
|
||||||
|
return comp.label || (comp as any).columnName || comp.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold">조건부 표시 설정</h4>
|
||||||
|
{layer.condition && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
설정됨
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 트리거 컴포넌트 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">트리거 컴포넌트</Label>
|
||||||
|
<Select value={targetComponentId} onValueChange={setTargetComponentId}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컴포넌트 선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{triggerableComponents.length === 0 ? (
|
||||||
|
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||||
|
조건 설정 가능한 컴포넌트가 없습니다.
|
||||||
|
<br />
|
||||||
|
(셀렉트, 라디오, 코드 타입)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
triggerableComponents.map((comp) => (
|
||||||
|
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{getComponentLabel(comp)}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{comp.componentType || (comp as any).widgetType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 코드 카테고리 표시 */}
|
||||||
|
{codeCategory && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>카테고리:</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{codeCategory}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연산자 선택 */}
|
||||||
|
{targetComponentId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">조건</Label>
|
||||||
|
<Select
|
||||||
|
value={operator}
|
||||||
|
onValueChange={(val) => setOperator(val as OperatorType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 조건 값 선택 */}
|
||||||
|
{targetComponentId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">
|
||||||
|
{operator === "in" ? "값 선택 (복수)" : "값"}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{isLoadingCodes ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
코드 목록 로딩 중...
|
||||||
|
</div>
|
||||||
|
) : codeLoadError ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{codeLoadError}
|
||||||
|
</div>
|
||||||
|
) : codeOptions.length > 0 ? (
|
||||||
|
// 코드 카테고리가 있는 경우 - 선택 UI
|
||||||
|
operator === "in" ? (
|
||||||
|
// 다중 선택 (in 연산자)
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||||
|
{codeOptions.map((code) => (
|
||||||
|
<div
|
||||||
|
key={code.codeValue}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
||||||
|
multiValues.includes(code.codeValue) && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleMultiValue(code.codeValue)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-4 h-4 rounded border flex items-center justify-center",
|
||||||
|
multiValues.includes(code.codeValue)
|
||||||
|
? "bg-primary border-primary"
|
||||||
|
: "border-input"
|
||||||
|
)}>
|
||||||
|
{multiValues.includes(code.codeValue) && (
|
||||||
|
<Check className="h-3 w-3 text-primary-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>{code.codeName}</span>
|
||||||
|
<span className="text-muted-foreground">({code.codeValue})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 단일 선택 (eq, neq 연산자)
|
||||||
|
<Select value={value} onValueChange={setValue}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="값 선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{codeOptions.map((code) => (
|
||||||
|
<SelectItem
|
||||||
|
key={code.codeValue}
|
||||||
|
value={code.codeValue}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{code.codeName} ({code.codeValue})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// 코드 카테고리가 없는 경우 - 직접 입력
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="조건 값 입력..."
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 값 표시 (in 연산자) */}
|
||||||
|
{operator === "in" && multiValues.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{multiValues.map((val) => {
|
||||||
|
const code = codeOptions.find((c) => c.codeValue === val);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={val}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] gap-1"
|
||||||
|
>
|
||||||
|
{code?.codeName || val}
|
||||||
|
<X
|
||||||
|
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
||||||
|
onClick={() => toggleMultiValue(val)}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 현재 조건 요약 */}
|
||||||
|
{targetComponentId && (value || multiValues.length > 0) && (
|
||||||
|
<div className="p-2 bg-muted rounded-md text-xs">
|
||||||
|
<span className="font-medium">요약: </span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
||||||
|
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
|
||||||
|
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
|
||||||
|
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
|
||||||
|
{" "}이 레이어 표시
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
조건 삭제
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!targetComponentId || (!value && multiValues.length === 0)}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
import { useLayer } from "@/contexts/LayerContext";
|
import { useLayer } from "@/contexts/LayerContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
@ -10,6 +10,11 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -22,10 +27,13 @@ import {
|
||||||
SplitSquareVertical,
|
SplitSquareVertical,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LayerType, LayerDefinition, ComponentData } from "@/types/screen-management";
|
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
|
||||||
|
import { LayerConditionPanel } from "./LayerConditionPanel";
|
||||||
|
|
||||||
// 레이어 타입별 아이콘
|
// 레이어 타입별 아이콘
|
||||||
const getLayerTypeIcon = (type: LayerType) => {
|
const getLayerTypeIcon = (type: LayerType) => {
|
||||||
|
|
@ -78,137 +86,196 @@ function getLayerTypeColor(type: LayerType): string {
|
||||||
interface LayerItemProps {
|
interface LayerItemProps {
|
||||||
layer: LayerDefinition;
|
layer: LayerDefinition;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
componentCount: number; // 🆕 실제 컴포넌트 수 (layout.components 기반)
|
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
|
||||||
|
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onToggleVisibility: () => void;
|
onToggleVisibility: () => void;
|
||||||
onToggleLock: () => void;
|
onToggleLock: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onUpdateName: (name: string) => void;
|
onUpdateName: (name: string) => void;
|
||||||
|
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerItem: React.FC<LayerItemProps> = ({
|
const LayerItem: React.FC<LayerItemProps> = ({
|
||||||
layer,
|
layer,
|
||||||
isActive,
|
isActive,
|
||||||
componentCount,
|
componentCount,
|
||||||
|
allComponents,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onToggleLock,
|
onToggleLock,
|
||||||
onRemove,
|
onRemove,
|
||||||
onUpdateName,
|
onUpdateName,
|
||||||
|
onUpdateCondition,
|
||||||
}) => {
|
}) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isConditionOpen, setIsConditionOpen] = useState(false);
|
||||||
|
|
||||||
|
// 조건부 레이어인지 확인
|
||||||
|
const isConditionalLayer = layer.type === "conditional";
|
||||||
|
// 조건 설정 여부
|
||||||
|
const hasCondition = !!layer.condition;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="space-y-0">
|
||||||
className={cn(
|
{/* 레이어 메인 영역 */}
|
||||||
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
|
<div
|
||||||
isActive
|
className={cn(
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
|
||||||
: "hover:bg-muted border-transparent",
|
isActive
|
||||||
!layer.isVisible && "opacity-50",
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
)}
|
: "hover:bg-muted border-transparent",
|
||||||
onClick={onSelect}
|
!layer.isVisible && "opacity-50",
|
||||||
>
|
isConditionOpen && "rounded-b-none border-b-0",
|
||||||
{/* 드래그 핸들 */}
|
)}
|
||||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
|
||||||
|
|
||||||
{/* 레이어 정보 */}
|
{/* 레이어 정보 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 레이어 타입 아이콘 */}
|
{/* 레이어 타입 아이콘 */}
|
||||||
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
|
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
|
||||||
{getLayerTypeIcon(layer.type)}
|
{getLayerTypeIcon(layer.type)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 레이어 이름 */}
|
{/* 레이어 이름 */}
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={layer.name}
|
value={layer.name}
|
||||||
onChange={(e) => onUpdateName(e.target.value)}
|
onChange={(e) => onUpdateName(e.target.value)}
|
||||||
onBlur={() => setIsEditing(false)}
|
onBlur={() => setIsEditing(false)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") setIsEditing(false);
|
if (e.key === "Enter") setIsEditing(false);
|
||||||
}}
|
}}
|
||||||
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
|
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="flex-1 truncate font-medium"
|
className="flex-1 truncate font-medium"
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이어 메타 정보 */}
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
|
||||||
|
{getLayerTypeLabel(layer.type)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{componentCount}개 컴포넌트
|
||||||
|
</span>
|
||||||
|
{/* 조건 설정됨 표시 */}
|
||||||
|
{hasCondition && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
|
||||||
|
<Zap className="h-2.5 w-2.5" />
|
||||||
|
조건
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼들 */}
|
||||||
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
{/* 조건부 레이어일 때 조건 설정 버튼 */}
|
||||||
|
{isConditionalLayer && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
hasCondition && "text-amber-600"
|
||||||
|
)}
|
||||||
|
title="조건 설정"
|
||||||
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsEditing(true);
|
setIsConditionOpen(!isConditionOpen);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{layer.name}
|
{isConditionOpen ? (
|
||||||
</span>
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 레이어 메타 정보 */}
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
|
|
||||||
{getLayerTypeLabel(layer.type)}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-[10px]">
|
|
||||||
{componentCount}개 컴포넌트
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 버튼들 */}
|
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleVisibility();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{layer.isVisible ? (
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleLock();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{layer.isLocked ? (
|
|
||||||
<Lock className="text-destructive h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{layer.type !== "base" && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hover:text-destructive h-6 w-6"
|
className="h-6 w-6"
|
||||||
title="레이어 삭제"
|
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
onToggleVisibility();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
{layer.isVisible ? (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleLock();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer.isLocked ? (
|
||||||
|
<Lock className="text-destructive h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{layer.type !== "base" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hover:text-destructive h-6 w-6"
|
||||||
|
title="레이어 삭제"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 설정 패널 (조건부 레이어만) */}
|
||||||
|
{isConditionalLayer && isConditionOpen && (
|
||||||
|
<div className={cn(
|
||||||
|
"border border-t-0 rounded-b-md bg-muted/30",
|
||||||
|
isActive ? "border-primary" : "border-border"
|
||||||
|
)}>
|
||||||
|
<LayerConditionPanel
|
||||||
|
layer={layer}
|
||||||
|
components={allComponents}
|
||||||
|
onUpdateCondition={onUpdateCondition}
|
||||||
|
onClose={() => setIsConditionOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -229,6 +296,11 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components
|
||||||
updateLayer,
|
updateLayer,
|
||||||
} = useLayer();
|
} = useLayer();
|
||||||
|
|
||||||
|
// 레이어 조건 업데이트 핸들러
|
||||||
|
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
|
||||||
|
updateLayer(layerId, { condition });
|
||||||
|
}, [updateLayer]);
|
||||||
|
|
||||||
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
|
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
|
||||||
const componentCountByLayer = useMemo(() => {
|
const componentCountByLayer = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
|
|
@ -311,11 +383,13 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components
|
||||||
layer={layer}
|
layer={layer}
|
||||||
isActive={activeLayerId === layer.id}
|
isActive={activeLayerId === layer.id}
|
||||||
componentCount={componentCountByLayer[layer.id] || 0}
|
componentCount={componentCountByLayer[layer.id] || 0}
|
||||||
|
allComponents={components}
|
||||||
onSelect={() => setActiveLayerId(layer.id)}
|
onSelect={() => setActiveLayerId(layer.id)}
|
||||||
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
|
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
|
||||||
onToggleLock={() => toggleLayerLock(layer.id)}
|
onToggleLock={() => toggleLayerLock(layer.id)}
|
||||||
onRemove={() => removeLayer(layer.id)}
|
onRemove={() => removeLayer(layer.id)}
|
||||||
onUpdateName={(name) => updateLayer(layer.id, { name })}
|
onUpdateName={(name) => updateLayer(layer.id, { name })}
|
||||||
|
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export interface FileUploadProps {
|
||||||
/**
|
/**
|
||||||
* 파일 업로드 상태 타입
|
* 파일 업로드 상태 타입
|
||||||
*/
|
*/
|
||||||
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
|
export type FileUploadStatus = "idle" | "uploading" | "success" | "error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 업로드 응답 타입
|
* 파일 업로드 응답 타입
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue