Merge pull request 'feature/v2-unified-renewal' (#381) from feature/v2-unified-renewal into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/381
This commit is contained in:
kjs 2026-02-06 10:21:07 +09:00
commit 1cbaf07cfd
14 changed files with 2055 additions and 335 deletions

View File

@ -73,20 +73,4 @@ router.get("/categories/:categoryCode/options", (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;

View File

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

View File

@ -47,11 +47,11 @@ class NumberingRuleService {
logger.info("채번 규칙 목록 조회 시작", { companyCode });
const pool = getPool();
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사 데이터 조회 가능
query = `
@ -107,7 +107,7 @@ class NumberingRuleService {
for (const rule of result.rows) {
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 파트 조회
partsQuery = `
@ -156,7 +156,7 @@ class NumberingRuleService {
/**
* ( )
*
*
* :
* - menuObjid가
* - 우선순위: menu ( ) > table > global
@ -166,7 +166,7 @@ class NumberingRuleService {
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode,
@ -178,14 +178,17 @@ class NumberingRuleService {
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
if (menuObjid) {
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", {
menuObjid,
menuAndChildObjids,
});
}
// menuObjid가 없으면 global 규칙만 반환
if (!menuObjid || menuAndChildObjids.length === 0) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 global 규칙 조회
query = `
@ -239,7 +242,7 @@ class NumberingRuleService {
for (const rule of result.rows) {
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
partsQuery = `
SELECT
@ -281,7 +284,7 @@ class NumberingRuleService {
// 우선순위: menu (형제 메뉴) > table > global
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회
query = `
@ -333,7 +336,7 @@ class NumberingRuleService {
logger.info("🔍 채번 규칙 쿼리 실행", {
queryPreview: query.substring(0, 200),
paramsTypes: params.map(p => typeof p),
paramsTypes: params.map((p) => typeof p),
paramsValues: params,
});
@ -346,7 +349,7 @@ class NumberingRuleService {
try {
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
partsQuery = `
SELECT
@ -379,7 +382,7 @@ class NumberingRuleService {
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
@ -537,11 +540,11 @@ class NumberingRuleService {
companyCode: string
): Promise<NumberingRuleConfig | null> {
const pool = getPool();
// 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 가능
query = `
@ -598,7 +601,7 @@ class NumberingRuleService {
// 파트 정보 조회
let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") {
partsQuery = `
SELECT
@ -836,12 +839,12 @@ class NumberingRuleService {
return { ...ruleResult.rows[0], parts };
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("채번 규칙 수정 실패", {
logger.error("채번 규칙 수정 실패", {
ruleId,
companyCode,
error: error.message,
stack: error.stack,
updates
updates,
});
throw error;
} finally {
@ -875,7 +878,7 @@ class NumberingRuleService {
* @param formData ( )
*/
async previewCode(
ruleId: string,
ruleId: string,
companyCode: string,
formData?: Record<string, any>
): Promise<string> {
@ -911,21 +914,26 @@ class NumberingRuleService {
case "date": {
// 날짜 (다양한 날짜 형식)
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
if (
autoConfig.useColumnValue &&
autoConfig.sourceColumnName &&
formData
) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
const dateValue = columnValue instanceof Date
? columnValue
: new Date(columnValue);
const dateValue =
columnValue instanceof Date
? columnValue
: new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
return this.formatDate(dateValue, dateFormat);
}
}
}
return this.formatDate(new Date(), dateFormat);
}
@ -938,63 +946,68 @@ class NumberingRuleService {
// 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
logger.warn("카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
logger.warn("카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr) return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
// valueCode로 매칭 (라벨과 동일할 수 있음)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
const mapping = categoryMappings.find((m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
// valueCode로 매칭 (라벨과 동일할 수 있음)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel
}))
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
@ -1006,7 +1019,12 @@ class NumberingRuleService {
});
const previewCode = parts.join(rule.separator || "");
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData });
logger.info("코드 미리보기 생성", {
ruleId,
previewCode,
companyCode,
hasFormData: !!formData,
});
return previewCode;
}
@ -1018,8 +1036,8 @@ class NumberingRuleService {
* @param userInputCode ( )
*/
async allocateCode(
ruleId: string,
companyCode: string,
ruleId: string,
companyCode: string,
formData?: Record<string, any>,
userInputCode?: string
): Promise<string> {
@ -1033,9 +1051,11 @@ class NumberingRuleService {
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[] = [];
if (manualParts.length > 0 && userInputCode) {
// 프리뷰 코드를 생성해서 ____ 위치 파악
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
@ -1059,39 +1079,38 @@ class NumberingRuleService {
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "CATEGORY"; // 폴백
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "CATEGORY"; // 폴백
}
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
const mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
return mapping?.format || "CATEGORY";
}
default:
return "";
}
});
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
const templateParts = previewTemplate.split("____");
@ -1100,19 +1119,23 @@ class NumberingRuleService {
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
// prefix 이후 부분 추출
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
// suffix 이전까지가 수동 입력 값
if (suffix) {
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
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) {
extractedManualValues.push(remainingCode.slice(0, manualEndIndex));
extractedManualValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} 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;
@ -1130,7 +1155,10 @@ class NumberingRuleService {
.map((part: any) => {
if (part.generationMethod === "manual") {
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || "";
const manualValue =
extractedManualValues[manualPartIndex] ||
part.manualConfig?.value ||
"";
manualPartIndex++;
return manualValue;
}
@ -1155,16 +1183,21 @@ class NumberingRuleService {
case "date": {
// 날짜 (다양한 날짜 형식)
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
if (
autoConfig.useColumnValue &&
autoConfig.sourceColumnName &&
formData
) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
// 날짜 문자열 또는 Date 객체를 Date로 변환
const dateValue = columnValue instanceof Date
? columnValue
: new Date(columnValue);
const dateValue =
columnValue instanceof Date
? columnValue
: new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
logger.info("컬럼 기준 날짜 생성", {
sourceColumn: autoConfig.sourceColumnName,
@ -1185,7 +1218,7 @@ class NumberingRuleService {
});
}
}
// 기본: 현재 날짜 사용
return this.formatDate(new Date(), dateFormat);
}
@ -1199,60 +1232,65 @@ class NumberingRuleService {
// 카테고리 기반 코드 생성 (allocateCode용)
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("allocateCode: 카테고리 파트 처리", {
categoryKey,
columnName,
logger.info("allocateCode: 카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr) return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
const mapping = categoryMappings.find((m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (mapping) {
logger.info("allocateCode: 카테고리 매핑 적용", {
selectedValue,
logger.info("allocateCode: 카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel
}))
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
@ -1344,14 +1382,17 @@ class NumberingRuleService {
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
try {
logger.info("[테스트] 채번 규칙 목록 조회 시작", { companyCode, menuObjid });
logger.info("[테스트] 채번 규칙 목록 조회 시작", {
companyCode,
menuObjid,
});
const pool = getPool();
// 멀티테넌시: 최고 관리자 vs 일반 회사
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회
query = `
@ -1508,7 +1549,10 @@ class NumberingRuleService {
WHERE rule_id = $1 AND company_code = $2
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;
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
@ -1556,7 +1600,10 @@ class NumberingRuleService {
SELECT rule_id FROM numbering_rules
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) {
// 업데이트
@ -1671,7 +1718,10 @@ class NumberingRuleService {
try {
await client.query("BEGIN");
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode });
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", {
ruleId,
companyCode,
});
// 파트 먼저 삭제
await client.query(
@ -1779,7 +1829,10 @@ class NumberingRuleService {
WHERE rule_id = $1 AND company_code = $2
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;
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
@ -1814,7 +1867,11 @@ class NumberingRuleService {
AND r.category_value_id IS NULL
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) {
const rule = defaultResult.rows[0];
@ -1831,7 +1888,10 @@ class NumberingRuleService {
WHERE rule_id = $1 AND company_code = $2
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;
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
@ -1891,8 +1951,12 @@ class NumberingRuleService {
AND r.column_name = $3
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) {
const partsQuery = `
@ -1907,7 +1971,10 @@ class NumberingRuleService {
WHERE rule_id = $1 AND company_code = $2
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;
}
@ -1928,11 +1995,21 @@ class NumberingRuleService {
async copyRulesForCompany(
sourceCompanyCode: 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 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 {
await client.query("BEGIN");
@ -1950,9 +2027,9 @@ class NumberingRuleService {
[targetCompanyCode]
);
if (deleteResult.rowCount && deleteResult.rowCount > 0) {
logger.info("기존 채번규칙 삭제", {
targetCompanyCode,
deletedCount: deleteResult.rowCount
logger.info("기존 채번규칙 삭제", {
targetCompanyCode,
deletedCount: deleteResult.rowCount,
});
}
@ -1962,9 +2039,9 @@ class NumberingRuleService {
[sourceCompanyCode]
);
logger.info("원본 채번규칙 조회", {
sourceCompanyCode,
count: sourceRulesResult.rowCount
logger.info("원본 채번규칙 조회", {
sourceCompanyCode,
count: sourceRulesResult.rowCount,
});
// 2. 각 채번규칙 복제
@ -2038,18 +2115,18 @@ class NumberingRuleService {
result.ruleIdMap[rule.rule_id] = newRuleId;
result.copiedCount++;
result.details.push(`복제 완료: ${rule.rule_name}`);
logger.info("채번규칙 복제 완료", {
ruleName: rule.rule_name,
logger.info("채번규칙 복제 완료", {
ruleName: rule.rule_name,
oldRuleId: rule.rule_id,
newRuleId
newRuleId,
});
}
// 3. 화면 레이아웃의 numberingRuleId 참조 업데이트
if (Object.keys(result.ruleIdMap).length > 0) {
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
targetCompanyCode,
mappingCount: Object.keys(result.ruleIdMap).length
mappingCount: Object.keys(result.ruleIdMap).length,
});
// 대상 회사의 모든 화면 레이아웃 조회
@ -2069,9 +2146,13 @@ class NumberingRuleService {
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}"`)) {
propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`);
propsStr = propsStr
.split(`"${oldRuleId}"`)
.join(`"${newRuleId}"`);
updated = true;
}
}
@ -2085,27 +2166,33 @@ class NumberingRuleService {
}
}
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
targetCompanyCode,
updatedLayouts
updatedLayouts,
});
result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`);
result.details.push(
`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`
);
}
await client.query("COMMIT");
logger.info("회사별 채번규칙 복제 완료", {
sourceCompanyCode,
targetCompanyCode,
logger.info("회사별 채번규칙 복제 완료", {
sourceCompanyCode,
targetCompanyCode,
copiedCount: result.copiedCount,
skippedCount: result.skippedCount,
ruleIdMapCount: Object.keys(result.ruleIdMap).length
ruleIdMapCount: Object.keys(result.ruleIdMap).length,
});
return result;
} catch (error) {
await client.query("ROLLBACK");
logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode });
logger.error("회사별 채번규칙 복제 실패", {
error,
sourceCompanyCode,
targetCompanyCode,
});
throw error;
} finally {
client.release();

View File

@ -3869,6 +3869,7 @@ export class TableManagementService {
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}>
> {
return await entityJoinService.getReferenceTableColumns(tableName);

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
@ -16,7 +16,7 @@ import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management";
import {
ComponentData,
WidgetComponent,
@ -164,6 +164,8 @@ interface InteractiveScreenViewerProps {
enableAutoSave?: boolean;
showToastMessages?: boolean;
};
// 🆕 레이어 시스템 지원
layers?: LayerDefinition[];
}
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
@ -178,6 +180,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
tableColumns = [],
showValidationPanel = false,
validationOptions = {},
layers = [], // 🆕 레이어 목록
}) => {
// component가 없으면 빈 div 반환
if (!component) {
@ -206,9 +209,81 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 🆕 레이어 상태 관리 (런타임용)
const [activeLayerIds, setActiveLayerIds] = useState<string[]>([]);
// 🆕 초기 레이어 설정 (visible인 레이어들)
useEffect(() => {
if (layers.length > 0) {
const initialActiveLayers = layers.filter((l) => l.isVisible).map((l) => l.id);
setActiveLayerIds(initialActiveLayers);
}
}, [layers]);
// 🆕 레이어 제어 액션 핸들러
const handleLayerAction = useCallback((action: string, layerId: string) => {
setActiveLayerIds((prev) => {
switch (action) {
case "show":
return [...new Set([...prev, layerId])];
case "hide":
return prev.filter((id) => id !== layerId);
case "toggle":
return prev.includes(layerId)
? prev.filter((id) => id !== layerId)
: [...prev, layerId];
case "exclusive":
// 해당 레이어만 표시 (모달/드로어 같은 특수 레이어 처리에 활용)
return [...prev, layerId];
default:
return prev;
}
});
}, []);
// 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData };
// 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가)
useEffect(() => {
layers.forEach((layer) => {
if (layer.type === "conditional" && layer.condition) {
const { targetComponentId, operator, value } = layer.condition;
// 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;
switch (operator) {
case "eq":
isMatch = targetValue == value;
break;
case "neq":
isMatch = targetValue != value;
break;
case "in":
isMatch = Array.isArray(value) && value.includes(targetValue);
break;
}
if (isMatch) {
handleLayerAction("show", layer.id);
} else {
handleLayerAction("hide", layer.id);
}
}
});
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation(
@ -1395,7 +1470,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
@ -1413,7 +1487,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</SelectItem>
))}
</SelectContent>
</Select>,
</Select>
);
}
@ -2124,6 +2198,159 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
: component;
// 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
if (!activeLayerIds.includes(layer.id)) return null;
// 모달 레이어 처리
if (layer.type === "modal") {
const modalStyle: React.CSSProperties = {
...(layer.overlayConfig?.backgroundColor && { backgroundColor: layer.overlayConfig.backgroundColor }),
...(layer.overlayConfig?.backdropBlur && { backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)` }),
};
return (
<Dialog key={layer.id} open={true} onOpenChange={() => handleLayerAction("hide", layer.id)}>
<DialogContent
className="max-w-4xl max-h-[90vh] overflow-hidden"
style={modalStyle}
>
<DialogHeader>
<DialogTitle>{layer.name}</DialogTitle>
</DialogHeader>
<div className="relative h-full w-full min-h-[300px]">
{layer.components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
// 드로어 레이어 처리
if (layer.type === "drawer") {
const drawerPosition = layer.overlayConfig?.position || "right";
const drawerWidth = layer.overlayConfig?.width || "400px";
const drawerHeight = layer.overlayConfig?.height || "100%";
const drawerPositionStyles: Record<string, React.CSSProperties> = {
right: { right: 0, top: 0, width: drawerWidth, height: "100%" },
left: { left: 0, top: 0, width: drawerWidth, height: "100%" },
bottom: { bottom: 0, left: 0, width: "100%", height: drawerHeight },
top: { top: 0, left: 0, width: "100%", height: drawerHeight },
};
return (
<div
key={layer.id}
className="fixed inset-0 z-50"
onClick={() => handleLayerAction("hide", layer.id)}
>
{/* 백드롭 */}
<div
className="absolute inset-0 bg-black/50"
style={{
...(layer.overlayConfig?.backdropBlur && {
backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)`
}),
}}
/>
{/* 드로어 패널 */}
<div
className="absolute bg-background shadow-lg"
style={drawerPositionStyles[drawerPosition]}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-lg font-semibold">{layer.name}</h3>
<Button
variant="ghost"
size="icon"
onClick={() => handleLayerAction("hide", layer.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="relative h-full overflow-auto p-4">
{layer.components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
</div>
</div>
);
}
// 일반/조건부 레이어 (base, conditional)
return (
<div
key={layer.id}
className="pointer-events-none absolute inset-0"
style={{ zIndex: layer.zIndex }}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
return (
<SplitPanelProvider>
<ActiveTabProvider>
@ -2147,6 +2374,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</div>
</div>
{/* 🆕 레이어 렌더링 */}
{layers.length > 0 && layers.map(renderLayerComponents)}
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">

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

@ -0,0 +1,405 @@
import React, { useState, useMemo, useCallback } from "react";
import { useLayer } from "@/contexts/LayerContext";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Eye,
EyeOff,
Lock,
Unlock,
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
PanelRight,
ChevronDown,
ChevronRight,
Settings2,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
import { LayerConditionPanel } from "./LayerConditionPanel";
// 레이어 타입별 아이콘
const getLayerTypeIcon = (type: LayerType) => {
switch (type) {
case "base":
return <Layers className="h-3 w-3" />;
case "conditional":
return <SplitSquareVertical className="h-3 w-3" />;
case "modal":
return <Settings2 className="h-3 w-3" />;
case "drawer":
return <PanelRight className="h-3 w-3" />;
default:
return <Layers className="h-3 w-3" />;
}
};
// 레이어 타입별 라벨
function getLayerTypeLabel(type: LayerType): string {
switch (type) {
case "base":
return "기본";
case "conditional":
return "조건부";
case "modal":
return "모달";
case "drawer":
return "드로어";
default:
return type;
}
}
// 레이어 타입별 색상
function getLayerTypeColor(type: LayerType): string {
switch (type) {
case "base":
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
case "conditional":
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
case "modal":
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
case "drawer":
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
default:
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
}
}
interface LayerItemProps {
layer: LayerDefinition;
isActive: boolean;
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
onSelect: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onRemove: () => void;
onUpdateName: (name: string) => void;
onUpdateCondition: (condition: LayerCondition | undefined) => void;
}
const LayerItem: React.FC<LayerItemProps> = ({
layer,
isActive,
componentCount,
allComponents,
onSelect,
onToggleVisibility,
onToggleLock,
onRemove,
onUpdateName,
onUpdateCondition,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isConditionOpen, setIsConditionOpen] = useState(false);
// 조건부 레이어인지 확인
const isConditionalLayer = layer.type === "conditional";
// 조건 설정 여부
const hasCondition = !!layer.condition;
return (
<div className="space-y-0">
{/* 레이어 메인 영역 */}
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
isConditionOpen && "rounded-b-none border-b-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 items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</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>
{/* 레이어 메타 정보 */}
<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();
setIsConditionOpen(!isConditionOpen);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
<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
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>
{/* 조건 설정 패널 (조건부 레이어만) */}
{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>
);
};
interface LayerManagerPanelProps {
components?: ComponentData[]; // layout.components를 전달받음
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
const {
layers,
activeLayerId,
setActiveLayerId,
addLayer,
removeLayer,
toggleLayerVisibility,
toggleLayerLock,
updateLayer,
} = useLayer();
// 레이어 조건 업데이트 핸들러
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
updateLayer(layerId, { condition });
}, [updateLayer]);
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
const componentCountByLayer = useMemo(() => {
const counts: Record<string, number> = {};
// 모든 레이어를 0으로 초기화
layers.forEach(layer => {
counts[layer.id] = 0;
});
// layout.components에서 layerId별로 카운트
components.forEach(comp => {
const layerId = comp.layerId || "default-layer";
if (counts[layerId] !== undefined) {
counts[layerId]++;
} else {
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
if (counts["default-layer"] !== undefined) {
counts["default-layer"]++;
}
}
});
return counts;
}, [components, layers]);
return (
<div className="bg-background flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold"></h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{layers.length}
</Badge>
</div>
{/* 레이어 추가 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
<Plus className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
<SplitSquareVertical className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
<Settings2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
<PanelRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 레이어 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{layers.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
.
<br />
<span className="text-xs"> + .</span>
</div>
) : (
layers
.slice()
.reverse() // 상위 레이어가 위에 표시
.map((layer) => (
<LayerItem
key={layer.id}
layer={layer}
isActive={activeLayerId === layer.id}
componentCount={componentCountByLayer[layer.id] || 0}
allComponents={components}
onSelect={() => setActiveLayerId(layer.id)}
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
onToggleLock={() => toggleLayerLock(layer.id)}
onRemove={() => removeLayer(layer.id)}
onUpdateName={(name) => updateLayer(layer.id, { name })}
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
/>
))
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p>더블클릭: 이름 | 드래그: 순서 </p>
</div>
</div>
);
};

View File

@ -123,9 +123,12 @@ interface ScreenDesignerProps {
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
// 패널 설정 (통합 패널 1개)
import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
import { LayerManagerPanel } from "./LayerManagerPanel";
import { LayerType, LayerDefinition } from "@/types/screen-management";
// 패널 설정 업데이트
const panelConfigs: PanelConfig[] = [
// 통합 패널 (컴포넌트 + 편집 탭)
{
id: "v2",
title: "패널",
@ -134,12 +137,17 @@ const panelConfigs: PanelConfig[] = [
defaultHeight: 700,
shortcutKey: "p",
},
{
id: "layer",
title: "레이어",
defaultPosition: "right",
defaultWidth: 240,
defaultHeight: 500,
shortcutKey: "l",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: {
@ -171,6 +179,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
);
// 🆕 패널 상태 관리 (usePanelState 훅)
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } =
usePanelState(panelConfigs);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원)
@ -438,6 +450,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 🆕 검색어로 필터링된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm.trim()) return tables;
const term = searchTerm.toLowerCase();
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(term) ||
table.columns?.some((col) => col.columnName.toLowerCase().includes(term)),
);
}, [tables, searchTerm]);
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
@ -462,15 +485,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return lines;
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const visibleComponents = useMemo(() => {
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
if (!activeLayerId) {
return layout.components;
}
// 활성 레이어에 속한 컴포넌트만 필터링
return layout.components.filter((comp) => {
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const compLayerId = comp.layerId || "default-layer";
return compLayerId === activeLayerId;
});
}, [layout.components, activeLayerId]);
// 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => {
@ -1798,9 +1831,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
const updatedLayers = layout.layers?.map((layer) => ({
...layer,
components: layer.components.map((comp) => {
// 분할 패널 업데이트 로직 적용
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
return updatedComp || comp;
}),
}));
const layoutWithResolution = {
...layout,
components: updatedComponents,
layers: updatedLayers, // 🆕 레이어 정보 포함
screenResolution: screenResolution,
mainTableName: currentMainTableName, // 화면의 기본 테이블
};
@ -2339,23 +2383,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
});
// 🆕 현재 활성 레이어에 컴포넌트 추가
const componentsWithLayerId = newComponents.map((comp) => ({
...comp,
layerId: activeLayerId || "default-layer",
}));
// 레이아웃에 새 컴포넌트들 추가
const newLayout = {
...layout,
components: [...layout.components, ...newComponents],
components: [...layout.components, ...componentsWithLayerId],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 첫 번째 컴포넌트 선택
if (newComponents.length > 0) {
setSelectedComponent(newComponents[0]);
if (componentsWithLayerId.length > 0) {
setSelectedComponent(componentsWithLayerId[0]);
}
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory],
[layout, selectedScreen, saveToHistory, activeLayerId],
);
// 레이아웃 드래그 처리
@ -2409,6 +2459,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig,
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
@ -2425,7 +2476,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, screenResolution, saveToHistory, zoomLevel],
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -3016,6 +3067,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
position: snappedPosition,
size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가
@ -3049,7 +3101,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory],
[layout, selectedScreen, saveToHistory, activeLayerId],
);
// 드래그 앤 드롭 처리
@ -3421,6 +3473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
style: {
labelDisplay: true,
labelFontSize: "14px",
@ -3671,6 +3724,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -3737,6 +3791,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -4388,7 +4443,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y),
};
const selectedIds = layout.components
// 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만)
const selectedIds = visibleComponents
.filter((comp) => {
const compRect = {
left: comp.position.x,
@ -4411,7 +4467,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
selectedComponents: selectedIds,
}));
},
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components, zoomLevel],
[selectionDrag.isSelecting, selectionDrag.startPoint, visibleComponents, zoomLevel],
);
// 드래그 선택 종료
@ -4558,6 +4614,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
z: clipComponent.position.z || 1,
} as Position,
parentId: undefined, // 붙여넣기 시 부모 관계 해제
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
};
newComponents.push(newComponent);
});
@ -4578,7 +4635,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]);
}, [clipboard, layout, saveToHistory, activeLayerId]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
@ -5374,6 +5431,36 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
};
}, [layout, selectedComponent]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
// components는 그대로 유지 - layerId 속성으로 레이어 구분
// components: prevLayout.components (기본값으로 유지됨)
}));
}, []);
// 🆕 활성 레이어 변경 핸들러
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
setActiveLayerIdLocal(newActiveLayerId);
}, []);
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
const initialLayers = useMemo<LayerDefinition[]>(() => {
if (layout.layers && layout.layers.length > 0) {
// 기존 레이어 구조 사용 (layer.components는 무시하고 빈 배열로 설정)
return layout.layers.map(layer => ({
...layer,
components: [], // layout.components + layerId 방식 사용
}));
}
// layers가 없으면 기본 레이어 생성 (components는 빈 배열)
return [createDefaultLayer()];
}, [layout.layers]);
if (!selectedScreen) {
return (
<div className="bg-background flex h-full items-center justify-center">
@ -5393,7 +5480,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return (
<ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider>
<LayerProvider
initialLayers={initialLayers}
onLayersChange={handleLayersChange}
onActiveLayerChange={handleActiveLayerChange}
>
<TableOptionsProvider>
<div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */}
<SlimToolbar
@ -5428,10 +5520,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-2 gap-1">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
<TabsTrigger value="components" className="text-xs">
</TabsTrigger>
<TabsTrigger value="layers" className="text-xs">
</TabsTrigger>
<TabsTrigger value="properties" className="text-xs">
</TabsTrigger>
@ -5457,6 +5552,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
/>
</TabsContent>
{/* 🆕 레이어 관리 탭 */}
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
<LayerManagerPanel components={layout.components} />
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
{/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */}
{selectedTabComponentInfo ? (
@ -6088,7 +6188,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
{/* 컴포넌트들 */}
{(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리
const topLevelComponents = layout.components.filter((component) => !component.parentId);
// visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시
const topLevelComponents = visibleComponents.filter((component) => !component.parentId);
// auto-compact 모드의 버튼들을 그룹별로 묶기
const buttonGroups: Record<string, ComponentData[]> = {};
@ -6740,6 +6841,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
/>
</div>
</TableOptionsProvider>
</LayerProvider>
</ScreenPreviewProvider>
);
}

View File

@ -208,17 +208,14 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId?.startsWith("v2-")) {
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
"v2-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel,
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel")
.V2SelectConfigPanel,
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel").V2SelectConfigPanel,
"v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel,
"v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel,
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel")
.V2LayoutConfigPanel,
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel")
.V2HierarchyConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
};
const V2ConfigPanel = v2ConfigPanels[componentId];
@ -823,7 +820,11 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || selectedComponent.componentConfig?.label || "")}
value={
selectedComponent.style?.labelText !== undefined
? selectedComponent.style.labelText
: selectedComponent.label || selectedComponent.componentConfig?.label || ""
}
onChange={(e) => {
handleUpdate("style.labelText", e.target.value);
handleUpdate("label", e.target.value); // label도 함께 업데이트
@ -870,10 +871,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
handleUpdate("labelDisplay", boolValue);
// labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음)
if (boolValue && !selectedComponent.style?.labelText) {
const labelValue =
selectedComponent.label ||
selectedComponent.componentConfig?.label ||
"";
const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || "";
if (labelValue) {
handleUpdate("style.labelText", labelValue);
}
@ -963,8 +961,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}
// 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리
const v2ComponentType =
(selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
if (v2ComponentType.startsWith("v2-")) {
const configPanel = renderComponentConfigPanel();
if (configPanel) {

View File

@ -0,0 +1,337 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo } from "react";
import { LayerDefinition, LayerType, ComponentData } from "@/types/screen-management";
import { v4 as uuidv4 } from "uuid";
interface LayerContextType {
// 레이어 상태
layers: LayerDefinition[];
activeLayerId: string | null;
activeLayer: LayerDefinition | null;
// 레이어 관리
setLayers: (layers: LayerDefinition[]) => void;
setActiveLayerId: (id: string | null) => void;
addLayer: (type: LayerType, name?: string) => void;
removeLayer: (id: string) => void;
updateLayer: (id: string, updates: Partial<LayerDefinition>) => void;
moveLayer: (dragIndex: number, hoverIndex: number) => void;
toggleLayerVisibility: (id: string) => void;
toggleLayerLock: (id: string) => void;
getLayerById: (id: string) => LayerDefinition | undefined;
// 컴포넌트 관리 (레이어별)
addComponentToLayer: (layerId: string, component: ComponentData) => void;
removeComponentFromLayer: (layerId: string, componentId: string) => void;
updateComponentInLayer: (layerId: string, componentId: string, updates: Partial<ComponentData>) => void;
moveComponentToLayer: (componentId: string, fromLayerId: string, toLayerId: string) => void;
// 컴포넌트 조회
getAllComponents: () => ComponentData[];
getComponentById: (componentId: string) => { component: ComponentData; layerId: string } | null;
getComponentsInActiveLayer: () => ComponentData[];
// 레이어 가시성 (런타임용)
runtimeVisibleLayers: string[];
setRuntimeVisibleLayers: React.Dispatch<React.SetStateAction<string[]>>;
showLayer: (layerId: string) => void;
hideLayer: (layerId: string) => void;
toggleLayerRuntime: (layerId: string) => void;
}
const LayerContext = createContext<LayerContextType | undefined>(undefined);
export const useLayer = () => {
const context = useContext(LayerContext);
if (!context) {
throw new Error("useLayer must be used within a LayerProvider");
}
return context;
};
// LayerProvider가 없을 때 사용할 기본 컨텍스트 (선택적 사용)
export const useLayerOptional = () => {
return useContext(LayerContext);
};
interface LayerProviderProps {
children: ReactNode;
initialLayers?: LayerDefinition[];
onLayersChange?: (layers: LayerDefinition[]) => void;
onActiveLayerChange?: (activeLayerId: string | null) => void; // 🆕 활성 레이어 변경 콜백
}
// 기본 레이어 생성 헬퍼
export const createDefaultLayer = (components?: ComponentData[]): LayerDefinition => ({
id: "default-layer",
name: "기본 레이어",
type: "base",
zIndex: 0,
isVisible: true,
isLocked: false,
components: components || [],
});
export const LayerProvider: React.FC<LayerProviderProps> = ({
children,
initialLayers = [],
onLayersChange,
onActiveLayerChange,
}) => {
// 초기 레이어가 없으면 기본 레이어 생성
const effectiveInitialLayers = initialLayers.length > 0
? initialLayers
: [createDefaultLayer()];
const [layers, setLayersState] = useState<LayerDefinition[]>(effectiveInitialLayers);
const [activeLayerIdState, setActiveLayerIdState] = useState<string | null>(
effectiveInitialLayers.length > 0 ? effectiveInitialLayers[0].id : null,
);
// 🆕 활성 레이어 변경 시 콜백 호출
const setActiveLayerId = useCallback((id: string | null) => {
setActiveLayerIdState(id);
onActiveLayerChange?.(id);
}, [onActiveLayerChange]);
// 활성 레이어 ID (내부 상태 사용)
const activeLayerId = activeLayerIdState;
// 런타임 가시성 상태 (편집기에서의 isVisible과 별개)
const [runtimeVisibleLayers, setRuntimeVisibleLayers] = useState<string[]>(
effectiveInitialLayers.filter(l => l.isVisible).map(l => l.id)
);
// 레이어 변경 시 콜백 호출
const setLayers = useCallback((newLayers: LayerDefinition[]) => {
setLayersState(newLayers);
onLayersChange?.(newLayers);
}, [onLayersChange]);
// 활성 레이어 계산
const activeLayer = useMemo(() => {
return layers.find(l => l.id === activeLayerId) || null;
}, [layers, activeLayerId]);
const addLayer = useCallback(
(type: LayerType, name?: string) => {
const newLayer: LayerDefinition = {
id: uuidv4(),
name: name || `새 레이어 ${layers.length + 1}`,
type,
zIndex: layers.length,
isVisible: true,
isLocked: false,
components: [],
// 모달/드로어 기본 설정
...(type === "modal" || type === "drawer" ? {
overlayConfig: {
backdrop: true,
closeOnBackdropClick: true,
width: type === "drawer" ? "320px" : "600px",
height: type === "drawer" ? "100%" : "auto",
},
} : {}),
};
setLayers([...layers, newLayer]);
setActiveLayerId(newLayer.id);
// 새 레이어는 런타임에서도 기본적으로 표시
setRuntimeVisibleLayers(prev => [...prev, newLayer.id]);
},
[layers, setLayers],
);
const removeLayer = useCallback(
(id: string) => {
// 기본 레이어는 삭제 불가
const layer = layers.find(l => l.id === id);
if (layer?.type === "base") {
console.warn("기본 레이어는 삭제할 수 없습니다.");
return;
}
const filtered = layers.filter((layer) => layer.id !== id);
setLayers(filtered);
if (activeLayerId === id) {
setActiveLayerId(filtered.length > 0 ? filtered[0].id : null);
}
setRuntimeVisibleLayers(prev => prev.filter(lid => lid !== id));
},
[layers, activeLayerId, setLayers],
);
const updateLayer = useCallback((id: string, updates: Partial<LayerDefinition>) => {
setLayers(layers.map((layer) => (layer.id === id ? { ...layer, ...updates } : layer)));
}, [layers, setLayers]);
const moveLayer = useCallback((dragIndex: number, hoverIndex: number) => {
const newLayers = [...layers];
const [removed] = newLayers.splice(dragIndex, 1);
newLayers.splice(hoverIndex, 0, removed);
// Update zIndex based on new order
setLayers(newLayers.map((layer, index) => ({ ...layer, zIndex: index })));
}, [layers, setLayers]);
const toggleLayerVisibility = useCallback((id: string) => {
setLayers(layers.map((layer) => (layer.id === id ? { ...layer, isVisible: !layer.isVisible } : layer)));
}, [layers, setLayers]);
const toggleLayerLock = useCallback((id: string) => {
setLayers(layers.map((layer) => (layer.id === id ? { ...layer, isLocked: !layer.isLocked } : layer)));
}, [layers, setLayers]);
const getLayerById = useCallback(
(id: string) => {
return layers.find((layer) => layer.id === id);
},
[layers],
);
// ===== 컴포넌트 관리 함수 =====
const addComponentToLayer = useCallback((layerId: string, component: ComponentData) => {
setLayers(layers.map(layer => {
if (layer.id === layerId) {
return {
...layer,
components: [...layer.components, component],
};
}
return layer;
}));
}, [layers, setLayers]);
const removeComponentFromLayer = useCallback((layerId: string, componentId: string) => {
setLayers(layers.map(layer => {
if (layer.id === layerId) {
return {
...layer,
components: layer.components.filter(c => c.id !== componentId),
};
}
return layer;
}));
}, [layers, setLayers]);
const updateComponentInLayer = useCallback((layerId: string, componentId: string, updates: Partial<ComponentData>) => {
setLayers(layers.map(layer => {
if (layer.id === layerId) {
return {
...layer,
components: layer.components.map(c =>
c.id === componentId ? { ...c, ...updates } as ComponentData : c
),
};
}
return layer;
}));
}, [layers, setLayers]);
const moveComponentToLayer = useCallback((componentId: string, fromLayerId: string, toLayerId: string) => {
if (fromLayerId === toLayerId) return;
const fromLayer = layers.find(l => l.id === fromLayerId);
const component = fromLayer?.components.find(c => c.id === componentId);
if (!component) return;
setLayers(layers.map(layer => {
if (layer.id === fromLayerId) {
return {
...layer,
components: layer.components.filter(c => c.id !== componentId),
};
}
if (layer.id === toLayerId) {
return {
...layer,
components: [...layer.components, component],
};
}
return layer;
}));
}, [layers, setLayers]);
// ===== 컴포넌트 조회 함수 =====
const getAllComponents = useCallback((): ComponentData[] => {
return layers.flatMap(layer => layer.components);
}, [layers]);
const getComponentById = useCallback((componentId: string): { component: ComponentData; layerId: string } | null => {
for (const layer of layers) {
const component = layer.components.find(c => c.id === componentId);
if (component) {
return { component, layerId: layer.id };
}
}
return null;
}, [layers]);
const getComponentsInActiveLayer = useCallback((): ComponentData[] => {
const layer = layers.find(l => l.id === activeLayerId);
return layer?.components || [];
}, [layers, activeLayerId]);
// ===== 런타임 레이어 가시성 관리 =====
const showLayer = useCallback((layerId: string) => {
setRuntimeVisibleLayers(prev => [...new Set([...prev, layerId])]);
}, []);
const hideLayer = useCallback((layerId: string) => {
setRuntimeVisibleLayers(prev => prev.filter(id => id !== layerId));
}, []);
const toggleLayerRuntime = useCallback((layerId: string) => {
setRuntimeVisibleLayers(prev =>
prev.includes(layerId)
? prev.filter(id => id !== layerId)
: [...prev, layerId]
);
}, []);
return (
<LayerContext.Provider
value={{
// 레이어 상태
layers,
activeLayerId,
activeLayer,
// 레이어 관리
setLayers,
setActiveLayerId,
addLayer,
removeLayer,
updateLayer,
moveLayer,
toggleLayerVisibility,
toggleLayerLock,
getLayerById,
// 컴포넌트 관리
addComponentToLayer,
removeComponentFromLayer,
updateComponentInLayer,
moveComponentToLayer,
// 컴포넌트 조회
getAllComponents,
getComponentById,
getComponentsInActiveLayer,
// 런타임 가시성
runtimeVisibleLayers,
setRuntimeVisibleLayers,
showLayer,
hideLayer,
toggleLayerRuntime,
}}
>
{children}
</LayerContext.Provider>
);
};

View File

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

View File

@ -10,7 +10,19 @@ import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react";
import {
Plus,
Trash2,
ArrowUp,
ArrowDown,
ChevronsUpDown,
Check,
Lock,
Unlock,
Database,
Table2,
Link2,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
@ -35,7 +47,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}) => {
// config가 undefined인 경우 빈 객체로 초기화
const config = configProp || {};
// console.log("🔍 TableListConfigPanel props:", {
// config,
// configType: typeof config,
@ -202,12 +214,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
try {
const result = await tableManagementApi.getColumnList(targetTableName);
console.log("🔧 tableManagementApi 응답:", result);
if (result.success && result.data) {
if (result.success && result.data) {
// API 응답 구조: { columns: [...], total, page, ... }
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
console.log("🔧 컬럼 배열:", columns);
if (columns && Array.isArray(columns)) {
setAvailableColumns(
columns.map((col: any) => ({
@ -779,7 +791,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showEditMode ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
/>
<Label htmlFor="showEditMode" className="text-xs"> </Label>
<Label htmlFor="showEditMode" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -787,7 +801,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showExcel ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
/>
<Label htmlFor="showExcel" className="text-xs">Excel</Label>
<Label htmlFor="showExcel" className="text-xs">
Excel
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -795,7 +811,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showPdf ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
/>
<Label htmlFor="showPdf" className="text-xs">PDF</Label>
<Label htmlFor="showPdf" className="text-xs">
PDF
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -803,7 +821,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showCopy ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
/>
<Label htmlFor="showCopy" className="text-xs"></Label>
<Label htmlFor="showCopy" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -811,7 +831,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showSearch ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
/>
<Label htmlFor="showSearch" className="text-xs"></Label>
<Label htmlFor="showSearch" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -819,7 +841,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showFilter ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
/>
<Label htmlFor="showFilter" className="text-xs"></Label>
<Label htmlFor="showFilter" className="text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -827,7 +851,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showRefresh ?? false}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
/>
<Label htmlFor="showRefresh" className="text-xs"> ()</Label>
<Label htmlFor="showRefresh" className="text-xs">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -835,7 +861,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.toolbar?.showPaginationRefresh ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)}
/>
<Label htmlFor="showPaginationRefresh" className="text-xs"> ()</Label>
<Label htmlFor="showPaginationRefresh" className="text-xs">
()
</Label>
</div>
</div>
</div>
@ -1159,7 +1187,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-[10px] text-muted-foreground"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
{availableColumns.length > 0 ? (
@ -1176,7 +1204,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
onClick={() => {
if (isAdded) {
// 컬럼 제거
handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []);
handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== column.columnName) || [],
);
} else {
// 컬럼 추가
addColumn(column.columnName);
@ -1187,7 +1218,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={isAdded}
onCheckedChange={() => {
if (isAdded) {
handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []);
handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== column.columnName) || [],
);
} else {
addColumn(column.columnName);
}
@ -1196,7 +1230,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.label || column.columnName}</span>
<span className="text-[10px] text-gray-400 ml-auto">{column.input_type || column.dataType}</span>
<span className="ml-auto text-[10px] text-gray-400">
{column.input_type || column.dataType}
</span>
</div>
);
})}
@ -1211,13 +1247,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold">Entity </h3>
<p className="text-[10px] text-muted-foreground"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="space-y-3">
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
<div key={tableIndex} className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-medium text-blue-600 mb-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<Badge variant="outline" className="text-[10px]">
@ -1225,56 +1261,65 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</Badge>
</div>
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
{joinTable.availableColumns.map((column, colIndex) => {
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
const isAlreadyAdded = config.columns?.some(
(col) => col.columnName === matchingJoinColumn?.joinAlias,
);
const isAlreadyAdded = config.columns?.some(
(col) => col.columnName === matchingJoinColumn?.joinAlias,
);
if (!matchingJoinColumn) return null;
return (
<div
return (
<div
key={colIndex}
className={cn(
"hover:bg-blue-100/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
isAlreadyAdded && "bg-blue-100",
)}
onClick={() => {
onClick={() => {
if (isAlreadyAdded) {
// 컬럼 제거
handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []);
handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || [],
);
} else {
// 컬럼 추가
addEntityColumn(matchingJoinColumn);
}
}}
>
<Checkbox
<Checkbox
checked={isAlreadyAdded}
onCheckedChange={() => {
if (isAlreadyAdded) {
handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []);
} else {
handleChange(
"columns",
config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) ||
[],
);
} else {
addEntityColumn(matchingJoinColumn);
}
}}
}
}}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="text-blue-500 h-3 w-3 flex-shrink-0" />
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="text-[10px] text-blue-400 ml-auto">{column.inputType || column.dataType}</span>
</div>
);
})}
</div>
</div>
<span className="ml-auto text-[10px] text-blue-400">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</>
)}
@ -1301,7 +1346,6 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
/>
</div>
</div>
</div>
);

View File

@ -148,9 +148,53 @@ export const componentV2Schema = z.object({
overrides: z.record(z.string(), z.any()).default({}),
});
export const layoutV2Schema = z.object({
version: z.string().default("2.0"),
// ============================================
// 레이어 스키마 정의
// ============================================
export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]);
export const layerSchema = z.object({
id: z.string(),
name: z.string(),
type: layerTypeSchema,
zIndex: z.number().default(0),
isVisible: z.boolean().default(true), // 초기 표시 여부
isLocked: z.boolean().default(false), // 편집 잠금 여부
// 조건부 표시 로직
condition: z
.object({
targetComponentId: z.string(),
operator: z.enum(["eq", "neq", "in"]),
value: z.any(),
})
.optional(),
// 모달/드로어 전용 설정
overlayConfig: z
.object({
backdrop: z.boolean().default(true),
closeOnBackdropClick: z.boolean().default(true),
width: z.union([z.string(), z.number()]).optional(),
height: z.union([z.string(), z.number()]).optional(),
// 모달/드로어 스타일링
backgroundColor: z.string().optional(),
backdropBlur: z.number().optional(),
// 드로어 전용
position: z.enum(["left", "right", "top", "bottom"]).optional(),
})
.optional(),
// 해당 레이어에 속한 컴포넌트들
components: z.array(componentV2Schema).default([]),
});
export type Layer = z.infer<typeof layerSchema>;
export const layoutV2Schema = z.object({
version: z.string().default("2.1"),
layers: z.array(layerSchema).default([]), // 신규 필드
components: z.array(componentV2Schema).default([]), // 하위 호환성 유지
updatedAt: z.string().optional(),
screenResolution: z
.object({
@ -952,23 +996,78 @@ export function saveComponentV2(component: ComponentV2 & { config?: Record<strin
// ============================================
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
// ============================================
export function loadLayoutV2(
layoutData: any,
): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } {
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] });
export function loadLayoutV2(layoutData: any): LayoutV2 & {
components: Array<ComponentV2 & { config: Record<string, any> }>;
layers: Array<Layer & { components: Array<ComponentV2 & { config: Record<string, any> }> }>;
} {
const parsed = layoutV2Schema.parse(layoutData || { version: "2.1", components: [], layers: [] });
// 마이그레이션: components만 있고 layers가 없는 경우 Default Layer 생성
if ((!parsed.layers || parsed.layers.length === 0) && parsed.components && parsed.components.length > 0) {
const defaultLayer: Layer = {
id: "default-layer",
name: "기본 레이어",
type: "base",
zIndex: 0,
isVisible: true,
isLocked: false,
components: parsed.components,
};
parsed.layers = [defaultLayer];
}
// 모든 레이어의 컴포넌트 로드
const loadedLayers = parsed.layers.map((layer) => ({
...layer,
components: layer.components.map(loadComponentV2),
}));
// 하위 호환성을 위한 components 배열 (모든 레이어의 컴포넌트 합침)
const allComponents = loadedLayers.flatMap((layer) => layer.components);
return {
...parsed,
components: parsed.components.map(loadComponentV2),
layers: loadedLayers,
components: allComponents,
};
}
// ============================================
// V2 레이아웃 저장 (전체 컴포넌트 차이값 추출)
// ============================================
export function saveLayoutV2(components: Array<ComponentV2 & { config?: Record<string, any> }>): LayoutV2 {
export function saveLayoutV2(
components: Array<ComponentV2 & { config?: Record<string, any> }>,
layers?: Array<Layer & { components: Array<ComponentV2 & { config?: Record<string, any> }> }>,
): LayoutV2 {
// 레이어가 있는 경우 레이어 구조 저장
if (layers && layers.length > 0) {
const savedLayers = layers.map((layer) => ({
...layer,
components: layer.components.map(saveComponentV2),
}));
return {
version: "2.1",
layers: savedLayers,
components: savedLayers.flatMap((l) => l.components), // 하위 호환성
};
}
// 레이어가 없는 경우 (기존 방식) - Default Layer로 감싸서 저장
const savedComponents = components.map(saveComponentV2);
const defaultLayer: Layer = {
id: "default-layer",
name: "기본 레이어",
type: "base",
zIndex: 0,
isVisible: true,
isLocked: false,
components: savedComponents,
};
return {
version: "2.0",
components: components.map(saveComponentV2),
version: "2.1",
layers: [defaultLayer],
components: savedComponents,
};
}

View File

@ -38,6 +38,9 @@ export interface BaseComponent {
gridColumnStart?: number; // 시작 컬럼 (1-12)
gridRowIndex?: number; // 행 인덱스
// 🆕 레이어 시스템
layerId?: string; // 컴포넌트가 속한 레이어 ID
parentId?: string;
label?: string;
required?: boolean;
@ -102,13 +105,13 @@ export interface WidgetComponent extends BaseComponent {
entityConfig?: EntityTypeConfig;
buttonConfig?: ButtonTypeConfig;
arrayConfig?: ArrayTypeConfig;
// 🆕 자동 입력 설정 (테이블 조회 기반)
autoFill?: {
enabled: boolean; // 자동 입력 활성화
sourceTable: string; // 조회할 테이블 (예: company_mng)
filterColumn: string; // 필터링할 컬럼 (예: company_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드
userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보 필드
displayColumn: string; // 표시할 컬럼 (예: company_name)
};
}
@ -148,12 +151,12 @@ export interface DataTableComponent extends BaseComponent {
searchable?: boolean;
sortable?: boolean;
filters?: DataTableFilter[];
// 🆕 현재 사용자 정보로 자동 필터링
autoFilter?: {
enabled: boolean; // 자동 필터 활성화 여부
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보에서 가져올 필드
};
// 🆕 컬럼 값 기반 데이터 필터링
@ -307,13 +310,13 @@ export interface SelectTypeConfig {
required?: boolean;
readonly?: boolean;
emptyMessage?: string;
/** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */
cascadingRelationCode?: string;
/** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */
cascadingParentField?: string;
/** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */
cascading?: CascadingDropdownConfig;
}
@ -402,10 +405,10 @@ export interface EntityTypeConfig {
/**
* 🆕 (Cascading Dropdown)
*
*
* .
* : 창고
*
*
* @example
* // 창고 → 위치 연쇄 드롭다운
* {
@ -420,34 +423,34 @@ export interface EntityTypeConfig {
export interface CascadingDropdownConfig {
/** 연쇄 드롭다운 활성화 여부 */
enabled: boolean;
/** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */
parentField: string;
/** 옵션을 조회할 테이블명 */
sourceTable: string;
/** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */
parentKeyColumn: string;
/** 드롭다운 value로 사용할 컬럼명 */
valueColumn: string;
/** 드롭다운 label로 표시할 컬럼명 */
labelColumn: string;
/** 추가 필터 조건 (선택사항) */
additionalFilters?: Record<string, unknown>;
/** 부모 값이 없을 때 표시할 메시지 */
emptyParentMessage?: string;
/** 옵션이 없을 때 표시할 메시지 */
noOptionsMessage?: string;
/** 로딩 중 표시할 메시지 */
loadingMessage?: string;
/** 부모 값 변경 시 자동으로 값 초기화 */
clearOnParentChange?: boolean;
}
@ -472,23 +475,23 @@ export interface ButtonTypeConfig {
export interface QuickInsertColumnMapping {
/** 저장할 테이블의 대상 컬럼명 */
targetColumn: string;
/** 값 소스 타입 */
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
// sourceType별 추가 설정
/** component: 값을 가져올 컴포넌트 ID */
sourceComponentId?: string;
/** component: 컴포넌트의 columnName (formData 접근용) */
sourceColumnName?: string;
/** leftPanel: 좌측 선택 데이터의 컬럼명 */
sourceColumn?: string;
/** fixed: 고정값 */
fixedValue?: any;
/** currentUser: 사용자 정보 필드 */
userField?: "userId" | "userName" | "companyCode" | "deptCode";
}
@ -499,13 +502,13 @@ export interface QuickInsertColumnMapping {
export interface QuickInsertAfterAction {
/** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */
refreshData?: boolean;
/** 초기화할 컴포넌트 ID 목록 */
clearComponents?: string[];
/** 성공 메시지 표시 여부 */
showSuccessMessage?: boolean;
/** 커스텀 성공 메시지 */
successMessage?: string;
}
@ -516,20 +519,20 @@ export interface QuickInsertAfterAction {
export interface QuickInsertDuplicateCheck {
/** 중복 체크 활성화 */
enabled: boolean;
/** 중복 체크할 컬럼들 */
columns: string[];
/** 중복 시 에러 메시지 */
errorMessage?: string;
}
/**
* (quickInsert)
*
*
* entity ,
* INSERT하는
*
*
* @example
* ```typescript
* const config: QuickInsertConfig = {
@ -557,13 +560,13 @@ export interface QuickInsertDuplicateCheck {
export interface QuickInsertConfig {
/** 저장할 대상 테이블명 */
targetTable: string;
/** 컬럼 매핑 설정 */
columnMappings: QuickInsertColumnMapping[];
/** 저장 후 동작 설정 */
afterInsert?: QuickInsertAfterAction;
/** 중복 체크 설정 (선택사항) */
duplicateCheck?: QuickInsertDuplicateCheck;
}
@ -678,15 +681,15 @@ export interface DataTableFilter {
export interface ColumnFilter {
id: string;
columnName: string; // 필터링할 컬럼명
operator:
| "equals"
| "not_equals"
| "in"
| "not_in"
| "contains"
| "starts_with"
| "ends_with"
| "is_null"
operator:
| "equals"
| "not_equals"
| "in"
| "not_in"
| "contains"
| "starts_with"
| "ends_with"
| "is_null"
| "is_not_null"
| "greater_than"
| "less_than"
@ -836,12 +839,71 @@ export interface GroupState {
groupTitle?: string;
}
// ============================================
// 레이어 시스템 타입 정의
// ============================================
/**
*
* - base: 기본 ( )
* - conditional: 조건부 ( )
* - modal: 모달 ( )
* - drawer: 드로어 ( )
*/
export type LayerType = "base" | "conditional" | "modal" | "drawer";
/**
*
*/
export interface LayerCondition {
targetComponentId: string; // 트리거가 되는 컴포넌트 ID
operator: "eq" | "neq" | "in"; // 비교 연산자
value: any; // 비교할 값
}
/**
* (/)
*/
export interface LayerOverlayConfig {
backdrop: boolean; // 배경 어둡게 처리 여부
closeOnBackdropClick: boolean; // 배경 클릭 시 닫기 여부
width?: string | number; // 너비
height?: string | number; // 높이
// 모달/드로어 스타일링
backgroundColor?: string; // 컨텐츠 배경색
backdropBlur?: number; // 배경 블러 (px)
// 드로어 전용
position?: "left" | "right" | "top" | "bottom"; // 드로어 위치
}
/**
*
*/
export interface LayerDefinition {
id: string;
name: string;
type: LayerType;
zIndex: number;
isVisible: boolean; // 초기 표시 여부
isLocked: boolean; // 편집 잠금 여부
// 조건부 표시 로직
condition?: LayerCondition;
// 모달/드로어 전용 설정
overlayConfig?: LayerOverlayConfig;
// 해당 레이어에 속한 컴포넌트들
components: ComponentData[];
}
/**
*
*/
export interface LayoutData {
screenId: number;
components: ComponentData[];
components: ComponentData[]; // @deprecated - use layers instead (kept for backward compatibility)
layers?: LayerDefinition[]; // 🆕 레이어 목록
gridSettings?: GridSettings;
metadata?: LayoutMetadata;
screenResolution?: ScreenResolution;