feature/v2-unified-renewal #381

Merged
kjs merged 3 commits from feature/v2-unified-renewal into main 2026-02-06 10:21:08 +09:00
8 changed files with 820 additions and 292 deletions
Showing only changes of commit f2bee41336 - Show all commits

View File

@ -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;

View File

@ -43,6 +43,7 @@ export interface CreateCategoryValueInput {
icon?: string; icon?: string;
isActive?: boolean; isActive?: boolean;
isDefault?: boolean; isDefault?: boolean;
targetCompanyCode?: string; // 최고 관리자가 특정 회사를 선택할 때 사용
} }
// 카테고리 값 수정 입력 // 카테고리 값 수정 입력

View File

@ -47,11 +47,11 @@ class NumberingRuleService {
logger.info("채번 규칙 목록 조회 시작", { companyCode }); logger.info("채번 규칙 목록 조회 시작", { companyCode });
const pool = getPool(); const pool = getPool();
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
let query: string; let query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 회사 데이터 조회 가능 // 최고 관리자: 모든 회사 데이터 조회 가능
query = ` query = `
@ -107,7 +107,7 @@ class NumberingRuleService {
for (const rule of result.rows) { for (const rule of result.rows) {
let partsQuery: string; let partsQuery: string;
let partsParams: any[]; let partsParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 파트 조회 // 최고 관리자: 모든 파트 조회
partsQuery = ` partsQuery = `
@ -156,7 +156,7 @@ class NumberingRuleService {
/** /**
* ( ) * ( )
* *
* : * :
* - menuObjid가 * - menuObjid가
* - 우선순위: menu ( ) > table > global * - 우선순위: menu ( ) > table > global
@ -166,7 +166,7 @@ class NumberingRuleService {
menuObjid?: number menuObjid?: number
): Promise<NumberingRuleConfig[]> { ): Promise<NumberingRuleConfig[]> {
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try { try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode, companyCode,
@ -178,14 +178,17 @@ 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 규칙만 반환
if (!menuObjid || menuAndChildObjids.length === 0) { if (!menuObjid || menuAndChildObjids.length === 0) {
let query: string; let query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 global 규칙 조회 // 최고 관리자: 모든 global 규칙 조회
query = ` query = `
@ -239,7 +242,7 @@ class NumberingRuleService {
for (const rule of result.rows) { for (const rule of result.rows) {
let partsQuery: string; let partsQuery: string;
let partsParams: any[]; let partsParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
partsQuery = ` partsQuery = `
SELECT SELECT
@ -281,7 +284,7 @@ class NumberingRuleService {
// 우선순위: menu (형제 메뉴) > table > global // 우선순위: menu (형제 메뉴) > table > global
let query: string; let query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 // 최고 관리자: 모든 규칙 조회
query = ` query = `
@ -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,
}); });
@ -346,7 +349,7 @@ class NumberingRuleService {
try { try {
let partsQuery: string; let partsQuery: string;
let partsParams: any[]; let partsParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
partsQuery = ` partsQuery = `
SELECT SELECT
@ -379,7 +382,7 @@ class NumberingRuleService {
const partsResult = await pool.query(partsQuery, partsParams); const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows; rule.parts = partsResult.rows;
logger.info("✅ 규칙 파트 조회 성공", { logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
ruleName: rule.ruleName, ruleName: rule.ruleName,
@ -537,11 +540,11 @@ class NumberingRuleService {
companyCode: string companyCode: string
): Promise<NumberingRuleConfig | null> { ): Promise<NumberingRuleConfig | null> {
const pool = getPool(); const pool = getPool();
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
let query: string; let query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 가능 // 최고 관리자: 모든 규칙 조회 가능
query = ` query = `
@ -598,7 +601,7 @@ class NumberingRuleService {
// 파트 정보 조회 // 파트 정보 조회
let partsQuery: string; let partsQuery: string;
let partsParams: any[]; let partsParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
partsQuery = ` partsQuery = `
SELECT SELECT
@ -836,12 +839,12 @@ class NumberingRuleService {
return { ...ruleResult.rows[0], parts }; return { ...ruleResult.rows[0], parts };
} catch (error: any) { } catch (error: any) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");
logger.error("채번 규칙 수정 실패", { logger.error("채번 규칙 수정 실패", {
ruleId, ruleId,
companyCode, companyCode,
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
updates updates,
}); });
throw error; throw error;
} finally { } finally {
@ -875,7 +878,7 @@ class NumberingRuleService {
* @param formData ( ) * @param formData ( )
*/ */
async previewCode( async previewCode(
ruleId: string, ruleId: string,
companyCode: string, companyCode: string,
formData?: Record<string, any> formData?: Record<string, any>
): Promise<string> { ): Promise<string> {
@ -911,21 +914,26 @@ class NumberingRuleService {
case "date": { case "date": {
// 날짜 (다양한 날짜 형식) // 날짜 (다양한 날짜 형식)
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);
} }
} }
} }
return this.formatDate(new Date(), dateFormat); return this.formatDate(new Date(), dateFormat);
} }
@ -938,63 +946,68 @@ class NumberingRuleService {
// 카테고리 기반 코드 생성 // 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
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 "";
} }
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".") const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1] ? categoryKey.split(".")[1]
: categoryKey; : categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기 // 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName]; const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", { logger.info("카테고리 파트 처리", {
categoryKey, categoryKey,
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 || "";
} }
logger.warn("카테고리 매핑을 찾을 수 없음", { logger.warn("카테고리 매핑을 찾을 수 없음", {
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;
} }
@ -1018,8 +1036,8 @@ class NumberingRuleService {
* @param userInputCode ( ) * @param userInputCode ( )
*/ */
async allocateCode( async allocateCode(
ruleId: string, ruleId: string,
companyCode: string, companyCode: string,
formData?: Record<string, any>, formData?: Record<string, any>,
userInputCode?: string userInputCode?: string
): Promise<string> { ): Promise<string> {
@ -1033,9 +1051,11 @@ 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) {
// 프리뷰 코드를 생성해서 ____ 위치 파악 // 프리뷰 코드를 생성해서 ____ 위치 파악
// 🔧 category 파트도 처리하여 올바른 템플릿 생성 // 🔧 category 파트도 처리하여 올바른 템플릿 생성
@ -1059,39 +1079,38 @@ class NumberingRuleService {
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용 // 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
const categoryKey = autoConfig.categoryKey; const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || []; const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) { if (!categoryKey || !formData) {
return "CATEGORY"; // 폴백 return "CATEGORY"; // 폴백
} }
const columnName = categoryKey.includes(".") const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1] ? categoryKey.split(".")[1]
: categoryKey; : categoryKey;
const selectedValue = formData[columnName]; const selectedValue = formData[columnName];
if (!selectedValue) { if (!selectedValue) {
return "CATEGORY"; // 폴백 return "CATEGORY"; // 폴백
} }
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";
} }
default: default:
return ""; return "";
} }
}); });
const separator = rule.separator || ""; const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator); const previewTemplate = previewParts.join(separator);
// 사용자 입력 코드에서 수동 입력 부분 추출 // 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
const templateParts = previewTemplate.split("____"); const templateParts = previewTemplate.split("____");
@ -1100,19 +1119,23 @@ class NumberingRuleService {
for (let i = 0; i < templateParts.length - 1; i++) { for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i]; const prefix = templateParts[i];
const suffix = templateParts[i + 1]; const suffix = templateParts[i + 1];
// prefix 이후 부분 추출 // prefix 이후 부분 추출
if (prefix && remainingCode.startsWith(prefix)) { if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length); remainingCode = remainingCode.slice(prefix.length);
} }
// suffix 이전까지가 수동 입력 값 // suffix 이전까지가 수동 입력 값
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 {
@ -1120,8 +1143,10 @@ 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;
} }
@ -1155,16 +1183,21 @@ class NumberingRuleService {
case "date": { case "date": {
// 날짜 (다양한 날짜 형식) // 날짜 (다양한 날짜 형식)
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("컬럼 기준 날짜 생성", {
sourceColumn: autoConfig.sourceColumnName, sourceColumn: autoConfig.sourceColumnName,
@ -1185,7 +1218,7 @@ class NumberingRuleService {
}); });
} }
} }
// 기본: 현재 날짜 사용 // 기본: 현재 날짜 사용
return this.formatDate(new Date(), dateFormat); return this.formatDate(new Date(), dateFormat);
} }
@ -1199,60 +1232,65 @@ class NumberingRuleService {
// 카테고리 기반 코드 생성 (allocateCode용) // 카테고리 기반 코드 생성 (allocateCode용)
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
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 "";
} }
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".") const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1] ? categoryKey.split(".")[1]
: categoryKey; : categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기 // 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName]; const selectedValue = formData[columnName];
logger.info("allocateCode: 카테고리 파트 처리", { logger.info("allocateCode: 카테고리 파트 처리", {
categoryKey, categoryKey,
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 || "";
} }
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
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,14 +1382,17 @@ 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();
// 멀티테넌시: 최고 관리자 vs 일반 회사 // 멀티테넌시: 최고 관리자 vs 일반 회사
let query: string; let query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 // 최고 관리자: 모든 규칙 조회
query = ` query = `
@ -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,8 +1951,12 @@ 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) {
const partsQuery = ` const partsQuery = `
@ -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");
@ -1950,9 +2027,9 @@ class NumberingRuleService {
[targetCompanyCode] [targetCompanyCode]
); );
if (deleteResult.rowCount && deleteResult.rowCount > 0) { if (deleteResult.rowCount && deleteResult.rowCount > 0) {
logger.info("기존 채번규칙 삭제", { logger.info("기존 채번규칙 삭제", {
targetCompanyCode, targetCompanyCode,
deletedCount: deleteResult.rowCount deletedCount: deleteResult.rowCount,
}); });
} }
@ -1962,9 +2039,9 @@ class NumberingRuleService {
[sourceCompanyCode] [sourceCompanyCode]
); );
logger.info("원본 채번규칙 조회", { logger.info("원본 채번규칙 조회", {
sourceCompanyCode, sourceCompanyCode,
count: sourceRulesResult.rowCount count: sourceRulesResult.rowCount,
}); });
// 2. 각 채번규칙 복제 // 2. 각 채번규칙 복제
@ -2038,18 +2115,18 @@ class NumberingRuleService {
result.ruleIdMap[rule.rule_id] = newRuleId; result.ruleIdMap[rule.rule_id] = newRuleId;
result.copiedCount++; result.copiedCount++;
result.details.push(`복제 완료: ${rule.rule_name}`); result.details.push(`복제 완료: ${rule.rule_name}`);
logger.info("채번규칙 복제 완료", { logger.info("채번규칙 복제 완료", {
ruleName: rule.rule_name, ruleName: rule.rule_name,
oldRuleId: rule.rule_id, oldRuleId: rule.rule_id,
newRuleId newRuleId,
}); });
} }
// 3. 화면 레이아웃의 numberingRuleId 참조 업데이트 // 3. 화면 레이아웃의 numberingRuleId 참조 업데이트
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;
} }
} }
@ -2085,27 +2166,33 @@ 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");
logger.info("회사별 채번규칙 복제 완료", { logger.info("회사별 채번규칙 복제 완료", {
sourceCompanyCode, sourceCompanyCode,
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();

View File

@ -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);

View File

@ -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

View File

@ -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>
);
};

View File

@ -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 ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
{layer.name}
</span>
)}
</div>
{/* 레이어 이름 */} {/* 레이어 메타 정보 */}
{isEditing ? ( <div className="flex items-center gap-2 mt-0.5">
<input <Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
type="text" {getLayerTypeLabel(layer.type)}
value={layer.name} </Badge>
onChange={(e) => onUpdateName(e.target.value)} <span className="text-muted-foreground text-[10px]">
onBlur={() => setIsEditing(false)} {componentCount}
onKeyDown={(e) => { </span>
if (e.key === "Enter") setIsEditing(false); {/* 조건 설정됨 표시 */}
}} {hasCondition && (
className="flex-1 bg-transparent outline-none border-b border-primary text-sm" <Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
autoFocus <Zap className="h-2.5 w-2.5" />
onClick={(e) => e.stopPropagation()}
/> </Badge>
) : ( )}
<span </div>
className="flex-1 truncate font-medium" </div>
onDoubleClick={(e) => {
{/* 액션 버튼들 */}
<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)}
/> />
)) ))
)} )}

View File

@ -30,7 +30,7 @@ export interface FileInfo {
type?: string; // docType과 동일 type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일 uploadedAt?: string; // regdate와 동일
_file?: File; // 로컬 파일 객체 (업로드 전) _file?: File; // 로컬 파일 객체 (업로드 전)
// 대표 이미지 설정 // 대표 이미지 설정
isRepresentative?: boolean; // 대표 이미지로 설정 여부 isRepresentative?: boolean; // 대표 이미지로 설정 여부
} }
@ -45,24 +45,24 @@ export interface FileUploadConfig extends ComponentConfig {
accept?: string; accept?: string;
maxSize?: number; // bytes maxSize?: number; // bytes
maxFiles?: number; // 최대 파일 수 maxFiles?: number; // 최대 파일 수
// 공통 설정 // 공통 설정
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
helperText?: string; helperText?: string;
// 스타일 관련 // 스타일 관련
variant?: "default" | "outlined" | "filled"; variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
// V2 추가 설정 // V2 추가 설정
showPreview?: boolean; // 미리보기 표시 여부 showPreview?: boolean; // 미리보기 표시 여부
showFileList?: boolean; // 파일 목록 표시 여부 showFileList?: boolean; // 파일 목록 표시 여부
showFileSize?: boolean; // 파일 크기 표시 여부 showFileSize?: boolean; // 파일 크기 표시 여부
allowDelete?: boolean; // 삭제 허용 여부 allowDelete?: boolean; // 삭제 허용 여부
allowDownload?: boolean; // 다운로드 허용 여부 allowDownload?: boolean; // 다운로드 허용 여부
// 이벤트 관련 // 이벤트 관련
onChange?: (value: any) => void; onChange?: (value: any) => void;
onFocus?: () => void; onFocus?: () => void;
@ -83,10 +83,10 @@ export interface FileUploadProps {
config?: FileUploadConfig; config?: FileUploadConfig;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
// 파일 관련 // 파일 관련
uploadedFiles?: FileInfo[]; uploadedFiles?: FileInfo[];
// 이벤트 핸들러 // 이벤트 핸들러
onChange?: (value: any) => void; onChange?: (value: any) => void;
onFocus?: () => void; onFocus?: () => void;
@ -100,7 +100,7 @@ export interface FileUploadProps {
/** /**
* *
*/ */
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error'; export type FileUploadStatus = "idle" | "uploading" | "success" | "error";
/** /**
* *