Merge origin/main into ksh - resolve conflicts

This commit is contained in:
SeongHyun Kim 2026-01-09 14:55:16 +09:00
commit 23c9604672
61 changed files with 10564 additions and 524 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

View File

@ -42,6 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -3214,6 +3215,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/bwip-js": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/compression": { "node_modules/@types/compression": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",

View File

@ -56,6 +56,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",

View File

@ -282,3 +282,175 @@ export async function previewCodeMerge(
} }
} }
/**
* -
* oldValue를 newValue로
*/
export async function mergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("값 기반 코드 병합 시작", {
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_by_value($1, $2, $3)",
[oldValue, newValue, companyCode]
);
// 결과 처리
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = affectedData.reduce(
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
0
);
logger.info("값 기반 코드 병합 완료", {
oldValue,
newValue,
affectedTablesCount: affectedData.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
oldValue,
newValue,
affectedData: affectedData.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
rowsUpdated: parseInt(row.out_rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 실패:", {
error: error.message,
stack: error.stack,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_BY_VALUE_ERROR",
details: error.message,
},
});
}
}
/**
*
* /
*/
export async function previewMergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM preview_merge_code_by_value($1, $2)",
[oldValue, companyCode]
);
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = preview.reduce(
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
0
);
logger.info("값 기반 코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
oldValue,
preview: preview.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
affectedRows: parseInt(row.out_affected_rows),
})),
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_BY_VALUE_ERROR",
details: error.message,
},
});
}
}

View File

@ -30,6 +30,7 @@ export class EntityJoinController {
autoFilter, // 🔒 멀티테넌시 자동 필터 autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열) dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams ...otherParams
} = req.query; } = req.query;
@ -139,6 +140,24 @@ export class EntityJoinController {
} }
} }
// 🆕 중복 제거 설정 처리
let parsedDeduplication: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
} | undefined = undefined;
if (deduplication) {
try {
parsedDeduplication =
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
} catch (error) {
logger.warn("중복 제거 설정 파싱 오류:", error);
parsedDeduplication = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins( const result = await tableManagementService.getTableDataWithEntityJoins(
tableName, tableName,
{ {
@ -156,13 +175,26 @@ export class EntityJoinController {
screenEntityConfigs: parsedScreenEntityConfigs, screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
} }
); );
// 🆕 중복 제거 처리 (결과 데이터에 적용)
let finalData = result;
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
const originalCount = result.data.length;
finalData = {
...result,
data: this.deduplicateData(result.data, parsedDeduplication),
};
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}`);
}
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: "Entity 조인 데이터 조회 성공", message: "Entity 조인 데이터 조회 성공",
data: result, data: finalData,
}); });
} catch (error) { } catch (error) {
logger.error("Entity 조인 데이터 조회 실패", error); logger.error("Entity 조인 데이터 조회 실패", error);
@ -537,6 +569,98 @@ export class EntityJoinController {
}); });
} }
} }
/**
* ( )
*/
private deduplicateData(
data: any[],
config: {
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): any[] {
if (!data || data.length === 0) return data;
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal > bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal < bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "base_price":
// base_price가 true인 행 선택
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
break;
case "current_date":
// 오늘 날짜 기준 유효 기간 내 행 선택
const today = new Date().toISOString().split("T")[0];
selectedRow = rows.find((r) => {
const startDate = r.start_date;
const endDate = r.end_date;
if (!startDate) return true;
if (startDate <= today && (!endDate || endDate >= today)) return true;
return false;
}) || rows[0];
break;
default:
selectedRow = rows[0];
}
if (selectedRow) {
result.push(selectedRow);
}
}
return result;
}
} }
export const entityJoinController = new EntityJoinController(); export const entityJoinController = new EntityJoinController();

View File

@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { ruleId } = req.params; const { ruleId } = req.params;
logger.info("코드 할당 요청", { ruleId, companyCode });
try { try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } }); return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) { } catch (error: any) {
logger.error("코드 할당 실패", { error: error.message }); logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });
} }
}); });

View File

@ -2185,3 +2185,67 @@ export async function multiTableSave(
} }
} }
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
if (!leftTable || !rightTable) {
const response: ApiResponse<null> = {
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const relations = await tableManagementService.detectTableEntityRelations(
String(leftTable),
String(rightTable)
);
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
const response: ApiResponse<any> = {
success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: {
leftTable: String(leftTable),
rightTable: String(rightTable),
relations,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
error: {
code: "ENTITY_RELATIONS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -3,6 +3,8 @@ import {
mergeCodeAllTables, mergeCodeAllTables,
getTablesWithColumn, getTablesWithColumn,
previewCodeMerge, previewCodeMerge,
mergeCodeByValue,
previewMergeCodeByValue,
} from "../controllers/codeMergeController"; } from "../controllers/codeMergeController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -13,7 +15,7 @@ router.use(authenticateToken);
/** /**
* POST /api/code-merge/merge-all-tables * POST /api/code-merge/merge-all-tables
* ( ) * ( - )
* Body: { columnName, oldValue, newValue } * Body: { columnName, oldValue, newValue }
*/ */
router.post("/merge-all-tables", mergeCodeAllTables); router.post("/merge-all-tables", mergeCodeAllTables);
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
/** /**
* POST /api/code-merge/preview * POST /api/code-merge/preview
* ( ) * ( )
* Body: { columnName, oldValue } * Body: { columnName, oldValue }
*/ */
router.post("/preview", previewCodeMerge); router.post("/preview", previewCodeMerge);
/**
* POST /api/code-merge/merge-by-value
* ( )
* Body: { oldValue, newValue }
*/
router.post("/merge-by-value", mergeCodeByValue);
/**
* POST /api/code-merge/preview-by-value
* ( )
* Body: { oldValue }
*/
router.post("/preview-by-value", previewMergeCodeByValue);
export default router; export default router;

View File

@ -698,6 +698,7 @@ router.post(
try { try {
const { tableName } = req.params; const { tableName } = req.params;
const filterConditions = req.body; const filterConditions = req.body;
const userCompany = req.user?.companyCode;
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({ return res.status(400).json({
@ -706,11 +707,12 @@ router.post(
}); });
} }
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
const result = await dataService.deleteGroupRecords( const result = await dataService.deleteGroupRecords(
tableName, tableName,
filterConditions filterConditions,
userCompany // 회사 코드 전달
); );
if (!result.success) { if (!result.success) {

View File

@ -25,6 +25,7 @@ import {
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -38,6 +39,15 @@ router.use(authenticateToken);
*/ */
router.get("/tables", getTableList); router.get("/tables", getTableList);
/**
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/
router.get("/tables/entity-relations", getTableEntityRelations);
/** /**
* *
* GET /api/table-management/tables/:tableName/columns * GET /api/table-management/tables/:tableName/columns

View File

@ -1189,6 +1189,13 @@ class DataService {
[tableName] [tableName]
); );
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
pkColumns: pkResult.map((r) => r.attname),
pkCount: pkResult.length,
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
inputIdType: typeof id,
});
let whereClauses: string[] = []; let whereClauses: string[] = [];
let params: any[] = []; let params: any[] = [];
@ -1216,17 +1223,31 @@ class DataService {
params.push(typeof id === "object" ? id[pkColumn] : id); params.push(typeof id === "object" ? id[pkColumn] : id);
} }
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params); console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params); const result = await query<any>(queryText, params);
// 삭제된 행이 없으면 실패 처리
if (result.length === 0) {
console.warn(
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
{ whereClauses, params }
);
return {
success: false,
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
console.log( console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
); );
return { return {
success: true, success: true,
data: result[0], // 삭제된 레코드 정보 반환
}; };
} catch (error) { } catch (error) {
console.error(`레코드 삭제 오류 (${tableName}):`, error); console.error(`레코드 삭제 오류 (${tableName}):`, error);
@ -1240,10 +1261,14 @@ class DataService {
/** /**
* ( ) * ( )
* @param tableName
* @param filterConditions
* @param userCompany ( )
*/ */
async deleteGroupRecords( async deleteGroupRecords(
tableName: string, tableName: string,
filterConditions: Record<string, any> filterConditions: Record<string, any>,
userCompany?: string
): Promise<ServiceResponse<{ deleted: number }>> { ): Promise<ServiceResponse<{ deleted: number }>> {
try { try {
const validation = await this.validateTableAccess(tableName); const validation = await this.validateTableAccess(tableName);
@ -1255,6 +1280,7 @@ class DataService {
const whereValues: any[] = []; const whereValues: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 사용자 필터 조건 추가
for (const [key, value] of Object.entries(filterConditions)) { for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`); whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value); whereValues.push(value);
@ -1269,10 +1295,24 @@ class DataService {
}; };
} }
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
if (hasCompanyCode && userCompany && userCompany !== "*") {
whereConditions.push(`"company_code" = $${paramIndex}`);
whereValues.push(userCompany);
paramIndex++;
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
}
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); console.log(`🗑️ 그룹 삭제:`, {
tableName,
conditions: filterConditions,
userCompany,
whereClause,
});
const result = await pool.query(deleteQuery, whereValues); const result = await pool.query(deleteQuery, whereValues);

View File

@ -1306,6 +1306,41 @@ export class TableManagementService {
paramCount: number; paramCount: number;
} | null> { } | null> {
try { try {
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
if (Array.isArray(value) && value.length > 0) {
// 배열의 각 값에 대해 OR 조건으로 검색
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
// 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = [];
const values: any[] = [];
value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
// - 정확히 "2"
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + (idx * 4);
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
});
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
paramCount: values.length,
};
}
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) { if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo( const columnInfo = await this.getColumnWebTypeInfo(
@ -3734,6 +3769,15 @@ export class TableManagementService {
const cacheableJoins: EntityJoinConfig[] = []; const cacheableJoins: EntityJoinConfig[] = [];
const dbJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = [];
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
const companySpecificTables = [
"supplier_mng",
"customer_mng",
"item_info",
"dept_info",
// 필요시 추가
];
for (const config of joinConfigs) { for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") { if (config.referenceTable === "table_column_category_values") {
@ -3742,6 +3786,13 @@ export class TableManagementService {
continue; continue;
} }
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
if (companySpecificTables.includes(config.referenceTable)) {
dbJoins.push(config);
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
continue;
}
// 캐시 가능성 확인 // 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference( const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable, config.referenceTable,
@ -4630,4 +4681,101 @@ export class TableManagementService {
return false; return false;
} }
} }
/**
*
* column_labels에서 .
*
* @param leftTable
* @param rightTable
* @returns
*/
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>> {
try {
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
const relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}> = [];
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
const rightToLeftRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[rightTable, leftTable]
);
for (const rel of rightToLeftRels) {
relations.push({
leftColumn: rel.reference_column,
rightColumn: rel.column_name,
direction: "right_to_left",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: left_table의 item_id -> right_table(item_info)의 item_number
const leftToRightRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''`,
[leftTable, rightTable]
);
for (const rel of leftToRightRels) {
relations.push({
leftColumn: rel.column_name,
rightColumn: rel.reference_column,
direction: "left_to_right",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
});
return relations;
} catch (error) {
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
return [];
}
}
} }

View File

@ -174,8 +174,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) { if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
setFormData(editData);
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) // 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
if (Array.isArray(editData)) {
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
} else {
setFormData(editData);
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
} else { } else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함

View File

@ -4,7 +4,7 @@
* DELETE * DELETE
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react"; import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -24,6 +24,12 @@ interface DeleteActionPropertiesProps {
data: DeleteActionNodeData; data: DeleteActionNodeData;
} }
// 소스 필드 타입
interface SourceField {
name: string;
label?: string;
}
const OPERATORS = [ const OPERATORS = [
{ value: "EQUALS", label: "=" }, { value: "EQUALS", label: "=" },
{ value: "NOT_EQUALS", label: "≠" }, { value: "NOT_EQUALS", label: "≠" },
@ -34,7 +40,7 @@ const OPERATORS = [
] as const; ] as const;
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) { export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore(); const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
// 🔥 타겟 타입 상태 // 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
@ -43,6 +49,10 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
const [targetTable, setTargetTable] = useState(data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
// 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<SourceField[]>([]);
const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState<boolean[]>([]);
// 🔥 외부 DB 관련 상태 // 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]); const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
@ -124,8 +134,106 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
// whereConditions 변경 시 fieldOpenState 초기화 // whereConditions 변경 시 fieldOpenState 초기화
useEffect(() => { useEffect(() => {
setFieldOpenState(new Array(whereConditions.length).fill(false)); setFieldOpenState(new Array(whereConditions.length).fill(false));
setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
}, [whereConditions.length]); }, [whereConditions.length]);
// 🆕 소스 필드 로딩 (연결된 입력 노드에서)
const loadSourceFields = useCallback(async () => {
// 현재 노드로 연결된 엣지 찾기
const incomingEdges = edges.filter((e) => e.target === nodeId);
console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges);
if (incomingEdges.length === 0) {
console.log("⚠️ 연결된 소스 노드가 없습니다");
setSourceFields([]);
return;
}
const fields: SourceField[] = [];
const processedFields = new Set<string>();
for (const edge of incomingEdges) {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (!sourceNode) continue;
console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data);
// 소스 노드 타입에 따라 필드 추출
if (sourceNode.type === "trigger" && sourceNode.data.tableName) {
// 트리거 노드: 테이블 컬럼 조회
try {
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
if (columns && Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (!processedFields.has(colName)) {
processedFields.add(colName);
fields.push({
name: colName,
label: col.columnLabel || col.column_label || colName,
});
}
});
}
} catch (error) {
console.error("트리거 노드 컬럼 로딩 실패:", error);
}
} else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) {
// 테이블 소스 노드
try {
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
if (columns && Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (!processedFields.has(colName)) {
processedFields.add(colName);
fields.push({
name: colName,
label: col.columnLabel || col.column_label || colName,
});
}
});
}
} catch (error) {
console.error("테이블 소스 노드 컬럼 로딩 실패:", error);
}
} else if (sourceNode.type === "condition") {
// 조건 노드: 연결된 이전 노드에서 필드 가져오기
const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id);
for (const condEdge of conditionIncomingEdges) {
const condSourceNode = nodes.find((n) => n.id === condEdge.source);
if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) {
try {
const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName);
if (columns && Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (!processedFields.has(colName)) {
processedFields.add(colName);
fields.push({
name: colName,
label: col.columnLabel || col.column_label || colName,
});
}
});
}
} catch (error) {
console.error("조건 노드 소스 컬럼 로딩 실패:", error);
}
}
}
}
}
console.log("✅ DELETE 노드 소스 필드:", fields);
setSourceFields(fields);
}, [nodeId, nodes, edges]);
// 소스 필드 로딩
useEffect(() => {
loadSourceFields();
}, [loadSourceFields]);
const loadExternalConnections = async () => { const loadExternalConnections = async () => {
try { try {
setExternalConnectionsLoading(true); setExternalConnectionsLoading(true);
@ -239,22 +347,41 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
field: "", field: "",
operator: "EQUALS", operator: "EQUALS",
value: "", value: "",
sourceField: undefined,
staticValue: undefined,
}, },
]; ];
setWhereConditions(newConditions); setWhereConditions(newConditions);
setFieldOpenState(new Array(newConditions.length).fill(false)); setFieldOpenState(new Array(newConditions.length).fill(false));
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
// 자동 저장
updateNode(nodeId, {
whereConditions: newConditions,
});
}; };
const handleRemoveCondition = (index: number) => { const handleRemoveCondition = (index: number) => {
const newConditions = whereConditions.filter((_, i) => i !== index); const newConditions = whereConditions.filter((_, i) => i !== index);
setWhereConditions(newConditions); setWhereConditions(newConditions);
setFieldOpenState(new Array(newConditions.length).fill(false)); setFieldOpenState(new Array(newConditions.length).fill(false));
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
// 자동 저장
updateNode(nodeId, {
whereConditions: newConditions,
});
}; };
const handleConditionChange = (index: number, field: string, value: any) => { const handleConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...whereConditions]; const newConditions = [...whereConditions];
newConditions[index] = { ...newConditions[index], [field]: value }; newConditions[index] = { ...newConditions[index], [field]: value };
setWhereConditions(newConditions); setWhereConditions(newConditions);
// 자동 저장
updateNode(nodeId, {
whereConditions: newConditions,
});
}; };
const handleSave = () => { const handleSave = () => {
@ -840,14 +967,125 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
</Select> </Select>
</div> </div>
{/* 🆕 소스 필드 - Combobox */}
<div> <div>
<Label className="text-xs text-gray-600"></Label> <Label className="text-xs text-gray-600"> ()</Label>
{sourceFields.length > 0 ? (
<Popover
open={sourceFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...sourceFieldsOpenState];
newState[index] = open;
setSourceFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{condition.sourceField
? (() => {
const field = sourceFields.find((f) => f.name === condition.sourceField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-medium">
{field?.label || condition.sourceField}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
)}
</div>
);
})()
: "소스 필드 선택 (선택)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="_NONE_"
onSelect={() => {
handleConditionChange(index, "sourceField", undefined);
const newState = [...sourceFieldsOpenState];
newState[index] = false;
setSourceFieldsOpenState(newState);
}}
className="text-xs text-gray-400 sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!condition.sourceField ? "opacity-100" : "opacity-0",
)}
/>
( )
</CommandItem>
{sourceFields.map((field) => (
<CommandItem
key={field.name}
value={field.name}
onSelect={(currentValue) => {
handleConditionChange(index, "sourceField", currentValue);
const newState = [...sourceFieldsOpenState];
newState[index] = false;
setSourceFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
condition.sourceField === field.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="mt-1 rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center text-xs text-gray-500">
</div>
)}
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input <Input
value={condition.value as string} value={condition.staticValue || condition.value || ""}
onChange={(e) => handleConditionChange(index, "value", e.target.value)} onChange={(e) => {
placeholder="비교 값" handleConditionChange(index, "staticValue", e.target.value || undefined);
handleConditionChange(index, "value", e.target.value);
}}
placeholder="비교할 고정 값"
className="mt-1 h-8 text-xs" className="mt-1 h-8 text-xs"
/> />
<p className="mt-1 text-xs text-gray-400"> </p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -124,17 +124,17 @@ export function PopProductionPanel({
return ( return (
<div className="pop-step-form-section"> <div className="pop-step-form-section">
<h4 className="pop-step-form-title"> </h4> <h4 className="pop-step-form-title"> </h4>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-sm)" }}> <div className="pop-checkbox-list">
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}> <label className="pop-checkbox-label">
<input type="checkbox" disabled={isCompleted} /> <input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
<span> </span> <span> </span>
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}> <label className="pop-checkbox-label">
<input type="checkbox" disabled={isCompleted} /> <input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
<span> </span> <span> </span>
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}> <label className="pop-checkbox-label">
<input type="checkbox" disabled={isCompleted} /> <input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
<span> </span> <span> </span>
</label> </label>
</div> </div>
@ -177,14 +177,12 @@ export function PopProductionPanel({
<span className="pop-badge pop-badge-primary">{workOrder.processName}</span> <span className="pop-badge pop-badge-primary">{workOrder.processName}</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}> <div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
<div style={{ fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}> <div className="pop-panel-datetime">
<span>{formatDate(currentDateTime)}</span> <span className="pop-panel-date">{formatDate(currentDateTime)}</span>
<span style={{ marginLeft: "var(--spacing-sm)", color: "rgb(var(--neon-cyan))", fontWeight: 700 }}> <span className="pop-panel-time">{formatTime(currentDateTime)}</span>
{formatTime(currentDateTime)}
</span>
</div> </div>
<button className="pop-icon-btn" onClick={onClose}> <button className="pop-icon-btn" onClick={onClose}>
<X size={16} /> <X size={20} />
</button> </button>
</div> </div>
</div> </div>

View File

@ -142,9 +142,10 @@
line-height: 1.5; line-height: 1.5;
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
background: rgb(var(--bg-deepest)); background: rgb(var(--bg-deepest));
min-height: 100vh; height: 100vh;
min-height: 100dvh; height: 100dvh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
position: relative; position: relative;
} }
@ -197,8 +198,7 @@
.pop-app { .pop-app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100%;
min-height: 100dvh;
padding: var(--spacing-sm); padding: var(--spacing-sm);
padding-bottom: calc(60px + var(--spacing-sm) + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(60px + var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
} }
@ -209,7 +209,9 @@
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
position: relative; position: sticky;
top: 0;
z-index: 100;
overflow: hidden; overflow: hidden;
} }
@ -227,8 +229,8 @@
.pop-top-bar { .pop-top-bar {
display: flex; display: flex;
align-items: center; align-items: center;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-xs) var(--spacing-sm);
gap: var(--spacing-md); gap: var(--spacing-sm);
} }
.pop-top-bar.row-1 { .pop-top-bar.row-1 {
@ -243,8 +245,8 @@
.pop-datetime { .pop-datetime {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-xs);
font-size: var(--text-xs); font-size: var(--text-2xs);
} }
.pop-date { .pop-date {
@ -254,7 +256,7 @@
.pop-time { .pop-time {
color: rgb(var(--neon-cyan)); color: rgb(var(--neon-cyan));
font-weight: 700; font-weight: 700;
font-size: var(--text-sm); font-size: var(--text-xs);
} }
/* 생산유형 버튼 */ /* 생산유형 버튼 */
@ -264,12 +266,12 @@
} }
.pop-type-btn { .pop-type-btn {
padding: var(--spacing-xs) var(--spacing-md); padding: 4px var(--spacing-sm);
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: rgb(var(--text-muted)); color: rgb(var(--text-muted));
font-size: var(--text-xs); font-size: var(--text-2xs);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast); transition: all var(--transition-fast);
@ -291,12 +293,13 @@
.pop-filter-btn { .pop-filter-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: 2px;
padding: var(--spacing-sm) var(--spacing-md); padding: 4px var(--spacing-sm);
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: rgb(var(--text-secondary)); color: rgb(var(--text-secondary));
font-size: var(--text-2xs);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@ -343,6 +346,9 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-sm);
position: sticky;
top: 90px;
z-index: 99;
} }
.pop-status-tab { .pop-status-tab {
@ -395,7 +401,7 @@
/* ==================== 메인 콘텐츠 ==================== */ /* ==================== 메인 콘텐츠 ==================== */
.pop-main-content { .pop-main-content {
flex: 1; flex: 1;
overflow-y: auto; min-height: 0;
} }
.pop-work-list { .pop-work-list {
@ -675,6 +681,7 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 4px 8px; padding: 4px 8px;
font-size: var(--text-2xs);
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@ -851,10 +858,10 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: var(--text-xs); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast); transition: all var(--transition-fast);
@ -1105,8 +1112,9 @@
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0;
width: 100%; width: 100%;
max-width: 600px; max-width: none;
background: rgb(var(--bg-primary)); background: rgb(var(--bg-primary));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1133,11 +1141,29 @@
} }
.pop-slide-panel-title { .pop-slide-panel-title {
font-size: var(--text-lg); font-size: var(--text-xl);
font-weight: 700; font-weight: 700;
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
} }
/* 슬라이드 패널 날짜/시간 */
.pop-panel-datetime {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.pop-panel-date {
font-size: var(--text-sm);
color: rgb(var(--text-muted));
}
.pop-panel-time {
font-size: var(--text-base);
font-weight: 700;
color: rgb(var(--neon-cyan));
}
.pop-badge { .pop-badge {
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
@ -1174,7 +1200,7 @@
.pop-work-steps-header { .pop-work-steps-header {
padding: var(--spacing-md); padding: var(--spacing-md);
font-size: var(--text-xs); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
color: rgb(var(--text-muted)); color: rgb(var(--text-muted));
border-bottom: 1px solid rgb(var(--border)); border-bottom: 1px solid rgb(var(--border));
@ -1239,7 +1265,7 @@
} }
.pop-work-step-name { .pop-work-step-name {
font-size: var(--text-xs); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
white-space: nowrap; white-space: nowrap;
@ -1248,13 +1274,13 @@
} }
.pop-work-step-time { .pop-work-step-time {
font-size: var(--text-2xs); font-size: var(--text-xs);
color: rgb(var(--text-muted)); color: rgb(var(--text-muted));
} }
.pop-work-step-status { .pop-work-step-status {
font-size: var(--text-2xs); font-size: var(--text-xs);
padding: 2px 6px; padding: 3px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@ -1288,14 +1314,14 @@
} }
.pop-step-title { .pop-step-title {
font-size: var(--text-lg); font-size: var(--text-xl);
font-weight: 700; font-weight: 700;
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.pop-step-description { .pop-step-description {
font-size: var(--text-sm); font-size: var(--text-base);
color: rgb(var(--text-muted)); color: rgb(var(--text-muted));
} }
@ -1311,20 +1337,20 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg);
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: rgb(var(--text-secondary)); color: rgb(var(--text-secondary));
font-size: var(--text-sm); font-size: var(--text-base);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast); transition: all var(--transition-fast);
} }
.pop-time-control-btn svg { .pop-time-control-btn svg {
width: 16px; width: 20px;
height: 16px; height: 20px;
} }
.pop-time-control-btn:hover:not(:disabled) { .pop-time-control-btn:hover:not(:disabled) {
@ -1358,7 +1384,7 @@
} }
.pop-step-form-title { .pop-step-form-title {
font-size: var(--text-sm); font-size: var(--text-base);
font-weight: 600; font-weight: 600;
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
margin-bottom: var(--spacing-md); margin-bottom: var(--spacing-md);
@ -1374,20 +1400,53 @@
.pop-form-label { .pop-form-label {
display: block; display: block;
font-size: var(--text-xs); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;
color: rgb(var(--text-secondary)); color: rgb(var(--text-secondary));
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
/* 체크박스 리스트 */
.pop-checkbox-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pop-checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--text-base);
color: rgb(var(--text-primary));
cursor: pointer;
}
.pop-checkbox {
width: 20px;
height: 20px;
accent-color: rgb(var(--neon-cyan));
cursor: pointer;
}
.pop-checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pop-checkbox-label:has(.pop-checkbox:disabled) {
opacity: 0.6;
cursor: not-allowed;
}
.pop-input { .pop-input {
width: 100%; width: 100%;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-md);
background: rgba(var(--bg-tertiary), 0.5); background: rgba(var(--bg-tertiary), 0.5);
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
font-size: var(--text-sm); font-size: var(--text-base);
transition: all var(--transition-fast); transition: all var(--transition-fast);
} }
@ -1441,12 +1500,12 @@
} }
.pop-work-order-info-item .label { .pop-work-order-info-item .label {
font-size: var(--text-2xs); font-size: var(--text-xs);
color: rgb(var(--text-muted)); color: rgb(var(--text-muted));
} }
.pop-work-order-info-item .value { .pop-work-order-info-item .value {
font-size: var(--text-sm); font-size: var(--text-base);
font-weight: 500; font-weight: 500;
color: rgb(var(--text-primary)); color: rgb(var(--text-primary));
} }
@ -1634,9 +1693,9 @@
/* 헤더 인라인 테마 토글 버튼 */ /* 헤더 인라인 테마 토글 버튼 */
.pop-theme-toggle-inline { .pop-theme-toggle-inline {
width: 32px; width: 26px;
height: 32px; height: 26px;
margin-left: var(--spacing-sm); margin-left: var(--spacing-xs);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgb(var(--border)); border: 1px solid rgb(var(--border));
@ -1656,8 +1715,8 @@
} }
.pop-theme-toggle-inline svg { .pop-theme-toggle-inline svg {
width: 18px; width: 14px;
height: 18px; height: 14px;
} }
/* ==================== 아이콘 버튼 ==================== */ /* ==================== 아이콘 버튼 ==================== */
@ -1722,7 +1781,144 @@
} }
.pop-slide-panel-content { .pop-slide-panel-content {
max-width: 100%; max-width: none;
left: 0;
}
}
/* 태블릿 이상 (768px+) - 폰트 크기 증가 */
@media (min-width: 768px) {
/* 상태 탭 sticky 위치 조정 */
.pop-status-tabs {
top: 100px;
}
/* 헤더 */
.pop-top-bar {
font-size: var(--text-sm);
padding: var(--spacing-sm) var(--spacing-md);
gap: var(--spacing-md);
}
.pop-datetime {
font-size: var(--text-xs);
}
.pop-time {
font-size: var(--text-sm);
}
.pop-type-btn {
font-size: var(--text-xs);
padding: var(--spacing-xs) var(--spacing-md);
}
.pop-filter-btn {
font-size: var(--text-xs);
padding: var(--spacing-xs) var(--spacing-md);
}
.pop-theme-toggle-inline {
width: 32px;
height: 32px;
}
.pop-theme-toggle-inline svg {
width: 16px;
height: 16px;
}
.pop-equipment-name,
.pop-process-name {
font-size: var(--text-sm);
}
/* 상태 탭 */
.pop-status-tab-label {
font-size: var(--text-base);
}
.pop-status-tab-count {
font-size: var(--text-xl);
}
.pop-status-tab-detail {
font-size: var(--text-sm);
}
/* 작업 카드 */
.pop-work-card-id {
font-size: var(--text-base);
}
.pop-work-card-item {
font-size: var(--text-lg);
}
.pop-work-card-spec {
font-size: var(--text-base);
}
.pop-work-card-info-item .label {
font-size: var(--text-sm);
}
.pop-work-card-info-item .value {
font-size: var(--text-base);
}
/* 작업 카드 내부 - 태블릿 */
.pop-work-number {
font-size: var(--text-base);
}
.pop-work-status {
font-size: var(--text-xs);
padding: 3px 10px;
}
.pop-work-info-label {
font-size: var(--text-xs);
}
.pop-work-info-value {
font-size: var(--text-sm);
}
.pop-process-bar-label,
.pop-process-bar-count {
font-size: var(--text-xs);
}
.pop-process-chip {
font-size: var(--text-xs);
padding: 5px 10px;
}
.pop-progress-text,
.pop-progress-percent {
font-size: var(--text-sm);
}
.pop-btn-sm {
font-size: var(--text-xs);
padding: var(--spacing-xs) var(--spacing-md);
}
/* 공정 타임라인 */
.pop-process-step-label {
font-size: var(--text-sm);
}
/* 하단 네비게이션 */
.pop-nav-btn {
font-size: var(--text-base);
}
/* 배지 */
.pop-badge {
font-size: var(--text-sm);
padding: 4px 10px;
} }
} }
@ -1734,6 +1930,11 @@
padding-bottom: calc(64px + var(--spacing-lg) + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(64px + var(--spacing-lg) + env(safe-area-inset-bottom, 0px));
} }
/* 상태 탭 sticky 위치 조정 - 데스크톱 */
.pop-status-tabs {
top: 110px;
}
.pop-work-list { .pop-work-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
@ -1741,11 +1942,217 @@
} }
.pop-slide-panel-content { .pop-slide-panel-content {
max-width: 700px; max-width: none;
left: 0;
} }
.pop-work-steps-sidebar { .pop-work-steps-sidebar {
width: 240px; width: 280px;
}
/* 데스크톱 (1024px+) - 폰트 크기 더 증가 */
/* 헤더 */
.pop-top-bar {
font-size: var(--text-base);
padding: var(--spacing-sm) var(--spacing-lg);
}
.pop-datetime {
font-size: var(--text-sm);
}
.pop-time {
font-size: var(--text-base);
}
.pop-type-btn {
font-size: var(--text-sm);
padding: var(--spacing-sm) var(--spacing-md);
}
.pop-filter-btn {
font-size: var(--text-sm);
padding: var(--spacing-sm) var(--spacing-md);
}
.pop-theme-toggle-inline {
width: 36px;
height: 36px;
}
.pop-theme-toggle-inline svg {
width: 18px;
height: 18px;
}
.pop-equipment-name,
.pop-process-name {
font-size: var(--text-base);
}
/* 상태 탭 */
.pop-status-tab-label {
font-size: var(--text-lg);
}
.pop-status-tab-count {
font-size: var(--text-2xl);
}
.pop-status-tab-detail {
font-size: var(--text-base);
}
/* 작업 카드 */
.pop-work-card-id {
font-size: var(--text-lg);
}
.pop-work-card-item {
font-size: var(--text-xl);
}
.pop-work-card-spec {
font-size: var(--text-lg);
}
.pop-work-card-info-item .label {
font-size: var(--text-base);
}
.pop-work-card-info-item .value {
font-size: var(--text-lg);
}
/* 작업 카드 내부 - 데스크톱 */
.pop-work-number {
font-size: var(--text-lg);
}
.pop-work-status {
font-size: var(--text-sm);
padding: 4px 12px;
}
.pop-work-info-label {
font-size: var(--text-sm);
}
.pop-work-info-value {
font-size: var(--text-base);
}
.pop-process-bar-label,
.pop-process-bar-count {
font-size: var(--text-sm);
}
.pop-process-chip {
font-size: var(--text-sm);
padding: 6px 12px;
}
.pop-progress-text,
.pop-progress-percent {
font-size: var(--text-base);
}
.pop-btn-sm {
font-size: var(--text-sm);
padding: var(--spacing-sm) var(--spacing-md);
}
/* 공정 타임라인 */
.pop-process-step-label {
font-size: var(--text-base);
}
/* 하단 네비게이션 */
.pop-nav-btn {
font-size: var(--text-lg);
}
/* 배지 */
.pop-badge {
font-size: var(--text-base);
padding: 5px 12px;
}
/* 슬라이드 패널 - 데스크톱 */
.pop-slide-panel-title {
font-size: var(--text-2xl);
}
.pop-panel-date {
font-size: var(--text-base);
}
.pop-panel-time {
font-size: var(--text-lg);
}
.pop-work-steps-header {
font-size: var(--text-base);
}
.pop-work-step-name {
font-size: var(--text-base);
}
.pop-work-step-time {
font-size: var(--text-sm);
}
.pop-work-step-status {
font-size: var(--text-sm);
}
.pop-step-title {
font-size: var(--text-2xl);
}
.pop-step-description {
font-size: var(--text-lg);
}
.pop-step-form-title {
font-size: var(--text-lg);
}
.pop-form-label {
font-size: var(--text-base);
}
.pop-checkbox-label {
font-size: var(--text-lg);
}
.pop-checkbox {
width: 24px;
height: 24px;
}
.pop-input {
font-size: var(--text-lg);
padding: var(--spacing-md) var(--spacing-lg);
}
.pop-work-order-info-item .label {
font-size: var(--text-sm);
}
.pop-work-order-info-item .value {
font-size: var(--text-lg);
}
.pop-btn {
font-size: var(--text-base);
padding: var(--spacing-md) var(--spacing-xl);
}
.pop-time-control-btn {
font-size: var(--text-lg);
} }
} }

View File

@ -761,10 +761,74 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// INSERT 모드 // INSERT 모드
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
// 🆕 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
const dataToSave = { ...formData };
const fieldsWithNumbering: Record<string, string> = {};
// formData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(formData)) {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumbering[fieldName] = value as string;
console.log(`🎯 [EditModal] 채번 규칙 발견: ${fieldName} → 규칙 ${value}`);
}
}
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 [EditModal] 채번 규칙 할당 시작");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
let hasAllocationFailure = false;
const failedFields: string[] = [];
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]}${newCode}`);
dataToSave[fieldName] = newCode;
} else {
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
}
} catch (allocateError) {
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
}
}
// 채번 규칙 할당 실패 시 저장 중단
if (hasAllocationFailure) {
const fieldNames = failedFields.join(", ");
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
return;
}
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
for (const key of Object.keys(dataToSave)) {
if (key.endsWith("_numberingRuleId")) {
delete dataToSave[key];
}
}
}
console.log("[EditModal] 최종 저장 데이터:", dataToSave);
const response = await dynamicFormApi.saveFormData({ const response = await dynamicFormApi.saveFormData({
screenId: modalState.screenId!, screenId: modalState.screenId!,
tableName: screenData.screenInfo.tableName, tableName: screenData.screenInfo.tableName,
data: formData, data: dataToSave,
}); });
if (response.success) { if (response.success) {

View File

@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
case "entity": { case "entity": {
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
const widget = comp as WidgetComponent; const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined; return applyStyles(
<DynamicWebTypeRenderer
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", { webType="entity"
componentId: widget.id, config={widget.webTypeConfig}
widgetType: widget.widgetType, props={{
config, component: widget,
appliedSettings: { value: currentValue,
entityName: config?.entityName, onChange: (value: any) => updateFormData(fieldName, value),
displayField: config?.displayField, onFormDataChange: updateFormData,
valueField: config?.valueField, formData: formData,
multiple: config?.multiple, readonly: readonly,
defaultValue: config?.defaultValue, required: required,
}, placeholder: widget.placeholder || "엔티티를 선택하세요",
}); isInteractive: true,
className: "w-full h-full",
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요..."; }}
const defaultOptions = [ />,
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
return (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{defaultOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
); );
} }

View File

@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Search, Database, Link, X, Plus } from "lucide-react"; import { Search, Database, Link, X, Plus } from "lucide-react";
import { EntityTypeConfig } from "@/types/screen"; import { EntityTypeConfig } from "@/types/screen";
@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
placeholder: "", placeholder: "",
displayFormat: "simple", displayFormat: "simple",
separator: " - ", separator: " - ",
multiple: false, // 다중 선택
uiMode: "select", // UI 모드: select, combo, modal, autocomplete
...config, ...config,
}; };
@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
displayFormat: safeConfig.displayFormat, displayFormat: safeConfig.displayFormat,
separator: safeConfig.separator, separator: safeConfig.separator,
multiple: safeConfig.multiple,
uiMode: safeConfig.uiMode,
}); });
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" }); const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
displayFormat: safeConfig.displayFormat, displayFormat: safeConfig.displayFormat,
separator: safeConfig.separator, separator: safeConfig.separator,
multiple: safeConfig.multiple,
uiMode: safeConfig.uiMode,
}); });
}, [ }, [
safeConfig.referenceTable, safeConfig.referenceTable,
@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
safeConfig.placeholder, safeConfig.placeholder,
safeConfig.displayFormat, safeConfig.displayFormat,
safeConfig.separator, safeConfig.separator,
safeConfig.multiple,
safeConfig.uiMode,
]); ]);
// UI 모드 옵션
const uiModes = [
{ value: "select", label: "드롭다운 선택" },
{ value: "combo", label: "입력 + 모달 버튼" },
{ value: "modal", label: "모달 팝업" },
{ value: "autocomplete", label: "자동완성" },
];
const updateConfig = (key: keyof EntityTypeConfig, value: any) => { const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트 // 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value })); setLocalValues((prev) => ({ ...prev, [key]: value }));
@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
/> />
</div> </div>
{/* UI 모드 */}
<div>
<Label htmlFor="uiMode" className="text-sm font-medium">
UI
</Label>
<Select value={localValues.uiMode || "select"} onValueChange={(value) => updateConfig("uiMode", value)}>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
{uiModes.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
{localValues.uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
{localValues.uiMode === "combo" && "입력 필드와 검색 버튼이 함께 표시됩니다."}
{localValues.uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
{localValues.uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
</p>
</div>
{/* 다중 선택 */}
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="multiple"
checked={localValues.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
</div>
{/* 필터 관리 */} {/* 필터 관리 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>

View File

@ -34,7 +34,11 @@ const getApiBaseUrl = (): string => {
export const API_BASE_URL = getApiBaseUrl(); export const API_BASE_URL = getApiBaseUrl();
// 이미지 URL을 완전한 URL로 변환하는 함수 // 이미지 URL을 완전한 URL로 변환하는 함수
// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지
export const getFullImageUrl = (imagePath: string): string => { export const getFullImageUrl = (imagePath: string): string => {
// 빈 값 체크
if (!imagePath) return "";
// 이미 전체 URL인 경우 그대로 반환 // 이미 전체 URL인 경우 그대로 반환
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return imagePath; return imagePath;
@ -42,8 +46,29 @@ export const getFullImageUrl = (imagePath: string): string => {
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
if (imagePath.startsWith("/uploads")) { if (imagePath.startsWith("/uploads")) {
const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거 // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때)
return `${baseUrl}${imagePath}`; if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") {
return `https://api.vexplor.com${imagePath}`;
}
// 로컬 개발환경
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return `http://localhost:8080${imagePath}`;
}
}
// SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback)
const baseUrl = API_BASE_URL.replace("/api", "");
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
return `${baseUrl}${imagePath}`;
}
// 최종 fallback
return imagePath;
} }
return imagePath; return imagePath;

View File

@ -77,6 +77,12 @@ export const entityJoinApi = {
filterColumn?: string; filterColumn?: string;
filterValue?: any; filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
} = {}, } = {},
): Promise<EntityJoinResponse> => { ): Promise<EntityJoinResponse> => {
// 🔒 멀티테넌시: company_code 자동 필터링 활성화 // 🔒 멀티테넌시: company_code 자동 필터링 활성화
@ -99,6 +105,7 @@ export const entityJoinApi = {
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
}, },
}); });
return response.data.data; return response.data.data;

View File

@ -247,10 +247,40 @@ export const getFileDownloadUrl = (fileId: string): string => {
/** /**
* URL ( ) * URL ( )
* 주의: 모듈 hostname을 SSR
*/ */
export const getDirectFileUrl = (filePath: string): string => { export const getDirectFileUrl = (filePath: string): string => {
// 빈 값 체크
if (!filePath) return "";
// 이미 전체 URL인 경우 그대로 반환
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
return filePath;
}
// 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때)
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") {
return `https://api.vexplor.com${filePath}`;
}
// 로컬 개발환경
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return `http://localhost:8080${filePath}`;
}
}
// SSR 또는 알 수 없는 환경에서는 환경변수 사용 (fallback)
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || ""; const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || "";
return `${baseUrl}${filePath}`; if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
return `${baseUrl}${filePath}`;
}
// 최종 fallback
return filePath;
}; };
/** /**

View File

@ -328,6 +328,40 @@ class TableManagementApi {
}; };
} }
} }
/**
*
* column_labels에서 /
* .
*/
async getTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<ApiResponse<{
leftTable: string;
rightTable: string;
relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>;
}>> {
try {
const response = await apiClient.get(
`${this.basePath}/tables/entity-relations?leftTable=${encodeURIComponent(leftTable)}&rightTable=${encodeURIComponent(rightTable)}`
);
return response.data;
} catch (error: any) {
console.error(`❌ 테이블 엔티티 관계 조회 실패: ${leftTable} <-> ${rightTable}`, error);
return {
success: false,
message: error.response?.data?.message || error.message || "테이블 엔티티 관계를 조회할 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
}
} }
// 싱글톤 인스턴스 생성 // 싱글톤 인스턴스 생성

View File

@ -35,7 +35,9 @@ export function EntitySearchInputComponent({
parentValue: parentValueProp, parentValue: parentValueProp,
parentFieldId, parentFieldId,
formData, formData,
// 🆕 추가 props // 다중선택 props
multiple: multipleProp,
// 추가 props
component, component,
isInteractive, isInteractive,
onFormDataChange, onFormDataChange,
@ -49,8 +51,11 @@ export function EntitySearchInputComponent({
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서) // 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서)
const config = component?.componentConfig || {}; const config = component?.componentConfig || component?.webTypeConfig || {};
const isMultiple = multipleProp ?? config.multiple ?? false;
// 연쇄관계 설정 추출
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
// cascadingParentField: ConfigPanel에서 저장되는 필드명 // cascadingParentField: ConfigPanel에서 저장되는 필드명
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId; const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
@ -68,11 +73,27 @@ export function EntitySearchInputComponent({
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false);
// 다중선택 상태 (콤마로 구분된 값들)
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [selectedDataList, setSelectedDataList] = useState<EntitySearchResult[]>([]);
// 연쇄관계 상태 // 연쇄관계 상태
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]); const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
const [isCascadingLoading, setIsCascadingLoading] = useState(false); const [isCascadingLoading, setIsCascadingLoading] = useState(false);
const previousParentValue = useRef<any>(null); const previousParentValue = useRef<any>(null);
// 다중선택 초기값 설정
useEffect(() => {
if (isMultiple && value) {
const vals =
typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value];
setSelectedValues(vals.map(String));
} else if (isMultiple && !value) {
setSelectedValues([]);
setSelectedDataList([]);
}
}, [isMultiple, value]);
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요 // 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
const parentValue = isChildRole const parentValue = isChildRole
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined)) ? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
@ -249,23 +270,75 @@ export function EntitySearchInputComponent({
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]); }, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => { const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData); if (isMultiple) {
setDisplayValue(fullData[displayField] || ""); // 다중선택 모드
onChange?.(newValue, fullData); const valueStr = String(newValue);
const isAlreadySelected = selectedValues.includes(valueStr);
let newSelectedValues: string[];
let newSelectedDataList: EntitySearchResult[];
if (isAlreadySelected) {
// 이미 선택된 항목이면 제거
newSelectedValues = selectedValues.filter((v) => v !== valueStr);
newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr);
} else {
// 선택되지 않은 항목이면 추가
newSelectedValues = [...selectedValues, valueStr];
newSelectedDataList = [...selectedDataList, fullData];
}
setSelectedValues(newSelectedValues);
setSelectedDataList(newSelectedDataList);
const joinedValue = newSelectedValues.join(",");
onChange?.(joinedValue, newSelectedDataList);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, joinedValue);
console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue);
}
} else {
// 단일선택 모드
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
}
}
};
// 다중선택 모드에서 개별 항목 제거
const handleRemoveValue = (valueToRemove: string) => {
const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove);
const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove);
setSelectedValues(newSelectedValues);
setSelectedDataList(newSelectedDataList);
const joinedValue = newSelectedValues.join(",");
onChange?.(joinedValue || null, newSelectedDataList);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
if (isInteractive && onFormDataChange && component?.columnName) { if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue); onFormDataChange(component.columnName, joinedValue || null);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
} }
}; };
const handleClear = () => { const handleClear = () => {
setDisplayValue(""); if (isMultiple) {
setSelectedData(null); setSelectedValues([]);
onChange?.(null, null); setSelectedDataList([]);
onChange?.(null, []);
} else {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
}
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
if (isInteractive && onFormDataChange && component?.columnName) { if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, null); onFormDataChange(component.columnName, null);
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null); console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
@ -280,7 +353,10 @@ export function EntitySearchInputComponent({
const handleSelectOption = (option: EntitySearchResult) => { const handleSelectOption = (option: EntitySearchResult) => {
handleSelect(option[valueField], option); handleSelect(option[valueField], option);
setSelectOpen(false); // 다중선택이 아닌 경우에만 드롭다운 닫기
if (!isMultiple) {
setSelectOpen(false);
}
}; };
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값) // 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
@ -289,6 +365,111 @@ export function EntitySearchInputComponent({
// select 모드: 검색 가능한 드롭다운 // select 모드: 검색 가능한 드롭다운
if (mode === "select") { if (mode === "select") {
// 다중선택 모드
if (isMultiple) {
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 선택된 항목들 표시 (태그 형식) */}
<div
className={cn(
"box-border flex min-h-[40px] w-full flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
!disabled && "hover:border-primary/50",
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
)}
onClick={() => !disabled && !isLoading && setSelectOpen(true)}
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
>
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
const opt = effectiveOptions.find((o) => String(o[valueField]) === val);
const label = opt?.[displayField] || val;
return (
<span
key={val}
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveValue(val);
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
);
})
) : (
<span className="text-muted-foreground text-sm">
{isLoading
? "로딩 중..."
: shouldApplyCascading && !parentValue
? "상위 항목을 먼저 선택하세요"
: placeholder}
</span>
)}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</div>
{/* 옵션 드롭다운 */}
{selectOpen && !disabled && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-md">
<Command>
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
<CommandList className="max-h-60">
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{effectiveOptions.map((option, index) => {
const isSelected = selectedValues.includes(String(option[valueField]));
return (
<CommandItem
key={option[valueField] || index}
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
onSelect={() => handleSelectOption(option)}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{option[displayField]}</span>
{valueField !== displayField && (
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
{/* 닫기 버튼 */}
<div className="border-t p-2">
<Button variant="outline" size="sm" onClick={() => setSelectOpen(false)} className="w-full text-xs">
</Button>
</div>
</div>
)}
{/* 외부 클릭 시 닫기 */}
{selectOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectOpen(false)} />}
</div>
);
}
// 단일선택 모드 (기존 로직)
return ( return (
<div className={cn("relative flex flex-col", className)} style={style}> <div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}
@ -366,6 +547,95 @@ export function EntitySearchInputComponent({
} }
// modal, combo, autocomplete 모드 // modal, combo, autocomplete 모드
// 다중선택 모드
if (isMultiple) {
return (
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */}
<div className="flex h-full gap-2">
<div
className={cn(
"box-border flex min-h-[40px] flex-1 flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
!disabled && "hover:border-primary/50",
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
)}
>
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
// selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기
const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val);
const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val);
const label = opt?.[displayField] || val;
return (
<span
key={val}
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveValue(val);
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
);
})
) : (
<span className="text-muted-foreground text-sm">{placeholder}</span>
)}
</div>
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
>
<Search className="h-4 w-4" />
</Button>
)}
</div>
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
{(mode === "modal" || mode === "combo") && (
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
multiple={isMultiple}
selectedValues={selectedValues}
/>
)}
</div>
);
}
// 단일선택 모드 (기존 로직)
return ( return (
<div className={cn("relative flex flex-col", className)} style={style}> <div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */} {/* 라벨 렌더링 */}

View File

@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({
</p> </p>
</div> </div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.multiple || false}
onCheckedChange={(checked) =>
updateConfig({ multiple: checked })
}
/>
</div>
<p className="text-[10px] text-muted-foreground">
{localConfig.multiple
? "여러 항목을 선택할 수 있습니다. 값은 콤마로 구분됩니다."
: "하나의 항목만 선택할 수 있습니다."}
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
<Input <Input

View File

@ -0,0 +1,83 @@
"use client";
import React from "react";
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
import { WebTypeComponentProps } from "@/lib/registry/types";
/**
* EntitySearchInput
* WebTypeRegistry에서 ,
* props를 EntitySearchInputComponent에 .
*/
export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
component,
value,
onChange,
readonly = false,
...props
}) => {
// component에서 필요한 설정 추출
const widget = component as any;
const webTypeConfig = widget?.webTypeConfig || {};
const componentConfig = widget?.componentConfig || {};
// 설정 우선순위: webTypeConfig > componentConfig > component 직접 속성
const config = { ...componentConfig, ...webTypeConfig };
// 테이블 타입 관리에서 설정된 참조 테이블 정보 사용
const tableName = config.referenceTable || widget?.referenceTable || "";
const displayField = config.labelField || config.displayColumn || config.displayField || "name";
const valueField = config.valueField || config.referenceColumn || "id";
// UI 모드: uiMode > mode 순서
const uiMode = config.uiMode || config.mode || "select";
// 다중선택 설정
const multiple = config.multiple ?? false;
// placeholder
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
tableName,
displayField,
valueField,
uiMode,
multiple,
value,
config,
});
// 테이블 정보가 없으면 안내 메시지 표시
if (!tableName) {
return (
<div className="text-muted-foreground flex h-full w-full items-center rounded-md border border-dashed px-3 py-2 text-sm">
</div>
);
}
return (
<EntitySearchInputComponent
tableName={tableName}
displayField={displayField}
valueField={valueField}
uiMode={uiMode}
placeholder={placeholder}
disabled={readonly}
value={value}
onChange={onChange}
multiple={multiple}
component={component}
isInteractive={props.isInteractive}
onFormDataChange={props.onFormDataChange}
formData={props.formData}
className="h-full w-full"
style={widget?.style}
{...props}
/>
);
};
EntitySearchInputWrapper.displayName = "EntitySearchInputWrapper";

View File

@ -11,7 +11,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, Loader2 } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox";
import { Search, Loader2, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEntitySearch } from "./useEntitySearch"; import { useEntitySearch } from "./useEntitySearch";
import { EntitySearchResult } from "./types"; import { EntitySearchResult } from "./types";
@ -26,6 +28,9 @@ interface EntitySearchModalProps {
modalTitle?: string; modalTitle?: string;
modalColumns?: string[]; modalColumns?: string[];
onSelect: (value: any, fullData: EntitySearchResult) => void; onSelect: (value: any, fullData: EntitySearchResult) => void;
// 다중선택 관련
multiple?: boolean;
selectedValues?: string[]; // 이미 선택된 값들
} }
export function EntitySearchModal({ export function EntitySearchModal({
@ -39,6 +44,8 @@ export function EntitySearchModal({
modalTitle = "검색", modalTitle = "검색",
modalColumns = [], modalColumns = [],
onSelect, onSelect,
multiple = false,
selectedValues = [],
}: EntitySearchModalProps) { }: EntitySearchModalProps) {
const [localSearchText, setLocalSearchText] = useState(""); const [localSearchText, setLocalSearchText] = useState("");
const { const {
@ -71,7 +78,15 @@ export function EntitySearchModal({
const handleSelect = (item: EntitySearchResult) => { const handleSelect = (item: EntitySearchResult) => {
onSelect(item[valueField], item); onSelect(item[valueField], item);
onOpenChange(false); // 다중선택이 아닌 경우에만 모달 닫기
if (!multiple) {
onOpenChange(false);
}
};
// 항목이 선택되어 있는지 확인
const isItemSelected = (item: EntitySearchResult): boolean => {
return selectedValues.includes(String(item[valueField]));
}; };
// 표시할 컬럼 결정 // 표시할 컬럼 결정
@ -123,10 +138,16 @@ export function EntitySearchModal({
{/* 검색 결과 테이블 */} {/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<table className="w-full text-xs sm:text-sm"> <table className="w-full text-xs sm:text-sm">
<thead className="bg-muted"> <thead className="bg-muted sticky top-0">
<tr> <tr>
{/* 다중선택 시 체크박스 컬럼 */}
{multiple && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
</th>
)}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<th <th
key={col} key={col}
@ -135,54 +156,72 @@ export function EntitySearchModal({
{col} {col}
</th> </th>
))} ))}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24"> {!multiple && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
</th>
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && results.length === 0 ? ( {loading && results.length === 0 ? (
<tr> <tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center"> <td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" /> <Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p> <p className="mt-2 text-muted-foreground"> ...</p>
</td> </td>
</tr> </tr>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<tr> <tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center text-muted-foreground">
</td> </td>
</tr> </tr>
) : ( ) : (
results.map((item, index) => { results.map((item, index) => {
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
const isSelected = isItemSelected(item);
return ( return (
<tr <tr
key={uniqueKey} key={uniqueKey}
className="border-t hover:bg-accent cursor-pointer transition-colors" className={cn(
onClick={() => handleSelect(item)} "border-t cursor-pointer transition-colors",
> isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-accent"
)}
onClick={() => handleSelect(item)}
>
{/* 다중선택 시 체크박스 */}
{multiple && (
<td className="px-4 py-2">
<Checkbox
checked={isSelected}
onCheckedChange={() => handleSelect(item)}
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<td key={`${uniqueKey}-${col}`} className="px-4 py-2"> <td key={`${uniqueKey}-${col}`} className="px-4 py-2">
{item[col] || "-"} {item[col] || "-"}
</td> </td>
))} ))}
<td className="px-4 py-2"> {!multiple && (
<Button <td className="px-4 py-2">
size="sm" <Button
variant="outline" size="sm"
onClick={(e) => { variant="outline"
e.stopPropagation(); onClick={(e) => {
handleSelect(item); e.stopPropagation();
}} handleSelect(item);
className="h-7 text-xs" }}
> className="h-7 text-xs"
>
</Button>
</td> </Button>
</tr> </td>
); )}
</tr>
);
}) })
)} )}
</tbody> </tbody>
@ -211,12 +250,18 @@ export function EntitySearchModal({
)} )}
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
{/* 다중선택 시 선택된 항목 수 표시 */}
{multiple && selectedValues.length > 0 && (
<div className="flex-1 text-sm text-muted-foreground">
{selectedValues.length}
</div>
)}
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
{multiple ? "완료" : "취소"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -11,6 +11,9 @@ export interface EntitySearchInputConfig {
showAdditionalInfo?: boolean; showAdditionalInfo?: boolean;
additionalFields?: string[]; additionalFields?: string[];
// 다중 선택 설정
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
// 연쇄관계 설정 (cascading_relation 테이블과 연동) // 연쇄관계 설정 (cascading_relation 테이블과 연동)
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식) cascadingRole?: "parent" | "child"; // 역할 (부모/자식)

View File

@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config";
// 컴포넌트 내보내기 // 컴포넌트 내보내기
export { EntitySearchInputComponent } from "./EntitySearchInputComponent"; export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper";
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer"; export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
export { EntitySearchModal } from "./EntitySearchModal"; export { EntitySearchModal } from "./EntitySearchModal";
export { useEntitySearch } from "./useEntitySearch"; export { useEntitySearch } from "./useEntitySearch";

View File

@ -19,6 +19,9 @@ export interface EntitySearchInputProps {
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
// 다중선택
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
// 필터링 // 필터링
filterCondition?: Record<string, any>; // 추가 WHERE 조건 filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시 companyCode?: string; // 멀티테넌시

View File

@ -0,0 +1,159 @@
# PivotGrid 컴포넌트 전체 구현 계획
## 개요
DevExtreme PivotGrid (https://js.devexpress.com/React/Demos/WidgetsGallery/Demo/PivotGrid/Overview/FluentBlueLight/) 수준의 다차원 데이터 분석 컴포넌트 구현
## 현재 상태: ✅ 모든 기능 구현 완료!
---
## 구현된 기능 목록
### 1. 기본 피벗 테이블 ✅
- [x] 피벗 테이블 렌더링
- [x] 행/열 확장/축소
- [x] 합계/소계 표시
- [x] 전체 확장/축소 버튼
### 2. 필드 패널 (드래그앤드롭) ✅
- [x] 상단에 4개 영역 표시 (필터, 열, 행, 데이터)
- [x] 각 영역에 배치된 필드 칩/태그 표시
- [x] 필드 제거 버튼 (X)
- [x] 필드 간 드래그 앤 드롭 지원 (@dnd-kit 사용)
- [x] 영역 간 필드 이동
- [x] 같은 영역 내 순서 변경
- [x] 드래그 시 시각적 피드백
### 3. 필드 선택기 (모달) ✅
- [x] 모달 열기/닫기
- [x] 사용 가능한 필드 목록
- [x] 필드 검색 기능
- [x] 필드별 영역 선택 드롭다운
- [x] 데이터 타입 아이콘 표시
- [x] 집계 함수 선택 (데이터 영역)
- [x] 표시 모드 선택 (데이터 영역)
### 4. 데이터 요약 (누계, % 모드) ✅
- [x] 절대값 표시 (기본)
- [x] 행 총계 대비 %
- [x] 열 총계 대비 %
- [x] 전체 총계 대비 %
- [x] 행/열 방향 누계
- [x] 이전 대비 차이
- [x] 이전 대비 % 차이
### 5. 필터링 ✅
- [x] 필터 팝업 컴포넌트 (FilterPopup)
- [x] 값 검색 기능
- [x] 체크박스 기반 값 선택
- [x] 포함/제외 모드
- [x] 전체 선택/해제
- [x] 선택된 항목 수 표시
### 6. Drill Down ✅
- [x] 셀 더블클릭 시 상세 데이터 모달
- [x] 원본 데이터 테이블 표시
- [x] 검색 기능
- [x] 정렬 기능
- [x] 페이지네이션
- [x] CSV/Excel 내보내기
### 7. Virtual Scrolling ✅
- [x] useVirtualScroll 훅 (행)
- [x] useVirtualColumnScroll 훅 (열)
- [x] useVirtual2DScroll 훅 (행+열)
- [x] overscan 버퍼 지원
### 8. Excel 내보내기 ✅
- [x] xlsx 라이브러리 사용
- [x] 피벗 데이터 Excel 내보내기
- [x] Drill Down 데이터 Excel 내보내기
- [x] CSV 내보내기 (기본)
- [x] 스타일링 (헤더, 데이터, 총계)
- [x] 숫자 포맷
### 9. 차트 통합 ✅
- [x] recharts 라이브러리 사용
- [x] 막대 차트
- [x] 누적 막대 차트
- [x] 선 차트
- [x] 영역 차트
- [x] 파이 차트
- [x] 범례 표시
- [x] 커스텀 툴팁
- [x] 차트 토글 버튼
### 10. 조건부 서식 (Conditional Formatting) ✅
- [x] Color Scale (색상 그라데이션)
- [x] Data Bar (데이터 막대)
- [x] Icon Set (아이콘)
- [x] Cell Value (조건 기반 스타일)
- [x] ConfigPanel에서 설정 UI
### 11. 상태 저장/복원 ✅
- [x] usePivotState 훅
- [x] localStorage/sessionStorage 지원
- [x] 자동 저장 (디바운스)
### 12. ConfigPanel 고도화 ✅
- [x] 데이터 소스 설정 (테이블 선택)
- [x] 필드별 영역 설정 (행, 열, 데이터, 필터)
- [x] 총계 옵션 설정
- [x] 스타일 설정 (테마, 교차 색상 등)
- [x] 내보내기 설정 (Excel/CSV)
- [x] 차트 설정 UI
- [x] 필드 선택기 설정 UI
- [x] 조건부 서식 설정 UI
- [x] 크기 설정
---
## 파일 구조
```
pivot-grid/
├── components/
│ ├── FieldPanel.tsx # 필드 패널 (드래그앤드롭)
│ ├── FieldChooser.tsx # 필드 선택기 모달
│ ├── DrillDownModal.tsx # Drill Down 모달
│ ├── FilterPopup.tsx # 필터 팝업
│ ├── PivotChart.tsx # 차트 컴포넌트
│ └── index.ts # 내보내기
├── hooks/
│ ├── useVirtualScroll.ts # 가상 스크롤 훅
│ ├── usePivotState.ts # 상태 저장 훅
│ └── index.ts # 내보내기
├── utils/
│ ├── aggregation.ts # 집계 함수
│ ├── pivotEngine.ts # 피벗 엔진
│ ├── exportExcel.ts # Excel 내보내기
│ ├── conditionalFormat.ts # 조건부 서식
│ └── index.ts # 내보내기
├── types.ts # 타입 정의
├── PivotGridComponent.tsx # 메인 컴포넌트
├── PivotGridConfigPanel.tsx # 설정 패널
├── PivotGridRenderer.tsx # 렌더러
├── index.ts # 모듈 내보내기
└── PLAN.md # 이 파일
```
---
## 후순위 기능 (선택적)
다음 기능들은 필요 시 추가 구현 가능:
### 데이터 바인딩 확장
- [ ] OLAP Data Source 연동 (복잡)
- [ ] GraphQL 연동
- [ ] 실시간 데이터 업데이트 (WebSocket)
### 고급 기능
- [ ] 피벗 테이블 병합 (여러 데이터 소스)
- [ ] 계산 필드 (커스텀 수식)
- [ ] 데이터 정렬 옵션 강화
- [ ] 그룹핑 옵션 (날짜 그룹핑 등)
---
## 완료일: 2026-01-08

View File

@ -0,0 +1,988 @@
"use client";
/**
* PivotGrid
*
*/
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
PivotGridProps,
PivotResult,
PivotFieldConfig,
PivotCellData,
PivotFlatRow,
PivotCellValue,
PivotGridState,
PivotAreaType,
} from "./types";
import { processPivotData, pathToKey } from "./utils/pivotEngine";
import { exportPivotToExcel } from "./utils/exportExcel";
import { getConditionalStyle, formatStyleToReact, CellFormatStyle } from "./utils/conditionalFormat";
import { FieldPanel } from "./components/FieldPanel";
import { FieldChooser } from "./components/FieldChooser";
import { DrillDownModal } from "./components/DrillDownModal";
import { PivotChart } from "./components/PivotChart";
import {
ChevronRight,
ChevronDown,
Download,
Settings,
RefreshCw,
Maximize2,
Minimize2,
LayoutGrid,
FileSpreadsheet,
BarChart3,
} from "lucide-react";
import { Button } from "@/components/ui/button";
// ==================== 서브 컴포넌트 ====================
// 행 헤더 셀
interface RowHeaderCellProps {
row: PivotFlatRow;
rowFields: PivotFieldConfig[];
onToggleExpand: (path: string[]) => void;
}
const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
row,
rowFields,
onToggleExpand,
}) => {
const indentSize = row.level * 20;
return (
<td
className={cn(
"border-r border-b border-border bg-muted/50",
"px-2 py-1.5 text-left text-sm",
"whitespace-nowrap font-medium",
row.isExpanded && "bg-muted/70"
)}
style={{ paddingLeft: `${8 + indentSize}px` }}
>
<div className="flex items-center gap-1">
{row.hasChildren && (
<button
onClick={() => onToggleExpand(row.path)}
className="p-0.5 hover:bg-accent rounded"
>
{row.isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
{!row.hasChildren && <span className="w-4" />}
<span>{row.caption}</span>
</div>
</td>
);
};
// 데이터 셀
interface DataCellProps {
values: PivotCellValue[];
isTotal?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
conditionalStyle?: CellFormatStyle;
}
const DataCell: React.FC<DataCellProps> = ({
values,
isTotal = false,
onClick,
onDoubleClick,
conditionalStyle,
}) => {
// 조건부 서식 스타일 계산
const cellStyle = conditionalStyle ? formatStyleToReact(conditionalStyle) : {};
const hasDataBar = conditionalStyle?.dataBarWidth !== undefined;
const icon = conditionalStyle?.icon;
if (!values || values.length === 0) {
return (
<td
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-right text-sm",
isTotal && "bg-primary/5 font-medium"
)}
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
-
</td>
);
}
// 단일 데이터 필드인 경우
if (values.length === 1) {
return (
<td
className={cn(
"border-r border-b border-border relative",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
)}
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{/* Data Bar */}
{hasDataBar && (
<div
className="absolute inset-y-0 left-0 opacity-30"
style={{
width: `${conditionalStyle?.dataBarWidth}%`,
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
}}
/>
)}
<span className="relative z-10 flex items-center justify-end gap-1">
{icon && <span>{icon}</span>}
{values[0].formattedValue}
</span>
</td>
);
}
// 다중 데이터 필드인 경우
return (
<>
{values.map((val, idx) => (
<td
key={idx}
className={cn(
"border-r border-b border-border relative",
"px-2 py-1.5 text-right text-sm tabular-nums",
isTotal && "bg-primary/5 font-medium",
(onClick || onDoubleClick) && "cursor-pointer hover:bg-accent/50"
)}
style={cellStyle}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{hasDataBar && (
<div
className="absolute inset-y-0 left-0 opacity-30"
style={{
width: `${conditionalStyle?.dataBarWidth}%`,
backgroundColor: conditionalStyle?.dataBarColor || "#3b82f6",
}}
/>
)}
<span className="relative z-10 flex items-center justify-end gap-1">
{icon && <span>{icon}</span>}
{val.formattedValue}
</span>
</td>
))}
</>
);
};
// ==================== 메인 컴포넌트 ====================
export const PivotGridComponent: React.FC<PivotGridProps> = ({
title,
fields: initialFields = [],
totals = {
showRowGrandTotals: true,
showColumnGrandTotals: true,
showRowTotals: true,
showColumnTotals: true,
},
style = {
theme: "default",
headerStyle: "default",
cellPadding: "normal",
borderStyle: "light",
alternateRowColors: true,
highlightTotals: true,
},
fieldChooser,
chart: chartConfig,
allowExpandAll = true,
height = "auto",
maxHeight,
exportConfig,
data: externalData,
onCellClick,
onCellDoubleClick,
onFieldDrop,
onExpandChange,
}) => {
// 디버깅 로그
console.log("🔶 PivotGridComponent props:", {
title,
hasExternalData: !!externalData,
externalDataLength: externalData?.length,
initialFieldsLength: initialFields?.length,
});
// ==================== 상태 ====================
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
const [pivotState, setPivotState] = useState<PivotGridState>({
expandedRowPaths: [],
expandedColumnPaths: [],
sortConfig: null,
filterConfig: {},
});
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFieldPanel, setShowFieldPanel] = useState(true);
const [showFieldChooser, setShowFieldChooser] = useState(false);
const [drillDownData, setDrillDownData] = useState<{
open: boolean;
cellData: PivotCellData | null;
}>({ open: false, cellData: null });
const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
// 외부 fields 변경 시 동기화
useEffect(() => {
if (initialFields.length > 0) {
setFields(initialFields);
}
}, [initialFields]);
// 데이터
const data = externalData || [];
// ==================== 필드 분류 ====================
const rowFields = useMemo(
() =>
fields
.filter((f) => f.area === "row" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
[fields]
);
const columnFields = useMemo(
() =>
fields
.filter((f) => f.area === "column" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
[fields]
);
const dataFields = useMemo(
() =>
fields
.filter((f) => f.area === "data" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
[fields]
);
const filterFields = useMemo(
() =>
fields
.filter((f) => f.area === "filter" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
[fields]
);
// 사용 가능한 필드 목록 (FieldChooser용)
const availableFields = useMemo(() => {
if (data.length === 0) return [];
const sampleRow = data[0];
return Object.keys(sampleRow).map((key) => {
const existingField = fields.find((f) => f.field === key);
const value = sampleRow[key];
// 데이터 타입 추론
let dataType: "string" | "number" | "date" | "boolean" = "string";
if (typeof value === "number") dataType = "number";
else if (typeof value === "boolean") dataType = "boolean";
else if (value instanceof Date) dataType = "date";
else if (typeof value === "string") {
// 날짜 문자열 감지
if (/^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
}
return {
field: key,
caption: existingField?.caption || key,
dataType,
isSelected: existingField?.visible !== false,
currentArea: existingField?.area,
};
});
}, [data, fields]);
// ==================== 피벗 처리 ====================
const pivotResult = useMemo<PivotResult | null>(() => {
if (!data || data.length === 0 || fields.length === 0) {
return null;
}
const visibleFields = fields.filter((f) => f.visible !== false);
if (visibleFields.filter((f) => f.area !== "filter").length === 0) {
return null;
}
return processPivotData(
data,
visibleFields,
pivotState.expandedRowPaths,
pivotState.expandedColumnPaths
);
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
// 조건부 서식용 전체 값 수집
const allCellValues = useMemo(() => {
if (!pivotResult) return new Map<string, number[]>();
const valuesByField = new Map<string, number[]>();
// 데이터 매트릭스에서 모든 값 수집
pivotResult.dataMatrix.forEach((values) => {
values.forEach((val) => {
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
const existing = valuesByField.get(val.field) || [];
existing.push(val.value);
valuesByField.set(val.field, existing);
}
});
});
// 행 총계 값 수집
pivotResult.grandTotals.row.forEach((values) => {
values.forEach((val) => {
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
const existing = valuesByField.get(val.field) || [];
existing.push(val.value);
valuesByField.set(val.field, existing);
}
});
});
// 열 총계 값 수집
pivotResult.grandTotals.column.forEach((values) => {
values.forEach((val) => {
if (val.field && typeof val.value === "number" && !isNaN(val.value)) {
const existing = valuesByField.get(val.field) || [];
existing.push(val.value);
valuesByField.set(val.field, existing);
}
});
});
return valuesByField;
}, [pivotResult]);
// 조건부 서식 스타일 계산 헬퍼
const getCellConditionalStyle = useCallback(
(value: number | undefined, field: string): CellFormatStyle => {
if (!style?.conditionalFormats || style.conditionalFormats.length === 0) {
return {};
}
const allValues = allCellValues.get(field) || [];
return getConditionalStyle(value, field, style.conditionalFormats, allValues);
},
[style?.conditionalFormats, allCellValues]
);
// ==================== 이벤트 핸들러 ====================
// 필드 변경
const handleFieldsChange = useCallback(
(newFields: PivotFieldConfig[]) => {
setFields(newFields);
},
[]
);
// 행 확장/축소
const handleToggleRowExpand = useCallback(
(path: string[]) => {
setPivotState((prev) => {
const pathKey = pathToKey(path);
const existingIndex = prev.expandedRowPaths.findIndex(
(p) => pathToKey(p) === pathKey
);
let newPaths: string[][];
if (existingIndex >= 0) {
newPaths = prev.expandedRowPaths.filter(
(_, i) => i !== existingIndex
);
} else {
newPaths = [...prev.expandedRowPaths, path];
}
onExpandChange?.(newPaths);
return {
...prev,
expandedRowPaths: newPaths,
};
});
},
[onExpandChange]
);
// 전체 확장
const handleExpandAll = useCallback(() => {
if (!pivotResult) return;
const allRowPaths: string[][] = [];
pivotResult.flatRows.forEach((row) => {
if (row.hasChildren) {
allRowPaths.push(row.path);
}
});
setPivotState((prev) => ({
...prev,
expandedRowPaths: allRowPaths,
expandedColumnPaths: [],
}));
}, [pivotResult]);
// 전체 축소
const handleCollapseAll = useCallback(() => {
setPivotState((prev) => ({
...prev,
expandedRowPaths: [],
expandedColumnPaths: [],
}));
}, []);
// 셀 클릭
const handleCellClick = useCallback(
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
if (!onCellClick) return;
const cellData: PivotCellData = {
value: values[0]?.value,
rowPath,
columnPath: colPath,
field: values[0]?.field,
};
onCellClick(cellData);
},
[onCellClick]
);
// 셀 더블클릭 (Drill Down)
const handleCellDoubleClick = useCallback(
(rowPath: string[], colPath: string[], values: PivotCellValue[]) => {
const cellData: PivotCellData = {
value: values[0]?.value,
rowPath,
columnPath: colPath,
field: values[0]?.field,
};
// Drill Down 모달 열기
setDrillDownData({ open: true, cellData });
// 외부 콜백 호출
if (onCellDoubleClick) {
onCellDoubleClick(cellData);
}
},
[onCellDoubleClick]
);
// CSV 내보내기
const handleExportCSV = useCallback(() => {
if (!pivotResult) return;
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
let csv = "";
// 헤더 행
const headerRow = [""].concat(
flatColumns.map((col) => col.caption || "총계")
);
if (totals?.showRowGrandTotals) {
headerRow.push("총계");
}
csv += headerRow.join(",") + "\n";
// 데이터 행
flatRows.forEach((row) => {
const rowData = [row.caption];
flatColumns.forEach((col) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey);
rowData.push(values?.[0]?.value?.toString() || "");
});
if (totals?.showRowGrandTotals) {
const rowTotal = grandTotals.row.get(pathToKey(row.path));
rowData.push(rowTotal?.[0]?.value?.toString() || "");
}
csv += rowData.join(",") + "\n";
});
// 열 총계 행
if (totals?.showColumnGrandTotals) {
const totalRow = ["총계"];
flatColumns.forEach((col) => {
const colTotal = grandTotals.column.get(pathToKey(col.path));
totalRow.push(colTotal?.[0]?.value?.toString() || "");
});
if (totals?.showRowGrandTotals) {
totalRow.push(grandTotals.grand[0]?.value?.toString() || "");
}
csv += totalRow.join(",") + "\n";
}
// 다운로드
const blob = new Blob(["\uFEFF" + csv], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${title || "pivot"}_export.csv`;
link.click();
}, [pivotResult, totals, title]);
// Excel 내보내기
const handleExportExcel = useCallback(async () => {
if (!pivotResult) return;
try {
await exportPivotToExcel(pivotResult, fields, totals, {
fileName: title || "pivot_export",
title: title,
});
} catch (error) {
console.error("Excel 내보내기 실패:", error);
}
}, [pivotResult, fields, totals, title]);
// ==================== 렌더링 ====================
// 빈 상태
if (!data || data.length === 0) {
return (
<div
className={cn(
"flex flex-col items-center justify-center",
"p-8 text-center text-muted-foreground",
"border border-dashed border-border rounded-lg"
)}
>
<RefreshCw className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"> </p>
<p className="text-xs mt-1"> </p>
</div>
);
}
// 필드 미설정
const hasActiveFields = fields.some(
(f) => f.visible !== false && f.area !== "filter"
);
if (!hasActiveFields) {
return (
<div
className={cn(
"flex flex-col",
"border border-border rounded-lg overflow-hidden bg-background"
)}
>
{/* 필드 패널 */}
<FieldPanel
fields={fields}
onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
/>
{/* 안내 메시지 */}
<div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
<Settings className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm"> </p>
<p className="text-xs mt-1">
, ,
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowFieldChooser(true)}
>
<LayoutGrid className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 필드 선택기 모달 */}
<FieldChooser
open={showFieldChooser}
onOpenChange={setShowFieldChooser}
availableFields={availableFields}
selectedFields={fields}
onFieldsChange={handleFieldsChange}
/>
</div>
);
}
// 피벗 결과 없음
if (!pivotResult) {
return (
<div className="flex items-center justify-center p-8">
<RefreshCw className="h-5 w-5 animate-spin" />
</div>
);
}
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
return (
<div
className={cn(
"flex flex-col",
"border border-border rounded-lg overflow-hidden",
"bg-background",
isFullscreen && "fixed inset-4 z-50 shadow-2xl"
)}
style={{
height: isFullscreen ? "auto" : height,
maxHeight: isFullscreen ? "none" : maxHeight,
}}
>
{/* 필드 패널 - 항상 렌더링 (collapsed 상태로 접기/펼치기 제어) */}
<FieldPanel
fields={fields}
onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
/>
{/* 헤더 툴바 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
{title && <h3 className="text-sm font-medium">{title}</h3>}
<span className="text-xs text-muted-foreground">
({data.length})
</span>
</div>
<div className="flex items-center gap-1">
{/* 필드 선택기 버튼 */}
{fieldChooser?.enabled !== false && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setShowFieldChooser(true)}
title="필드 선택기"
>
<LayoutGrid className="h-4 w-4" />
</Button>
)}
{/* 필드 패널 토글 */}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setShowFieldPanel(!showFieldPanel)}
title={showFieldPanel ? "필드 패널 숨기기" : "필드 패널 보기"}
>
<Settings className="h-4 w-4" />
</Button>
{allowExpandAll && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExpandAll}
title="전체 확장"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleCollapseAll}
title="전체 축소"
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
{/* 차트 토글 */}
{chartConfig && (
<Button
variant={showChart ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
onClick={() => setShowChart(!showChart)}
title={showChart ? "차트 숨기기" : "차트 보기"}
>
<BarChart3 className="h-4 w-4" />
</Button>
)}
{/* 내보내기 버튼들 */}
{exportConfig?.excel && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExportCSV}
title="CSV 내보내기"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExportExcel}
title="Excel 내보내기"
>
<FileSpreadsheet className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? "원래 크기" : "전체 화면"}
>
{isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 피벗 테이블 */}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead>
{/* 열 헤더 */}
<tr className="bg-muted/50">
{/* 좌상단 코너 (행 필드 라벨) */}
<th
className={cn(
"border-r border-b border-border",
"px-2 py-2 text-left text-xs font-medium",
"bg-muted sticky left-0 top-0 z-20"
)}
rowSpan={columnFields.length > 0 ? 2 : 1}
>
{rowFields.map((f) => f.caption).join(" / ") || "항목"}
</th>
{/* 열 헤더 셀 */}
{flatColumns.map((col, idx) => (
<th
key={idx}
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-muted/70 sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
>
{col.caption || "(전체)"}
</th>
))}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-primary/10 sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
>
</th>
)}
</tr>
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && (
<tr className="bg-muted/30">
{flatColumns.map((col, colIdx) => (
<React.Fragment key={colIdx}>
{dataFields.map((df, dfIdx) => (
<th
key={`${colIdx}-${dfIdx}`}
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-center text-xs font-normal",
"text-muted-foreground"
)}
>
{df.caption}
</th>
))}
</React.Fragment>
))}
{totals?.showRowGrandTotals &&
dataFields.map((df, dfIdx) => (
<th
key={`total-${dfIdx}`}
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-center text-xs font-normal",
"bg-primary/5 text-muted-foreground"
)}
>
{df.caption}
</th>
))}
</tr>
)}
</thead>
<tbody>
{flatRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className={cn(
style?.alternateRowColors &&
rowIdx % 2 === 1 &&
"bg-muted/20"
)}
>
{/* 행 헤더 */}
<RowHeaderCell
row={row}
rowFields={rowFields}
onToggleExpand={handleToggleRowExpand}
/>
{/* 데이터 셀 */}
{flatColumns.map((col, colIdx) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey) || [];
// 조건부 서식 (첫 번째 값 기준)
const conditionalStyle =
values.length > 0 && values[0].field
? getCellConditionalStyle(values[0].value, values[0].field)
: undefined;
return (
<DataCell
key={colIdx}
values={values}
conditionalStyle={conditionalStyle}
onClick={
onCellClick
? () => handleCellClick(row.path, col.path, values)
: undefined
}
onDoubleClick={() =>
handleCellDoubleClick(row.path, col.path, values)
}
/>
);
})}
{/* 행 총계 */}
{totals?.showRowGrandTotals && (
<DataCell
values={grandTotals.row.get(pathToKey(row.path)) || []}
isTotal
/>
)}
</tr>
))}
{/* 열 총계 행 */}
{totals?.showColumnGrandTotals && (
<tr className="bg-primary/5 font-medium">
<td
className={cn(
"border-r border-b border-border",
"px-2 py-1.5 text-left text-sm",
"bg-primary/10 sticky left-0"
)}
>
</td>
{flatColumns.map((col, colIdx) => (
<DataCell
key={colIdx}
values={grandTotals.column.get(pathToKey(col.path)) || []}
isTotal
/>
))}
{/* 대총합 */}
{totals?.showRowGrandTotals && (
<DataCell values={grandTotals.grand} isTotal />
)}
</tr>
)}
</tbody>
</table>
</div>
{/* 차트 */}
{showChart && chartConfig && pivotResult && (
<PivotChart
pivotResult={pivotResult}
config={{
...chartConfig,
enabled: true,
}}
dataFields={dataFields}
/>
)}
{/* 필드 선택기 모달 */}
<FieldChooser
open={showFieldChooser}
onOpenChange={setShowFieldChooser}
availableFields={availableFields}
selectedFields={fields}
onFieldsChange={handleFieldsChange}
/>
{/* Drill Down 모달 */}
<DrillDownModal
open={drillDownData.open}
onOpenChange={(open) => setDrillDownData((prev) => ({ ...prev, open }))}
cellData={drillDownData.cellData}
data={data}
fields={fields}
rowFields={rowFields}
columnFields={columnFields}
/>
</div>
);
};
export default PivotGridComponent;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,306 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { PivotGridComponent } from "./PivotGridComponent";
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
import { PivotFieldConfig } from "./types";
// ==================== 샘플 데이터 (미리보기용) ====================
const SAMPLE_DATA = [
{ region: "서울", product: "노트북", quarter: "Q1", sales: 1500000, quantity: 15 },
{ region: "서울", product: "노트북", quarter: "Q2", sales: 1800000, quantity: 18 },
{ region: "서울", product: "노트북", quarter: "Q3", sales: 2100000, quantity: 21 },
{ region: "서울", product: "노트북", quarter: "Q4", sales: 2500000, quantity: 25 },
{ region: "서울", product: "스마트폰", quarter: "Q1", sales: 2000000, quantity: 40 },
{ region: "서울", product: "스마트폰", quarter: "Q2", sales: 2200000, quantity: 44 },
{ region: "서울", product: "스마트폰", quarter: "Q3", sales: 2500000, quantity: 50 },
{ region: "서울", product: "스마트폰", quarter: "Q4", sales: 3000000, quantity: 60 },
{ region: "서울", product: "태블릿", quarter: "Q1", sales: 800000, quantity: 10 },
{ region: "서울", product: "태블릿", quarter: "Q2", sales: 900000, quantity: 11 },
{ region: "서울", product: "태블릿", quarter: "Q3", sales: 1000000, quantity: 12 },
{ region: "서울", product: "태블릿", quarter: "Q4", sales: 1200000, quantity: 15 },
{ region: "부산", product: "노트북", quarter: "Q1", sales: 1000000, quantity: 10 },
{ region: "부산", product: "노트북", quarter: "Q2", sales: 1200000, quantity: 12 },
{ region: "부산", product: "노트북", quarter: "Q3", sales: 1400000, quantity: 14 },
{ region: "부산", product: "노트북", quarter: "Q4", sales: 1600000, quantity: 16 },
{ region: "부산", product: "스마트폰", quarter: "Q1", sales: 1500000, quantity: 30 },
{ region: "부산", product: "스마트폰", quarter: "Q2", sales: 1700000, quantity: 34 },
{ region: "부산", product: "스마트폰", quarter: "Q3", sales: 1900000, quantity: 38 },
{ region: "부산", product: "스마트폰", quarter: "Q4", sales: 2200000, quantity: 44 },
{ region: "부산", product: "태블릿", quarter: "Q1", sales: 500000, quantity: 6 },
{ region: "부산", product: "태블릿", quarter: "Q2", sales: 600000, quantity: 7 },
{ region: "부산", product: "태블릿", quarter: "Q3", sales: 700000, quantity: 8 },
{ region: "부산", product: "태블릿", quarter: "Q4", sales: 800000, quantity: 10 },
{ region: "대구", product: "노트북", quarter: "Q1", sales: 700000, quantity: 7 },
{ region: "대구", product: "노트북", quarter: "Q2", sales: 850000, quantity: 8 },
{ region: "대구", product: "노트북", quarter: "Q3", sales: 900000, quantity: 9 },
{ region: "대구", product: "노트북", quarter: "Q4", sales: 1100000, quantity: 11 },
{ region: "대구", product: "스마트폰", quarter: "Q1", sales: 1000000, quantity: 20 },
{ region: "대구", product: "스마트폰", quarter: "Q2", sales: 1200000, quantity: 24 },
{ region: "대구", product: "스마트폰", quarter: "Q3", sales: 1300000, quantity: 26 },
{ region: "대구", product: "스마트폰", quarter: "Q4", sales: 1500000, quantity: 30 },
{ region: "대구", product: "태블릿", quarter: "Q1", sales: 400000, quantity: 5 },
{ region: "대구", product: "태블릿", quarter: "Q2", sales: 450000, quantity: 5 },
{ region: "대구", product: "태블릿", quarter: "Q3", sales: 500000, quantity: 6 },
{ region: "대구", product: "태블릿", quarter: "Q4", sales: 600000, quantity: 7 },
];
const SAMPLE_FIELDS: PivotFieldConfig[] = [
{
field: "region",
caption: "지역",
area: "row",
areaIndex: 0,
dataType: "string",
visible: true,
},
{
field: "product",
caption: "제품",
area: "row",
areaIndex: 1,
dataType: "string",
visible: true,
},
{
field: "quarter",
caption: "분기",
area: "column",
areaIndex: 0,
dataType: "string",
visible: true,
},
{
field: "sales",
caption: "매출",
area: "data",
areaIndex: 0,
dataType: "number",
summaryType: "sum",
format: { type: "number", precision: 0 },
visible: true,
},
];
/**
* PivotGrid ( )
*/
const PivotGridWrapper: React.FC<any> = (props) => {
// 컴포넌트 설정에서 값 추출
const componentConfig = props.componentConfig || props.config || {};
const configFields = componentConfig.fields || props.fields;
const configData = props.data;
// 디버깅 로그
console.log("🔷 PivotGridWrapper props:", {
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive,
hasComponentConfig: !!props.componentConfig,
hasConfig: !!props.config,
hasData: !!configData,
dataLength: configData?.length,
hasFields: !!configFields,
fieldsLength: configFields?.length,
});
// 디자인 모드 판단:
// 1. isDesignMode === true
// 2. isInteractive === false (편집 모드)
// 3. 데이터가 없는 경우
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
const usePreviewData = isDesignMode || !hasValidData;
// 최종 데이터/필드 결정
const finalData = usePreviewData ? SAMPLE_DATA : configData;
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
const finalTitle = usePreviewData
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
: (componentConfig.title || props.title);
console.log("🔷 PivotGridWrapper final:", {
isDesignMode,
usePreviewData,
finalDataLength: finalData?.length,
finalFieldsLength: finalFields?.length,
});
// 총계 설정
const totalsConfig = componentConfig.totals || props.totals || {
showRowGrandTotals: true,
showColumnGrandTotals: true,
showRowTotals: true,
showColumnTotals: true,
};
return (
<PivotGridComponent
title={finalTitle}
data={finalData}
fields={finalFields}
totals={totalsConfig}
style={componentConfig.style || props.style}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height={componentConfig.height || props.height || "400px"}
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}
onCellDoubleClick={props.onCellDoubleClick}
onFieldDrop={props.onFieldDrop}
onExpandChange={props.onExpandChange}
/>
);
};
/**
* PivotGrid
*/
const PivotGridDefinition = createComponentDefinition({
id: "pivot-grid",
name: "피벗 그리드",
nameEng: "PivotGrid Component",
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: PivotGridWrapper, // 래퍼 컴포넌트 사용
defaultConfig: {
dataSource: {
type: "table",
tableName: "",
},
fields: SAMPLE_FIELDS,
// 미리보기용 샘플 데이터
sampleData: SAMPLE_DATA,
totals: {
showRowGrandTotals: true,
showColumnGrandTotals: true,
showRowTotals: true,
showColumnTotals: true,
},
style: {
theme: "default",
headerStyle: "default",
cellPadding: "normal",
borderStyle: "light",
alternateRowColors: true,
highlightTotals: true,
},
allowExpandAll: true,
exportConfig: {
excel: true,
},
height: "400px",
},
defaultSize: { width: 800, height: 500 },
configPanel: PivotGridConfigPanel,
icon: "BarChart3",
tags: ["피벗", "분석", "집계", "그리드", "데이터"],
version: "1.0.0",
author: "개발팀",
documentation: "",
});
/**
* PivotGrid
*
*/
export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = PivotGridDefinition;
render(): React.ReactElement {
const props = this.props as any;
// 컴포넌트 설정에서 값 추출
const componentConfig = props.componentConfig || props.config || {};
const configFields = componentConfig.fields || props.fields;
const configData = props.data;
// 디버깅 로그
console.log("🔷 PivotGridRenderer props:", {
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive,
hasComponentConfig: !!props.componentConfig,
hasConfig: !!props.config,
hasData: !!configData,
dataLength: configData?.length,
hasFields: !!configFields,
fieldsLength: configFields?.length,
});
// 디자인 모드 판단:
// 1. isDesignMode === true
// 2. isInteractive === false (편집 모드)
// 3. 데이터가 없는 경우
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
const usePreviewData = isDesignMode || !hasValidData;
// 최종 데이터/필드 결정
const finalData = usePreviewData ? SAMPLE_DATA : configData;
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
const finalTitle = usePreviewData
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
: (componentConfig.title || props.title);
console.log("🔷 PivotGridRenderer final:", {
isDesignMode,
usePreviewData,
finalDataLength: finalData?.length,
finalFieldsLength: finalFields?.length,
});
// 총계 설정
const totalsConfig = componentConfig.totals || props.totals || {
showRowGrandTotals: true,
showColumnGrandTotals: true,
showRowTotals: true,
showColumnTotals: true,
};
return (
<PivotGridComponent
title={finalTitle}
data={finalData}
fields={finalFields}
totals={totalsConfig}
style={componentConfig.style || props.style}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height={componentConfig.height || props.height || "400px"}
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}
onCellDoubleClick={props.onCellDoubleClick}
onFieldDrop={props.onFieldDrop}
onExpandChange={props.onExpandChange}
/>
);
}
}
// 자동 등록 실행
PivotGridRenderer.registerSelf();
// 강제 등록 (디버깅용)
if (typeof window !== "undefined") {
setTimeout(() => {
try {
PivotGridRenderer.registerSelf();
} catch (error) {
console.error("❌ PivotGrid 강제 등록 실패:", error);
}
}, 1000);
}

View File

@ -0,0 +1,239 @@
# PivotGrid 컴포넌트
다차원 데이터 분석을 위한 피벗 테이블 컴포넌트입니다.
## 주요 기능
### 1. 다차원 데이터 배치
- **행 영역(Row Area)**: 데이터를 행으로 그룹화 (예: 지역 → 도시)
- **열 영역(Column Area)**: 데이터를 열로 그룹화 (예: 연도 → 분기)
- **데이터 영역(Data Area)**: 집계될 수치 필드 (예: 매출액, 수량)
- **필터 영역(Filter Area)**: 전체 데이터 필터링
### 2. 집계 함수
| 함수 | 설명 | 사용 예 |
|------|------|---------|
| `sum` | 합계 | 매출 합계 |
| `count` | 개수 | 건수 |
| `avg` | 평균 | 평균 단가 |
| `min` | 최소값 | 최저가 |
| `max` | 최대값 | 최고가 |
| `countDistinct` | 고유값 개수 | 거래처 수 |
### 3. 날짜 그룹화
날짜 필드를 다양한 단위로 그룹화할 수 있습니다:
- `year`: 연도별
- `quarter`: 분기별
- `month`: 월별
- `week`: 주별
- `day`: 일별
### 4. 드릴다운
계층적 데이터를 확장/축소하여 상세 내용을 볼 수 있습니다.
### 5. 총합계/소계
- 행 총합계 (Row Grand Total)
- 열 총합계 (Column Grand Total)
- 행 소계 (Row Subtotal)
- 열 소계 (Column Subtotal)
### 6. 내보내기
CSV 형식으로 데이터를 내보낼 수 있습니다.
## 사용법
### 기본 사용
```tsx
import { PivotGridComponent } from "@/lib/registry/components/pivot-grid";
const salesData = [
{ region: "북미", city: "뉴욕", year: 2024, quarter: "Q1", amount: 15000 },
{ region: "북미", city: "LA", year: 2024, quarter: "Q1", amount: 12000 },
// ...
];
<PivotGridComponent
title="매출 분석"
data={salesData}
fields={[
{ field: "region", caption: "지역", area: "row", areaIndex: 0 },
{ field: "city", caption: "도시", area: "row", areaIndex: 1 },
{ field: "year", caption: "연도", area: "column", areaIndex: 0 },
{ field: "quarter", caption: "분기", area: "column", areaIndex: 1 },
{ field: "amount", caption: "매출액", area: "data", summaryType: "sum" },
]}
/>
```
### 날짜 그룹화
```tsx
<PivotGridComponent
data={orderData}
fields={[
{ field: "customer", caption: "거래처", area: "row" },
{
field: "orderDate",
caption: "주문일",
area: "column",
dataType: "date",
groupInterval: "month", // 월별 그룹화
},
{ field: "totalAmount", caption: "주문금액", area: "data", summaryType: "sum" },
]}
/>
```
### 포맷 설정
```tsx
<PivotGridComponent
data={salesData}
fields={[
{ field: "region", caption: "지역", area: "row" },
{ field: "year", caption: "연도", area: "column" },
{
field: "amount",
caption: "매출액",
area: "data",
summaryType: "sum",
format: {
type: "currency",
prefix: "₩",
thousandSeparator: true,
},
},
{
field: "ratio",
caption: "비율",
area: "data",
summaryType: "avg",
format: {
type: "percent",
precision: 1,
suffix: "%",
},
},
]}
/>
```
### 화면 관리에서 사용
설정 패널을 통해 테이블 선택, 필드 배치, 집계 함수 등을 GUI로 설정할 수 있습니다.
```tsx
import { PivotGridRenderer } from "@/lib/registry/components/pivot-grid";
<PivotGridRenderer
id="pivot1"
config={{
dataSource: {
type: "table",
tableName: "sales_data",
},
fields: [...],
totals: {
showRowGrandTotals: true,
showColumnGrandTotals: true,
},
exportConfig: {
excel: true,
},
}}
autoFilter={{ companyCode: "COMPANY_A" }}
/>
```
## 설정 옵션
### PivotGridProps
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `title` | `string` | - | 피벗 테이블 제목 |
| `data` | `any[]` | `[]` | 원본 데이터 배열 |
| `fields` | `PivotFieldConfig[]` | `[]` | 필드 설정 목록 |
| `totals` | `PivotTotalsConfig` | - | 총합계/소계 표시 설정 |
| `style` | `PivotStyleConfig` | - | 스타일 설정 |
| `allowExpandAll` | `boolean` | `true` | 전체 확장/축소 버튼 |
| `exportConfig` | `PivotExportConfig` | - | 내보내기 설정 |
| `height` | `string | number` | `"auto"` | 높이 |
| `maxHeight` | `string` | - | 최대 높이 |
### PivotFieldConfig
| 속성 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `field` | `string` | O | 데이터 필드명 |
| `caption` | `string` | O | 표시 라벨 |
| `area` | `"row" | "column" | "data" | "filter"` | O | 배치 영역 |
| `areaIndex` | `number` | - | 영역 내 순서 |
| `dataType` | `"string" | "number" | "date" | "boolean"` | - | 데이터 타입 |
| `summaryType` | `AggregationType` | - | 집계 함수 (data 영역) |
| `groupInterval` | `DateGroupInterval` | - | 날짜 그룹 단위 |
| `format` | `PivotFieldFormat` | - | 값 포맷 |
| `visible` | `boolean` | - | 표시 여부 |
### PivotTotalsConfig
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `showRowGrandTotals` | `boolean` | `true` | 행 총합계 표시 |
| `showColumnGrandTotals` | `boolean` | `true` | 열 총합계 표시 |
| `showRowTotals` | `boolean` | `true` | 행 소계 표시 |
| `showColumnTotals` | `boolean` | `true` | 열 소계 표시 |
## 파일 구조
```
pivot-grid/
├── index.ts # 모듈 진입점
├── types.ts # 타입 정의
├── PivotGridComponent.tsx # 메인 컴포넌트
├── PivotGridRenderer.tsx # 화면 관리 렌더러
├── PivotGridConfigPanel.tsx # 설정 패널
├── README.md # 문서
└── utils/
├── index.ts # 유틸리티 모듈 진입점
├── aggregation.ts # 집계 함수
└── pivotEngine.ts # 피벗 데이터 처리 엔진
```
## 사용 시나리오
### 1. 매출 분석
지역별/기간별/제품별 매출 현황을 분석합니다.
### 2. 재고 현황
창고별/품목별 재고 수량을 한눈에 파악합니다.
### 3. 생산 실적
생산라인별/일자별 생산량을 분석합니다.
### 4. 비용 분석
부서별/계정별 비용을 집계하여 분석합니다.
### 5. 수주 현황
거래처별/품목별/월별 수주 현황을 분석합니다.
## 주의사항
1. **대량 데이터**: 데이터가 많을 경우 성능에 영향을 줄 수 있습니다. 적절한 필터링을 사용하세요.
2. **멀티테넌시**: `autoFilter.companyCode`를 통해 회사별 데이터 격리가 적용됩니다.
3. **필드 순서**: `areaIndex`를 통해 영역 내 필드 순서를 지정하세요.

View File

@ -0,0 +1,429 @@
"use client";
/**
* DrillDownModal
*
*/
import React, { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import { PivotCellData, PivotFieldConfig } from "../types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Search,
Download,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
// ==================== 타입 ====================
interface DrillDownModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cellData: PivotCellData | null;
data: any[]; // 전체 원본 데이터
fields: PivotFieldConfig[];
rowFields: PivotFieldConfig[];
columnFields: PivotFieldConfig[];
}
interface SortConfig {
field: string;
direction: "asc" | "desc";
}
// ==================== 메인 컴포넌트 ====================
export const DrillDownModal: React.FC<DrillDownModalProps> = ({
open,
onOpenChange,
cellData,
data,
fields,
rowFields,
columnFields,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
// 드릴다운 데이터 필터링
const filteredData = useMemo(() => {
if (!cellData || !data) return [];
// 행/열 경로에 해당하는 데이터 필터링
let result = data.filter((row) => {
// 행 경로 매칭
for (let i = 0; i < cellData.rowPath.length; i++) {
const field = rowFields[i];
if (field && String(row[field.field]) !== cellData.rowPath[i]) {
return false;
}
}
// 열 경로 매칭
for (let i = 0; i < cellData.columnPath.length; i++) {
const field = columnFields[i];
if (field && String(row[field.field]) !== cellData.columnPath[i]) {
return false;
}
}
return true;
});
// 검색 필터
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((row) =>
Object.values(row).some((val) =>
String(val).toLowerCase().includes(query)
)
);
}
// 정렬
if (sortConfig) {
result = [...result].sort((a, b) => {
const aVal = a[sortConfig.field];
const bVal = b[sortConfig.field];
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
let comparison = 0;
if (typeof aVal === "number" && typeof bVal === "number") {
comparison = aVal - bVal;
} else {
comparison = String(aVal).localeCompare(String(bVal));
}
return sortConfig.direction === "asc" ? comparison : -comparison;
});
}
return result;
}, [cellData, data, rowFields, columnFields, searchQuery, sortConfig]);
// 페이지네이션
const totalPages = Math.ceil(filteredData.length / pageSize);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filteredData.slice(start, start + pageSize);
}, [filteredData, currentPage, pageSize]);
// 표시할 컬럼 결정
const displayColumns = useMemo(() => {
// 모든 필드의 field명 수집
const fieldNames = new Set<string>();
// fields에서 가져오기
fields.forEach((f) => fieldNames.add(f.field));
// 데이터에서 추가 컬럼 가져오기
if (data.length > 0) {
Object.keys(data[0]).forEach((key) => fieldNames.add(key));
}
return Array.from(fieldNames).map((fieldName) => {
const fieldConfig = fields.find((f) => f.field === fieldName);
return {
field: fieldName,
caption: fieldConfig?.caption || fieldName,
dataType: fieldConfig?.dataType || "string",
};
});
}, [fields, data]);
// 정렬 토글
const handleSort = (field: string) => {
setSortConfig((prev) => {
if (!prev || prev.field !== field) {
return { field, direction: "asc" };
}
if (prev.direction === "asc") {
return { field, direction: "desc" };
}
return null;
});
};
// CSV 내보내기
const handleExportCSV = () => {
if (filteredData.length === 0) return;
const headers = displayColumns.map((c) => c.caption);
const rows = filteredData.map((row) =>
displayColumns.map((c) => {
const val = row[c.field];
if (val === null || val === undefined) return "";
if (typeof val === "string" && val.includes(",")) {
return `"${val}"`;
}
return String(val);
})
);
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const blob = new Blob(["\uFEFF" + csv], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `drilldown_${cellData?.rowPath.join("_") || "data"}.csv`;
link.click();
};
// 페이지 변경
const goToPage = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
// 경로 표시
const pathDisplay = cellData
? [
...(cellData.rowPath.length > 0
? [`행: ${cellData.rowPath.join(" > ")}`]
: []),
...(cellData.columnPath.length > 0
? [`열: ${cellData.columnPath.join(" > ")}`]
: []),
].join(" | ")
: "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{pathDisplay || "선택한 셀의 원본 데이터"}
<span className="ml-2 text-primary font-medium">
({filteredData.length})
</span>
</DialogDescription>
</DialogHeader>
{/* 툴바 */}
<div className="flex items-center gap-2 py-2 border-b border-border">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
className="pl-9 h-9"
/>
</div>
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-28 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={handleExportCSV}
disabled={filteredData.length === 0}
className="h-9"
>
<Download className="h-4 w-4 mr-1" />
CSV
</Button>
</div>
{/* 테이블 */}
<ScrollArea className="flex-1 -mx-6">
<div className="px-6">
<Table>
<TableHeader>
<TableRow>
{displayColumns.map((col) => (
<TableHead
key={col.field}
className="whitespace-nowrap cursor-pointer hover:bg-muted/50"
onClick={() => handleSort(col.field)}
>
<div className="flex items-center gap-1">
<span>{col.caption}</span>
{sortConfig?.field === col.field ? (
sortConfig.direction === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 opacity-30" />
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={displayColumns.length}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
) : (
paginatedData.map((row, idx) => (
<TableRow key={idx}>
{displayColumns.map((col) => (
<TableCell
key={col.field}
className={cn(
"whitespace-nowrap",
col.dataType === "number" && "text-right tabular-nums"
)}
>
{formatCellValue(row[col.field], col.dataType)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</ScrollArea>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-border">
<div className="text-sm text-muted-foreground">
{(currentPage - 1) * pageSize + 1} -{" "}
{Math.min(currentPage * pageSize, filteredData.length)} /{" "}
{filteredData.length}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(1)}
disabled={currentPage === 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(totalPages)}
disabled={currentPage === totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
};
// ==================== 유틸리티 ====================
function formatCellValue(value: any, dataType: string): string {
if (value === null || value === undefined) return "-";
if (dataType === "number") {
const num = Number(value);
if (isNaN(num)) return String(value);
return num.toLocaleString();
}
if (dataType === "date") {
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString("ko-KR");
}
} catch {
// 변환 실패 시 원본 반환
}
}
return String(value);
}
export default DrillDownModal;

View File

@ -0,0 +1,441 @@
"use client";
/**
* FieldChooser
*
*/
import React, { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import { PivotFieldConfig, PivotAreaType, AggregationType, SummaryDisplayMode } from "../types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Search,
Filter,
Columns,
Rows,
BarChart3,
GripVertical,
Plus,
Minus,
Type,
Hash,
Calendar,
ToggleLeft,
} from "lucide-react";
// ==================== 타입 ====================
interface AvailableField {
field: string;
caption: string;
dataType: "string" | "number" | "date" | "boolean";
isSelected: boolean;
currentArea?: PivotAreaType;
}
interface FieldChooserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
availableFields: AvailableField[];
selectedFields: PivotFieldConfig[];
onFieldsChange: (fields: PivotFieldConfig[]) => void;
}
// ==================== 영역 설정 ====================
const AREA_OPTIONS: {
value: PivotAreaType | "none";
label: string;
icon: React.ReactNode;
}[] = [
{ value: "none", label: "사용 안함", icon: <Minus className="h-3.5 w-3.5" /> },
{ value: "filter", label: "필터", icon: <Filter className="h-3.5 w-3.5" /> },
{ value: "row", label: "행", icon: <Rows className="h-3.5 w-3.5" /> },
{ value: "column", label: "열", icon: <Columns className="h-3.5 w-3.5" /> },
{ value: "data", label: "데이터", icon: <BarChart3 className="h-3.5 w-3.5" /> },
];
const SUMMARY_OPTIONS: { value: AggregationType; label: string }[] = [
{ value: "sum", label: "합계" },
{ value: "count", label: "개수" },
{ value: "avg", label: "평균" },
{ value: "min", label: "최소" },
{ value: "max", label: "최대" },
{ value: "countDistinct", label: "고유 개수" },
];
const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
{ value: "absoluteValue", label: "절대값" },
{ value: "percentOfRowTotal", label: "행 총계 %" },
{ value: "percentOfColumnTotal", label: "열 총계 %" },
{ value: "percentOfGrandTotal", label: "전체 총계 %" },
{ value: "runningTotalByRow", label: "행 누계" },
{ value: "runningTotalByColumn", label: "열 누계" },
{ value: "differenceFromPrevious", label: "이전 대비 차이" },
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
];
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
string: <Type className="h-3.5 w-3.5" />,
number: <Hash className="h-3.5 w-3.5" />,
date: <Calendar className="h-3.5 w-3.5" />,
boolean: <ToggleLeft className="h-3.5 w-3.5" />,
};
// ==================== 필드 아이템 ====================
interface FieldItemProps {
field: AvailableField;
config?: PivotFieldConfig;
onAreaChange: (area: PivotAreaType | "none") => void;
onSummaryChange?: (summary: AggregationType) => void;
onDisplayModeChange?: (displayMode: SummaryDisplayMode) => void;
}
const FieldItem: React.FC<FieldItemProps> = ({
field,
config,
onAreaChange,
onSummaryChange,
onDisplayModeChange,
}) => {
const currentArea = config?.area || "none";
const isSelected = currentArea !== "none";
return (
<div
className={cn(
"flex items-center gap-3 p-2 rounded-md border",
"transition-colors",
isSelected
? "bg-primary/5 border-primary/30"
: "bg-background border-border hover:bg-muted/50"
)}
>
{/* 데이터 타입 아이콘 */}
<div className="text-muted-foreground">
{DATA_TYPE_ICONS[field.dataType] || <Type className="h-3.5 w-3.5" />}
</div>
{/* 필드명 */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{field.caption}</div>
<div className="text-xs text-muted-foreground truncate">
{field.field}
</div>
</div>
{/* 영역 선택 */}
<Select
value={currentArea}
onValueChange={(value) => onAreaChange(value as PivotAreaType | "none")}
>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AREA_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
{option.icon}
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 집계 함수 선택 (데이터 영역인 경우) */}
{currentArea === "data" && onSummaryChange && (
<Select
value={config?.summaryType || "sum"}
onValueChange={(value) => onSummaryChange(value as AggregationType)}
>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUMMARY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 표시 모드 선택 (데이터 영역인 경우) */}
{currentArea === "data" && onDisplayModeChange && (
<Select
value={config?.summaryDisplayMode || "absoluteValue"}
onValueChange={(value) => onDisplayModeChange(value as SummaryDisplayMode)}
>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISPLAY_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
};
// ==================== 메인 컴포넌트 ====================
export const FieldChooser: React.FC<FieldChooserProps> = ({
open,
onOpenChange,
availableFields,
selectedFields,
onFieldsChange,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<"all" | "selected" | "unselected">(
"all"
);
// 필터링된 필드 목록
const filteredFields = useMemo(() => {
let result = availableFields;
// 검색어 필터
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(f) =>
f.caption.toLowerCase().includes(query) ||
f.field.toLowerCase().includes(query)
);
}
// 선택 상태 필터
if (filterType === "selected") {
result = result.filter((f) =>
selectedFields.some((sf) => sf.field === f.field && sf.visible !== false)
);
} else if (filterType === "unselected") {
result = result.filter(
(f) =>
!selectedFields.some(
(sf) => sf.field === f.field && sf.visible !== false
)
);
}
return result;
}, [availableFields, selectedFields, searchQuery, filterType]);
// 필드 영역 변경
const handleAreaChange = (
field: AvailableField,
area: PivotAreaType | "none"
) => {
const existingConfig = selectedFields.find((f) => f.field === field.field);
if (area === "none") {
// 필드 제거 또는 숨기기
if (existingConfig) {
const newFields = selectedFields.map((f) =>
f.field === field.field ? { ...f, visible: false } : f
);
onFieldsChange(newFields);
}
} else {
// 필드 추가 또는 영역 변경
if (existingConfig) {
const newFields = selectedFields.map((f) =>
f.field === field.field
? { ...f, area, visible: true }
: f
);
onFieldsChange(newFields);
} else {
// 새 필드 추가
const newField: PivotFieldConfig = {
field: field.field,
caption: field.caption,
area,
dataType: field.dataType,
visible: true,
summaryType: area === "data" ? "sum" : undefined,
areaIndex: selectedFields.filter((f) => f.area === area).length,
};
onFieldsChange([...selectedFields, newField]);
}
}
};
// 집계 함수 변경
const handleSummaryChange = (
field: AvailableField,
summaryType: AggregationType
) => {
const newFields = selectedFields.map((f) =>
f.field === field.field ? { ...f, summaryType } : f
);
onFieldsChange(newFields);
};
// 표시 모드 변경
const handleDisplayModeChange = (
field: AvailableField,
displayMode: SummaryDisplayMode
) => {
const newFields = selectedFields.map((f) =>
f.field === field.field ? { ...f, summaryDisplayMode: displayMode } : f
);
onFieldsChange(newFields);
};
// 모든 필드 선택 해제
const handleClearAll = () => {
const newFields = selectedFields.map((f) => ({ ...f, visible: false }));
onFieldsChange(newFields);
};
// 통계
const stats = useMemo(() => {
const visible = selectedFields.filter((f) => f.visible !== false);
return {
total: availableFields.length,
selected: visible.length,
filter: visible.filter((f) => f.area === "filter").length,
row: visible.filter((f) => f.area === "row").length,
column: visible.filter((f) => f.area === "column").length,
data: visible.filter((f) => f.area === "data").length,
};
}, [availableFields, selectedFields]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
{/* 통계 */}
<div className="flex items-center gap-4 py-2 px-1 text-xs text-muted-foreground border-b border-border">
<span>: {stats.total}</span>
<span className="text-primary font-medium">
: {stats.selected}
</span>
<span>: {stats.filter}</span>
<span>: {stats.row}</span>
<span>: {stats.column}</span>
<span>: {stats.data}</span>
</div>
{/* 검색 및 필터 */}
<div className="flex items-center gap-2 py-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="필드 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<Select
value={filterType}
onValueChange={(v) =>
setFilterType(v as "all" | "selected" | "unselected")
}
>
<SelectTrigger className="w-32 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="selected"></SelectItem>
<SelectItem value="unselected"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
className="h-9"
>
</Button>
</div>
{/* 필드 목록 */}
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-2 py-2">
{filteredFields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
</div>
) : (
filteredFields.map((field) => {
const config = selectedFields.find(
(f) => f.field === field.field && f.visible !== false
);
return (
<FieldItem
key={field.field}
field={field}
config={config}
onAreaChange={(area) => handleAreaChange(field, area)}
onSummaryChange={
config?.area === "data"
? (summary) => handleSummaryChange(field, summary)
: undefined
}
onDisplayModeChange={
config?.area === "data"
? (mode) => handleDisplayModeChange(field, mode)
: undefined
}
/>
);
})
)}
</div>
</ScrollArea>
{/* 푸터 */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default FieldChooser;

View File

@ -0,0 +1,551 @@
"use client";
/**
* FieldPanel
* (, , , )
*
*/
import React, { useState } from "react";
import {
DndContext,
DragOverlay,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
DragOverEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { PivotFieldConfig, PivotAreaType } from "../types";
import {
X,
Filter,
Columns,
Rows,
BarChart3,
GripVertical,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// ==================== 타입 ====================
interface FieldPanelProps {
fields: PivotFieldConfig[];
onFieldsChange: (fields: PivotFieldConfig[]) => void;
onFieldRemove?: (field: PivotFieldConfig) => void;
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
interface FieldChipProps {
field: PivotFieldConfig;
onRemove: () => void;
onSettingsChange?: (field: PivotFieldConfig) => void;
}
interface DroppableAreaProps {
area: PivotAreaType;
fields: PivotFieldConfig[];
title: string;
icon: React.ReactNode;
onFieldRemove: (field: PivotFieldConfig) => void;
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
isOver?: boolean;
}
// ==================== 영역 설정 ====================
const AREA_CONFIG: Record<
PivotAreaType,
{ title: string; icon: React.ReactNode; color: string }
> = {
filter: {
title: "필터",
icon: <Filter className="h-3.5 w-3.5" />,
color: "bg-orange-50 border-orange-200 dark:bg-orange-950/20 dark:border-orange-800",
},
column: {
title: "열",
icon: <Columns className="h-3.5 w-3.5" />,
color: "bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800",
},
row: {
title: "행",
icon: <Rows className="h-3.5 w-3.5" />,
color: "bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-800",
},
data: {
title: "데이터",
icon: <BarChart3 className="h-3.5 w-3.5" />,
color: "bg-purple-50 border-purple-200 dark:bg-purple-950/20 dark:border-purple-800",
},
};
// ==================== 필드 칩 (드래그 가능) ====================
const SortableFieldChip: React.FC<FieldChipProps> = ({
field,
onRemove,
onSettingsChange,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${field.area}-${field.field}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
"bg-background border border-border shadow-sm",
"hover:bg-accent/50 transition-colors",
isDragging && "opacity-50 shadow-lg"
)}
>
{/* 드래그 핸들 */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
>
<GripVertical className="h-3 w-3" />
</button>
{/* 필드 라벨 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:text-primary">
<span className="font-medium">{field.caption}</span>
{field.area === "data" && field.summaryType && (
<span className="text-muted-foreground">
({getSummaryLabel(field.summaryType)})
</span>
)}
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{field.area === "data" && (
<>
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({ ...field, summaryType: "sum" })
}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({ ...field, summaryType: "count" })
}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({ ...field, summaryType: "avg" })
}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({ ...field, summaryType: "min" })
}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({ ...field, summaryType: "max" })
}
>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({
...field,
sortOrder: field.sortOrder === "asc" ? "desc" : "asc",
})
}
>
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, visible: false })}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
);
};
// ==================== 드롭 영역 ====================
const DroppableArea: React.FC<DroppableAreaProps> = ({
area,
fields,
title,
icon,
onFieldRemove,
onFieldSettingsChange,
isOver,
}) => {
const config = AREA_CONFIG[area];
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
return (
<div
className={cn(
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
"transition-colors duration-200",
config.color,
isOver && "border-primary bg-primary/5"
)}
data-area={area}
>
{/* 영역 헤더 */}
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
{icon}
<span>{title}</span>
{areaFields.length > 0 && (
<span className="text-[10px] bg-muted px-1 rounded">
{areaFields.length}
</span>
)}
</div>
{/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
{areaFields.length === 0 ? (
<span className="text-xs text-muted-foreground/50 italic">
</span>
) : (
areaFields.map((field) => (
<SortableFieldChip
key={`${area}-${field.field}`}
field={field}
onRemove={() => onFieldRemove(field)}
onSettingsChange={onFieldSettingsChange}
/>
))
)}
</div>
</SortableContext>
</div>
);
};
// ==================== 유틸리티 ====================
function getSummaryLabel(type: string): string {
const labels: Record<string, string> = {
sum: "합계",
count: "개수",
avg: "평균",
min: "최소",
max: "최대",
countDistinct: "고유",
};
return labels[type] || type;
}
// ==================== 메인 컴포넌트 ====================
export const FieldPanel: React.FC<FieldPanelProps> = ({
fields,
onFieldsChange,
onFieldRemove,
onFieldSettingsChange,
collapsed = false,
onToggleCollapse,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 드래그 시작
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
// 드래그 오버
const handleDragOver = (event: DragOverEvent) => {
const { over } = event;
if (!over) {
setOverArea(null);
return;
}
// 드롭 영역 감지
const overId = over.id as string;
const targetArea = overId.split("-")[0] as PivotAreaType;
if (["filter", "column", "row", "data"].includes(targetArea)) {
setOverArea(targetArea);
}
};
// 드래그 종료
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
setOverArea(null);
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// 필드 정보 파싱
const [sourceArea, sourceField] = activeId.split("-") as [
PivotAreaType,
string
];
const [targetArea] = overId.split("-") as [PivotAreaType, string];
// 같은 영역 내 정렬
if (sourceArea === targetArea) {
const areaFields = fields.filter((f) => f.area === sourceArea);
const sourceIndex = areaFields.findIndex((f) => f.field === sourceField);
const targetIndex = areaFields.findIndex(
(f) => `${f.area}-${f.field}` === overId
);
if (sourceIndex !== targetIndex && targetIndex >= 0) {
// 순서 변경
const newFields = [...fields];
const fieldToMove = newFields.find(
(f) => f.field === sourceField && f.area === sourceArea
);
if (fieldToMove) {
fieldToMove.areaIndex = targetIndex;
// 다른 필드들 인덱스 조정
newFields
.filter((f) => f.area === sourceArea && f.field !== sourceField)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
.forEach((f, idx) => {
f.areaIndex = idx >= targetIndex ? idx + 1 : idx;
});
}
onFieldsChange(newFields);
}
return;
}
// 다른 영역으로 이동
if (["filter", "column", "row", "data"].includes(targetArea)) {
const newFields = fields.map((f) => {
if (f.field === sourceField && f.area === sourceArea) {
return {
...f,
area: targetArea as PivotAreaType,
areaIndex: fields.filter((ff) => ff.area === targetArea).length,
};
}
return f;
});
onFieldsChange(newFields);
}
};
// 필드 제거
const handleFieldRemove = (field: PivotFieldConfig) => {
if (onFieldRemove) {
onFieldRemove(field);
} else {
// 기본 동작: visible을 false로 설정
const newFields = fields.map((f) =>
f.field === field.field && f.area === field.area
? { ...f, visible: false }
: f
);
onFieldsChange(newFields);
}
};
// 필드 설정 변경
const handleFieldSettingsChange = (updatedField: PivotFieldConfig) => {
if (onFieldSettingsChange) {
onFieldSettingsChange(updatedField);
}
const newFields = fields.map((f) =>
f.field === updatedField.field && f.area === updatedField.area
? updatedField
: f
);
onFieldsChange(newFields);
};
// 활성 필드 찾기 (드래그 중인 필드)
const activeField = activeId
? fields.find((f) => `${f.area}-${f.field}` === activeId)
: null;
if (collapsed) {
return (
<div className="border-b border-border px-3 py-2">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs"
>
</Button>
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="border-b border-border bg-muted/20 p-3">
{/* 2x2 그리드로 영역 배치 */}
<div className="grid grid-cols-2 gap-2">
{/* 필터 영역 */}
<DroppableArea
area="filter"
fields={fields}
title={AREA_CONFIG.filter.title}
icon={AREA_CONFIG.filter.icon}
onFieldRemove={handleFieldRemove}
onFieldSettingsChange={handleFieldSettingsChange}
isOver={overArea === "filter"}
/>
{/* 열 영역 */}
<DroppableArea
area="column"
fields={fields}
title={AREA_CONFIG.column.title}
icon={AREA_CONFIG.column.icon}
onFieldRemove={handleFieldRemove}
onFieldSettingsChange={handleFieldSettingsChange}
isOver={overArea === "column"}
/>
{/* 행 영역 */}
<DroppableArea
area="row"
fields={fields}
title={AREA_CONFIG.row.title}
icon={AREA_CONFIG.row.icon}
onFieldRemove={handleFieldRemove}
onFieldSettingsChange={handleFieldSettingsChange}
isOver={overArea === "row"}
/>
{/* 데이터 영역 */}
<DroppableArea
area="data"
fields={fields}
title={AREA_CONFIG.data.title}
icon={AREA_CONFIG.data.icon}
onFieldRemove={handleFieldRemove}
onFieldSettingsChange={handleFieldSettingsChange}
isOver={overArea === "data"}
/>
</div>
{/* 접기 버튼 */}
{onToggleCollapse && (
<div className="flex justify-center mt-2">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-6"
>
</Button>
</div>
)}
</div>
{/* 드래그 오버레이 */}
<DragOverlay>
{activeField ? (
<div
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
"bg-background border border-primary shadow-lg"
)}
>
<GripVertical className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{activeField.caption}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
};
export default FieldPanel;

View File

@ -0,0 +1,265 @@
"use client";
/**
* FilterPopup
*
*/
import React, { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import { PivotFieldConfig } from "../types";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Label } from "@/components/ui/label";
import {
Search,
Filter,
Check,
X,
CheckSquare,
Square,
} from "lucide-react";
// ==================== 타입 ====================
interface FilterPopupProps {
field: PivotFieldConfig;
data: any[];
onFilterChange: (field: PivotFieldConfig, values: any[], type: "include" | "exclude") => void;
trigger?: React.ReactNode;
}
// ==================== 메인 컴포넌트 ====================
export const FilterPopup: React.FC<FilterPopupProps> = ({
field,
data,
onFilterChange,
trigger,
}) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selectedValues, setSelectedValues] = useState<Set<any>>(
new Set(field.filterValues || [])
);
const [filterType, setFilterType] = useState<"include" | "exclude">(
field.filterType || "include"
);
// 고유 값 추출
const uniqueValues = useMemo(() => {
const values = new Set<any>();
data.forEach((row) => {
const value = row[field.field];
if (value !== null && value !== undefined) {
values.add(value);
}
});
return Array.from(values).sort((a, b) => {
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b), "ko");
});
}, [data, field.field]);
// 필터링된 값 목록
const filteredValues = useMemo(() => {
if (!searchQuery) return uniqueValues;
const query = searchQuery.toLowerCase();
return uniqueValues.filter((val) =>
String(val).toLowerCase().includes(query)
);
}, [uniqueValues, searchQuery]);
// 값 토글
const handleValueToggle = (value: any) => {
const newSelected = new Set(selectedValues);
if (newSelected.has(value)) {
newSelected.delete(value);
} else {
newSelected.add(value);
}
setSelectedValues(newSelected);
};
// 모두 선택
const handleSelectAll = () => {
setSelectedValues(new Set(filteredValues));
};
// 모두 해제
const handleClearAll = () => {
setSelectedValues(new Set());
};
// 적용
const handleApply = () => {
onFilterChange(field, Array.from(selectedValues), filterType);
setOpen(false);
};
// 초기화
const handleReset = () => {
setSelectedValues(new Set());
setFilterType("include");
onFilterChange(field, [], "include");
setOpen(false);
};
// 필터 활성 상태
const isFilterActive = field.filterValues && field.filterValues.length > 0;
// 선택된 항목 수
const selectedCount = selectedValues.size;
const totalCount = uniqueValues.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{trigger || (
<button
className={cn(
"p-1 rounded hover:bg-accent",
isFilterActive && "text-primary"
)}
>
<Filter className="h-3.5 w-3.5" />
</button>
)}
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="start">
<div className="p-3 border-b border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{field.caption} </span>
<div className="flex gap-1">
<button
onClick={() => setFilterType("include")}
className={cn(
"px-2 py-0.5 text-xs rounded",
filterType === "include"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
)}
>
</button>
<button
onClick={() => setFilterType("exclude")}
className={cn(
"px-2 py-0.5 text-xs rounded",
filterType === "exclude"
? "bg-destructive text-destructive-foreground"
: "bg-muted hover:bg-accent"
)}
>
</button>
</div>
</div>
{/* 검색 */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
{/* 전체 선택/해제 */}
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
<span>
{selectedCount} / {totalCount}
</span>
<div className="flex gap-2">
<button
onClick={handleSelectAll}
className="flex items-center gap-1 hover:text-foreground"
>
<CheckSquare className="h-3 w-3" />
</button>
<button
onClick={handleClearAll}
className="flex items-center gap-1 hover:text-foreground"
>
<Square className="h-3 w-3" />
</button>
</div>
</div>
</div>
{/* 값 목록 */}
<ScrollArea className="h-48">
<div className="p-2 space-y-1">
{filteredValues.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
</div>
) : (
filteredValues.map((value) => (
<label
key={String(value)}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer",
"hover:bg-muted text-sm"
)}
>
<Checkbox
checked={selectedValues.has(value)}
onCheckedChange={() => handleValueToggle(value)}
/>
<span className="truncate">{String(value)}</span>
<span className="ml-auto text-xs text-muted-foreground">
({data.filter((r) => r[field.field] === value).length})
</span>
</label>
))
)}
</div>
</ScrollArea>
{/* 버튼 */}
<div className="flex items-center justify-between p-2 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-7 text-xs"
>
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setOpen(false)}
className="h-7 text-xs"
>
</Button>
<Button
size="sm"
onClick={handleApply}
className="h-7 text-xs"
>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default FilterPopup;

View File

@ -0,0 +1,386 @@
"use client";
/**
* PivotChart
*
*/
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types";
import { pathToKey } from "../utils/pivotEngine";
import {
BarChart,
Bar,
LineChart,
Line,
AreaChart,
Area,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
// ==================== 타입 ====================
interface PivotChartProps {
pivotResult: PivotResult;
config: PivotChartConfig;
dataFields: PivotFieldConfig[];
className?: string;
}
// ==================== 색상 ====================
const COLORS = [
"#4472C4", // 파랑
"#ED7D31", // 주황
"#A5A5A5", // 회색
"#FFC000", // 노랑
"#5B9BD5", // 하늘
"#70AD47", // 초록
"#264478", // 진한 파랑
"#9E480E", // 진한 주황
"#636363", // 진한 회색
"#997300", // 진한 노랑
];
// ==================== 데이터 변환 ====================
function transformDataForChart(
pivotResult: PivotResult,
dataFields: PivotFieldConfig[]
): any[] {
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
// 행 기준 차트 데이터 생성
return flatRows.map((row) => {
const dataPoint: any = {
name: row.caption,
path: row.path,
};
// 각 열에 대한 데이터 추가
flatColumns.forEach((col) => {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = dataMatrix.get(cellKey);
if (values && values.length > 0) {
const columnName = col.caption || "전체";
dataPoint[columnName] = values[0].value;
}
});
// 총계 추가
const rowTotal = grandTotals.row.get(pathToKey(row.path));
if (rowTotal && rowTotal.length > 0) {
dataPoint["총계"] = rowTotal[0].value;
}
return dataPoint;
});
}
function transformDataForPie(
pivotResult: PivotResult,
dataFields: PivotFieldConfig[]
): any[] {
const { flatRows, grandTotals } = pivotResult;
return flatRows.map((row, idx) => {
const rowTotal = grandTotals.row.get(pathToKey(row.path));
return {
name: row.caption,
value: rowTotal?.[0]?.value || 0,
color: COLORS[idx % COLORS.length],
};
});
}
// ==================== 차트 컴포넌트 ====================
const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
return (
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
<p className="text-sm font-medium mb-1">{label}</p>
{payload.map((entry: any, idx: number) => (
<p key={idx} className="text-xs" style={{ color: entry.color }}>
{entry.name}: {entry.value?.toLocaleString()}
</p>
))}
</div>
);
};
// 막대 차트
const PivotBarChart: React.FC<{
data: any[];
columns: string[];
height: number;
showLegend: boolean;
stacked?: boolean;
}> = ({ data, columns, height, showLegend, stacked }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#e5e5e5" }}
/>
<YAxis
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#e5e5e5" }}
tickFormatter={(value) => value.toLocaleString()}
/>
<Tooltip content={<CustomTooltip />} />
{showLegend && (
<Legend
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
iconType="square"
/>
)}
{columns.map((col, idx) => (
<Bar
key={col}
dataKey={col}
fill={COLORS[idx % COLORS.length]}
stackId={stacked ? "stack" : undefined}
radius={stacked ? 0 : [4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
};
// 선 차트
const PivotLineChart: React.FC<{
data: any[];
columns: string[];
height: number;
showLegend: boolean;
}> = ({ data, columns, height, showLegend }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#e5e5e5" }}
/>
<YAxis
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#e5e5e5" }}
tickFormatter={(value) => value.toLocaleString()}
/>
<Tooltip content={<CustomTooltip />} />
{showLegend && (
<Legend
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
iconType="line"
/>
)}
{columns.map((col, idx) => (
<Line
key={col}
type="monotone"
dataKey={col}
stroke={COLORS[idx % COLORS.length]}
strokeWidth={2}
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
};
// 영역 차트
const PivotAreaChart: React.FC<{
data: any[];
columns: string[];
height: number;
showLegend: boolean;
}> = ({ data, columns, height, showLegend }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#e5e5e5" }}
/>
<YAxis
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#e5e5e5" }}
tickFormatter={(value) => value.toLocaleString()}
/>
<Tooltip content={<CustomTooltip />} />
{showLegend && (
<Legend
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
iconType="square"
/>
)}
{columns.map((col, idx) => (
<Area
key={col}
type="monotone"
dataKey={col}
fill={COLORS[idx % COLORS.length]}
stroke={COLORS[idx % COLORS.length]}
fillOpacity={0.3}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
};
// 파이 차트
const PivotPieChart: React.FC<{
data: any[];
height: number;
showLegend: boolean;
}> = ({ data, height, showLegend }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={height / 3}
label={({ name, percent }) =>
`${name} (${(percent * 100).toFixed(1)}%)`
}
labelLine
>
{data.map((entry, idx) => (
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
{showLegend && (
<Legend
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
iconType="circle"
/>
)}
</PieChart>
</ResponsiveContainer>
);
};
// ==================== 메인 컴포넌트 ====================
export const PivotChart: React.FC<PivotChartProps> = ({
pivotResult,
config,
dataFields,
className,
}) => {
// 차트 데이터 변환
const chartData = useMemo(() => {
if (config.type === "pie") {
return transformDataForPie(pivotResult, dataFields);
}
return transformDataForChart(pivotResult, dataFields);
}, [pivotResult, dataFields, config.type]);
// 열 이름 목록 (파이 차트 제외)
const columns = useMemo(() => {
if (config.type === "pie" || chartData.length === 0) return [];
const firstItem = chartData[0];
return Object.keys(firstItem).filter(
(key) => key !== "name" && key !== "path"
);
}, [chartData, config.type]);
const height = config.height || 300;
const showLegend = config.showLegend !== false;
if (!config.enabled) {
return null;
}
return (
<div
className={cn(
"border-t border-border bg-background p-4",
className
)}
>
{/* 차트 렌더링 */}
{config.type === "bar" && (
<PivotBarChart
data={chartData}
columns={columns}
height={height}
showLegend={showLegend}
/>
)}
{config.type === "stackedBar" && (
<PivotBarChart
data={chartData}
columns={columns}
height={height}
showLegend={showLegend}
stacked
/>
)}
{config.type === "line" && (
<PivotLineChart
data={chartData}
columns={columns}
height={height}
showLegend={showLegend}
/>
)}
{config.type === "area" && (
<PivotAreaChart
data={chartData}
columns={columns}
height={height}
showLegend={showLegend}
/>
)}
{config.type === "pie" && (
<PivotPieChart
data={chartData}
height={height}
showLegend={showLegend}
/>
)}
</div>
);
};
export default PivotChart;

View File

@ -0,0 +1,10 @@
/**
* PivotGrid
*/
export { FieldPanel } from "./FieldPanel";
export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart";

View File

@ -0,0 +1,27 @@
/**
* PivotGrid
*/
export {
useVirtualScroll,
useVirtualColumnScroll,
useVirtual2DScroll,
} from "./useVirtualScroll";
export type {
VirtualScrollOptions,
VirtualScrollResult,
VirtualColumnScrollOptions,
VirtualColumnScrollResult,
Virtual2DScrollOptions,
Virtual2DScrollResult,
} from "./useVirtualScroll";
export { usePivotState } from "./usePivotState";
export type {
PivotStateConfig,
SavedPivotState,
UsePivotStateResult,
} from "./usePivotState";

View File

@ -0,0 +1,231 @@
"use client";
/**
* PivotState
* /
*/
import { useState, useEffect, useCallback } from "react";
import { PivotFieldConfig, PivotGridState } from "../types";
// ==================== 타입 ====================
export interface PivotStateConfig {
enabled: boolean;
storageKey?: string;
storageType?: "localStorage" | "sessionStorage";
}
export interface SavedPivotState {
version: string;
timestamp: number;
fields: PivotFieldConfig[];
expandedRowPaths: string[][];
expandedColumnPaths: string[][];
filterConfig: Record<string, any[]>;
sortConfig: {
field: string;
direction: "asc" | "desc";
} | null;
}
export interface UsePivotStateResult {
// 상태
fields: PivotFieldConfig[];
pivotState: PivotGridState;
// 상태 변경
setFields: (fields: PivotFieldConfig[]) => void;
setPivotState: (state: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => void;
// 저장/복원
saveState: () => void;
loadState: () => boolean;
clearState: () => void;
hasStoredState: () => boolean;
// 상태 정보
lastSaved: Date | null;
isDirty: boolean;
}
// ==================== 상수 ====================
const STATE_VERSION = "1.0.0";
const DEFAULT_STORAGE_KEY = "pivot-grid-state";
// ==================== 훅 ====================
export function usePivotState(
initialFields: PivotFieldConfig[],
config: PivotStateConfig
): UsePivotStateResult {
const {
enabled,
storageKey = DEFAULT_STORAGE_KEY,
storageType = "localStorage",
} = config;
// 상태
const [fields, setFieldsInternal] = useState<PivotFieldConfig[]>(initialFields);
const [pivotState, setPivotStateInternal] = useState<PivotGridState>({
expandedRowPaths: [],
expandedColumnPaths: [],
sortConfig: null,
filterConfig: {},
});
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [initialStateLoaded, setInitialStateLoaded] = useState(false);
// 스토리지 가져오기
const getStorage = useCallback(() => {
if (typeof window === "undefined") return null;
return storageType === "localStorage" ? localStorage : sessionStorage;
}, [storageType]);
// 저장된 상태 확인
const hasStoredState = useCallback((): boolean => {
const storage = getStorage();
if (!storage) return false;
return storage.getItem(storageKey) !== null;
}, [getStorage, storageKey]);
// 상태 저장
const saveState = useCallback(() => {
if (!enabled) return;
const storage = getStorage();
if (!storage) return;
const stateToSave: SavedPivotState = {
version: STATE_VERSION,
timestamp: Date.now(),
fields,
expandedRowPaths: pivotState.expandedRowPaths,
expandedColumnPaths: pivotState.expandedColumnPaths,
filterConfig: pivotState.filterConfig,
sortConfig: pivotState.sortConfig,
};
try {
storage.setItem(storageKey, JSON.stringify(stateToSave));
setLastSaved(new Date());
setIsDirty(false);
console.log("✅ 피벗 상태 저장됨:", storageKey);
} catch (error) {
console.error("❌ 피벗 상태 저장 실패:", error);
}
}, [enabled, getStorage, storageKey, fields, pivotState]);
// 상태 불러오기
const loadState = useCallback((): boolean => {
if (!enabled) return false;
const storage = getStorage();
if (!storage) return false;
try {
const saved = storage.getItem(storageKey);
if (!saved) return false;
const parsedState: SavedPivotState = JSON.parse(saved);
// 버전 체크
if (parsedState.version !== STATE_VERSION) {
console.warn("⚠️ 저장된 상태 버전이 다름, 무시됨");
return false;
}
// 상태 복원
setFieldsInternal(parsedState.fields);
setPivotStateInternal({
expandedRowPaths: parsedState.expandedRowPaths,
expandedColumnPaths: parsedState.expandedColumnPaths,
sortConfig: parsedState.sortConfig,
filterConfig: parsedState.filterConfig,
});
setLastSaved(new Date(parsedState.timestamp));
setIsDirty(false);
console.log("✅ 피벗 상태 복원됨:", storageKey);
return true;
} catch (error) {
console.error("❌ 피벗 상태 복원 실패:", error);
return false;
}
}, [enabled, getStorage, storageKey]);
// 상태 초기화
const clearState = useCallback(() => {
const storage = getStorage();
if (!storage) return;
try {
storage.removeItem(storageKey);
setLastSaved(null);
console.log("🗑️ 피벗 상태 삭제됨:", storageKey);
} catch (error) {
console.error("❌ 피벗 상태 삭제 실패:", error);
}
}, [getStorage, storageKey]);
// 필드 변경 (dirty 플래그 설정)
const setFields = useCallback((newFields: PivotFieldConfig[]) => {
setFieldsInternal(newFields);
setIsDirty(true);
}, []);
// 피벗 상태 변경 (dirty 플래그 설정)
const setPivotState = useCallback(
(newState: PivotGridState | ((prev: PivotGridState) => PivotGridState)) => {
setPivotStateInternal(newState);
setIsDirty(true);
},
[]
);
// 초기 로드
useEffect(() => {
if (!initialStateLoaded && enabled && hasStoredState()) {
loadState();
setInitialStateLoaded(true);
}
}, [enabled, hasStoredState, loadState, initialStateLoaded]);
// 초기 필드 동기화 (저장된 상태가 없을 때)
useEffect(() => {
if (initialStateLoaded) return;
if (!hasStoredState() && initialFields.length > 0) {
setFieldsInternal(initialFields);
setInitialStateLoaded(true);
}
}, [initialFields, hasStoredState, initialStateLoaded]);
// 자동 저장 (변경 시)
useEffect(() => {
if (!enabled || !isDirty) return;
const timeout = setTimeout(() => {
saveState();
}, 1000); // 1초 디바운스
return () => clearTimeout(timeout);
}, [enabled, isDirty, saveState]);
return {
fields,
pivotState,
setFields,
setPivotState,
saveState,
loadState,
clearState,
hasStoredState,
lastSaved,
isDirty,
};
}
export default usePivotState;

View File

@ -0,0 +1,312 @@
"use client";
/**
* Virtual Scroll
*
*/
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
// ==================== 타입 ====================
export interface VirtualScrollOptions {
itemCount: number; // 전체 아이템 수
itemHeight: number; // 각 아이템 높이 (px)
containerHeight: number; // 컨테이너 높이 (px)
overscan?: number; // 버퍼 아이템 수 (기본: 5)
}
export interface VirtualScrollResult {
// 현재 보여야 할 아이템 범위
startIndex: number;
endIndex: number;
// 가상 스크롤 관련 값
totalHeight: number; // 전체 높이
offsetTop: number; // 상단 오프셋
// 보여지는 아이템 목록
visibleItems: number[];
// 이벤트 핸들러
onScroll: (scrollTop: number) => void;
// 컨테이너 ref
containerRef: React.RefObject<HTMLDivElement>;
}
// ==================== 훅 ====================
export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollResult {
const {
itemCount,
itemHeight,
containerHeight,
overscan = 5,
} = options;
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
// 보이는 아이템 수
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 시작/끝 인덱스 계산
const { startIndex, endIndex } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const end = Math.min(
itemCount - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
return { startIndex: start, endIndex: end };
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
// 전체 높이
const totalHeight = itemCount * itemHeight;
// 상단 오프셋
const offsetTop = startIndex * itemHeight;
// 보이는 아이템 인덱스 배열
const visibleItems = useMemo(() => {
const items: number[] = [];
for (let i = startIndex; i <= endIndex; i++) {
items.push(i);
}
return items;
}, [startIndex, endIndex]);
// 스크롤 핸들러
const onScroll = useCallback((newScrollTop: number) => {
setScrollTop(newScrollTop);
}, []);
// 스크롤 이벤트 리스너
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
setScrollTop(container.scrollTop);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
return {
startIndex,
endIndex,
totalHeight,
offsetTop,
visibleItems,
onScroll,
containerRef,
};
}
// ==================== 열 가상 스크롤 ====================
export interface VirtualColumnScrollOptions {
columnCount: number; // 전체 열 수
columnWidth: number; // 각 열 너비 (px)
containerWidth: number; // 컨테이너 너비 (px)
overscan?: number;
}
export interface VirtualColumnScrollResult {
startIndex: number;
endIndex: number;
totalWidth: number;
offsetLeft: number;
visibleColumns: number[];
onScroll: (scrollLeft: number) => void;
}
export function useVirtualColumnScroll(
options: VirtualColumnScrollOptions
): VirtualColumnScrollResult {
const {
columnCount,
columnWidth,
containerWidth,
overscan = 3,
} = options;
const [scrollLeft, setScrollLeft] = useState(0);
const { startIndex, endIndex } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - overscan);
const end = Math.min(
columnCount - 1,
Math.ceil((scrollLeft + containerWidth) / columnWidth) + overscan
);
return { startIndex: start, endIndex: end };
}, [scrollLeft, columnWidth, containerWidth, columnCount, overscan]);
const totalWidth = columnCount * columnWidth;
const offsetLeft = startIndex * columnWidth;
const visibleColumns = useMemo(() => {
const cols: number[] = [];
for (let i = startIndex; i <= endIndex; i++) {
cols.push(i);
}
return cols;
}, [startIndex, endIndex]);
const onScroll = useCallback((newScrollLeft: number) => {
setScrollLeft(newScrollLeft);
}, []);
return {
startIndex,
endIndex,
totalWidth,
offsetLeft,
visibleColumns,
onScroll,
};
}
// ==================== 2D 가상 스크롤 (행 + 열) ====================
export interface Virtual2DScrollOptions {
rowCount: number;
columnCount: number;
rowHeight: number;
columnWidth: number;
containerHeight: number;
containerWidth: number;
rowOverscan?: number;
columnOverscan?: number;
}
export interface Virtual2DScrollResult {
// 행 범위
rowStartIndex: number;
rowEndIndex: number;
totalHeight: number;
offsetTop: number;
visibleRows: number[];
// 열 범위
columnStartIndex: number;
columnEndIndex: number;
totalWidth: number;
offsetLeft: number;
visibleColumns: number[];
// 스크롤 핸들러
onScroll: (scrollTop: number, scrollLeft: number) => void;
// 컨테이너 ref
containerRef: React.RefObject<HTMLDivElement>;
}
export function useVirtual2DScroll(
options: Virtual2DScrollOptions
): Virtual2DScrollResult {
const {
rowCount,
columnCount,
rowHeight,
columnWidth,
containerHeight,
containerWidth,
rowOverscan = 5,
columnOverscan = 3,
} = options;
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
// 행 계산
const { rowStartIndex, rowEndIndex, visibleRows } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - rowOverscan);
const end = Math.min(
rowCount - 1,
Math.ceil((scrollTop + containerHeight) / rowHeight) + rowOverscan
);
const rows: number[] = [];
for (let i = start; i <= end; i++) {
rows.push(i);
}
return {
rowStartIndex: start,
rowEndIndex: end,
visibleRows: rows,
};
}, [scrollTop, rowHeight, containerHeight, rowCount, rowOverscan]);
// 열 계산
const { columnStartIndex, columnEndIndex, visibleColumns } = useMemo(() => {
const start = Math.max(0, Math.floor(scrollLeft / columnWidth) - columnOverscan);
const end = Math.min(
columnCount - 1,
Math.ceil((scrollLeft + containerWidth) / columnWidth) + columnOverscan
);
const cols: number[] = [];
for (let i = start; i <= end; i++) {
cols.push(i);
}
return {
columnStartIndex: start,
columnEndIndex: end,
visibleColumns: cols,
};
}, [scrollLeft, columnWidth, containerWidth, columnCount, columnOverscan]);
const totalHeight = rowCount * rowHeight;
const totalWidth = columnCount * columnWidth;
const offsetTop = rowStartIndex * rowHeight;
const offsetLeft = columnStartIndex * columnWidth;
const onScroll = useCallback((newScrollTop: number, newScrollLeft: number) => {
setScrollTop(newScrollTop);
setScrollLeft(newScrollLeft);
}, []);
// 스크롤 이벤트 리스너
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
setScrollTop(container.scrollTop);
setScrollLeft(container.scrollLeft);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
return {
rowStartIndex,
rowEndIndex,
totalHeight,
offsetTop,
visibleRows,
columnStartIndex,
columnEndIndex,
totalWidth,
offsetLeft,
visibleColumns,
onScroll,
containerRef,
};
}
export default useVirtualScroll;

View File

@ -0,0 +1,62 @@
/**
* PivotGrid
*
*/
// 타입 내보내기
export type {
// 기본 타입
PivotAreaType,
AggregationType,
SummaryDisplayMode,
SortDirection,
DateGroupInterval,
FieldDataType,
DataSourceType,
// 필드 설정
PivotFieldFormat,
PivotFieldConfig,
// 데이터 소스
PivotFilterCondition,
PivotJoinConfig,
PivotDataSourceConfig,
// 표시 설정
PivotTotalsConfig,
FieldChooserConfig,
PivotChartConfig,
PivotStyleConfig,
PivotExportConfig,
// Props
PivotGridProps,
// 결과 데이터
PivotCellData,
PivotHeaderNode,
PivotCellValue,
PivotResult,
PivotFlatRow,
PivotFlatColumn,
// 상태
PivotGridState,
// Config
PivotGridComponentConfig,
} from "./types";
// 컴포넌트 내보내기
export { PivotGridComponent } from "./PivotGridComponent";
export { PivotGridConfigPanel } from "./PivotGridConfigPanel";
// 유틸리티
export {
aggregate,
sum,
count,
avg,
min,
max,
countDistinct,
formatNumber,
formatDate,
getAggregationLabel,
} from "./utils/aggregation";
export { processPivotData, pathToKey, keyToPath } from "./utils/pivotEngine";

View File

@ -0,0 +1,401 @@
/**
* PivotGrid
*
*/
// ==================== 기본 타입 ====================
// 필드 영역 타입
export type PivotAreaType = "row" | "column" | "data" | "filter";
// 집계 함수 타입
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
// 요약 표시 모드
export type SummaryDisplayMode =
| "absoluteValue" // 절대값 (기본)
| "percentOfColumnTotal" // 열 총계 대비 %
| "percentOfRowTotal" // 행 총계 대비 %
| "percentOfGrandTotal" // 전체 총계 대비 %
| "percentOfColumnGrandTotal" // 열 대총계 대비 %
| "percentOfRowGrandTotal" // 행 대총계 대비 %
| "runningTotalByRow" // 행 방향 누계
| "runningTotalByColumn" // 열 방향 누계
| "differenceFromPrevious" // 이전 대비 차이
| "percentDifferenceFromPrevious"; // 이전 대비 % 차이
// 정렬 방향
export type SortDirection = "asc" | "desc" | "none";
// 날짜 그룹 간격
export type DateGroupInterval = "year" | "quarter" | "month" | "week" | "day";
// 필드 데이터 타입
export type FieldDataType = "string" | "number" | "date" | "boolean";
// 데이터 소스 타입
export type DataSourceType = "table" | "api" | "static";
// ==================== 필드 설정 ====================
// 필드 포맷 설정
export interface PivotFieldFormat {
type: "number" | "currency" | "percent" | "date" | "text";
precision?: number; // 소수점 자릿수
thousandSeparator?: boolean; // 천단위 구분자
prefix?: string; // 접두사 (예: "$", "₩")
suffix?: string; // 접미사 (예: "%", "원")
dateFormat?: string; // 날짜 형식 (예: "YYYY-MM-DD")
}
// 필드 설정
export interface PivotFieldConfig {
// 기본 정보
field: string; // 데이터 필드명
caption: string; // 표시 라벨
area: PivotAreaType; // 배치 영역
areaIndex?: number; // 영역 내 순서
// 데이터 타입
dataType?: FieldDataType; // 데이터 타입
// 집계 설정 (data 영역용)
summaryType?: AggregationType; // 집계 함수
summaryDisplayMode?: SummaryDisplayMode; // 요약 표시 모드
showValuesAs?: SummaryDisplayMode; // 값 표시 방식 (summaryDisplayMode 별칭)
// 정렬 설정
sortBy?: "value" | "caption"; // 정렬 기준
sortOrder?: SortDirection; // 정렬 방향
sortBySummary?: string; // 요약값 기준 정렬 (data 필드명)
// 날짜 그룹화 설정
groupInterval?: DateGroupInterval; // 날짜 그룹 간격
groupName?: string; // 그룹 이름 (같은 그룹끼리 계층 형성)
// 표시 설정
visible?: boolean; // 표시 여부
width?: number; // 컬럼 너비
expanded?: boolean; // 기본 확장 상태
// 포맷 설정
format?: PivotFieldFormat; // 값 포맷
// 필터 설정
filterValues?: any[]; // 선택된 필터 값
filterType?: "include" | "exclude"; // 필터 타입
allowFiltering?: boolean; // 필터링 허용
allowSorting?: boolean; // 정렬 허용
// 계층 관련
displayFolder?: string; // 필드 선택기에서 폴더 구조
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
}
// ==================== 데이터 소스 설정 ====================
// 필터 조건
export interface PivotFilterCondition {
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
value?: any;
valueFromField?: string; // formData에서 값 가져오기
}
// 조인 설정
export interface PivotJoinConfig {
joinType: "INNER" | "LEFT" | "RIGHT";
targetTable: string;
sourceColumn: string;
targetColumn: string;
columns: string[]; // 가져올 컬럼들
}
// 데이터 소스 설정
export interface PivotDataSourceConfig {
type: DataSourceType;
// 테이블 기반
tableName?: string; // 테이블명
// API 기반
apiEndpoint?: string; // API 엔드포인트
apiMethod?: "GET" | "POST"; // HTTP 메서드
// 정적 데이터
staticData?: any[]; // 정적 데이터
// 필터 조건
filterConditions?: PivotFilterCondition[];
// 조인 설정
joinConfigs?: PivotJoinConfig[];
}
// ==================== 표시 설정 ====================
// 총합계 표시 설정
export interface PivotTotalsConfig {
// 행 총합계
showRowGrandTotals?: boolean; // 행 총합계 표시
showRowTotals?: boolean; // 행 소계 표시
rowTotalsPosition?: "first" | "last"; // 소계 위치
// 열 총합계
showColumnGrandTotals?: boolean; // 열 총합계 표시
showColumnTotals?: boolean; // 열 소계 표시
columnTotalsPosition?: "first" | "last"; // 소계 위치
}
// 필드 선택기 설정
export interface FieldChooserConfig {
enabled: boolean; // 활성화 여부
allowSearch?: boolean; // 검색 허용
layout?: "default" | "simplified"; // 레이아웃
height?: number; // 높이
applyChangesMode?: "instantly" | "onDemand"; // 변경 적용 시점
}
// 차트 연동 설정
export interface PivotChartConfig {
enabled: boolean; // 차트 표시 여부
type: "bar" | "line" | "area" | "pie" | "stackedBar";
position: "top" | "bottom" | "left" | "right";
height?: number;
showLegend?: boolean;
animate?: boolean;
}
// 조건부 서식 규칙
export interface ConditionalFormatRule {
id: string;
type: "colorScale" | "dataBar" | "iconSet" | "cellValue";
field?: string; // 적용할 데이터 필드 (없으면 전체)
// colorScale: 값 범위에 따른 색상 그라데이션
colorScale?: {
minColor: string; // 최소값 색상 (예: "#ff0000")
midColor?: string; // 중간값 색상 (선택)
maxColor: string; // 최대값 색상 (예: "#00ff00")
};
// dataBar: 값에 따른 막대 표시
dataBar?: {
color: string; // 막대 색상
showValue?: boolean; // 값 표시 여부
minValue?: number; // 최소값 (없으면 자동)
maxValue?: number; // 최대값 (없으면 자동)
};
// iconSet: 값에 따른 아이콘 표시
iconSet?: {
type: "arrows" | "traffic" | "rating" | "flags";
thresholds: number[]; // 경계값 (예: [30, 70] = 0-30, 30-70, 70-100)
reverse?: boolean; // 아이콘 순서 반전
};
// cellValue: 조건에 따른 스타일
cellValue?: {
operator: ">" | ">=" | "<" | "<=" | "=" | "!=" | "between";
value1: number;
value2?: number; // between 연산자용
backgroundColor?: string;
textColor?: string;
bold?: boolean;
};
}
// 스타일 설정
export interface PivotStyleConfig {
theme: "default" | "compact" | "modern";
headerStyle: "default" | "dark" | "light";
cellPadding: "compact" | "normal" | "comfortable";
borderStyle: "none" | "light" | "heavy";
alternateRowColors?: boolean;
highlightTotals?: boolean; // 총합계 강조
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
}
// ==================== 내보내기 설정 ====================
export interface PivotExportConfig {
excel?: boolean;
pdf?: boolean;
fileName?: string;
}
// ==================== 메인 Props ====================
export interface PivotGridProps {
// 기본 설정
id?: string;
title?: string;
// 데이터 소스
dataSource?: PivotDataSourceConfig;
// 필드 설정
fields?: PivotFieldConfig[];
// 표시 설정
totals?: PivotTotalsConfig;
style?: PivotStyleConfig;
// 필드 선택기
fieldChooser?: FieldChooserConfig;
// 차트 연동
chart?: PivotChartConfig;
// 기능 설정
allowSortingBySummary?: boolean; // 요약값 기준 정렬
allowFiltering?: boolean; // 필터링 허용
allowExpandAll?: boolean; // 전체 확장/축소 허용
wordWrapEnabled?: boolean; // 텍스트 줄바꿈
// 크기 설정
height?: string | number;
maxHeight?: string;
// 상태 저장
stateStoring?: {
enabled: boolean;
storageKey?: string; // localStorage 키
};
// 내보내기
exportConfig?: PivotExportConfig;
// 데이터 (외부 주입용)
data?: any[];
// 이벤트
onCellClick?: (cellData: PivotCellData) => void;
onCellDoubleClick?: (cellData: PivotCellData) => void;
onFieldDrop?: (field: PivotFieldConfig, targetArea: PivotAreaType) => void;
onExpandChange?: (expandedPaths: string[][]) => void;
onDataChange?: (data: any[]) => void;
}
// ==================== 결과 데이터 구조 ====================
// 셀 데이터
export interface PivotCellData {
value: any; // 셀 값
rowPath: string[]; // 행 경로 (예: ["북미", "뉴욕"])
columnPath: string[]; // 열 경로 (예: ["2024", "Q1"])
field?: string; // 데이터 필드명
aggregationType?: AggregationType;
isTotal?: boolean; // 총합계 여부
isGrandTotal?: boolean; // 대총합 여부
}
// 헤더 노드 (트리 구조)
export interface PivotHeaderNode {
value: any; // 원본 값
caption: string; // 표시 텍스트
level: number; // 깊이
children?: PivotHeaderNode[]; // 자식 노드
isExpanded: boolean; // 확장 상태
path: string[]; // 경로 (드릴다운용)
subtotal?: PivotCellValue[]; // 소계
span?: number; // colspan/rowspan
}
// 셀 값
export interface PivotCellValue {
field: string; // 데이터 필드
value: number | null; // 집계 값
formattedValue: string; // 포맷된 값
}
// 피벗 결과 데이터 구조
export interface PivotResult {
// 행 헤더 트리
rowHeaders: PivotHeaderNode[];
// 열 헤더 트리
columnHeaders: PivotHeaderNode[];
// 데이터 매트릭스 (rowPath + columnPath → values)
dataMatrix: Map<string, PivotCellValue[]>;
// 플랫 행 목록 (렌더링용)
flatRows: PivotFlatRow[];
// 플랫 열 목록 (렌더링용)
flatColumns: PivotFlatColumn[];
// 총합계
grandTotals: {
row: Map<string, PivotCellValue[]>; // 행별 총합
column: Map<string, PivotCellValue[]>; // 열별 총합
grand: PivotCellValue[]; // 대총합
};
}
// 플랫 행 (렌더링용)
export interface PivotFlatRow {
path: string[];
level: number;
caption: string;
isExpanded: boolean;
hasChildren: boolean;
isTotal?: boolean;
}
// 플랫 열 (렌더링용)
export interface PivotFlatColumn {
path: string[];
level: number;
caption: string;
span: number;
isTotal?: boolean;
}
// ==================== 상태 관리 ====================
export interface PivotGridState {
expandedRowPaths: string[][]; // 확장된 행 경로들
expandedColumnPaths: string[][]; // 확장된 열 경로들
sortConfig: {
field: string;
direction: SortDirection;
} | null;
filterConfig: Record<string, any[]>; // 필드별 필터값
}
// ==================== 컴포넌트 Config (화면관리용) ====================
export interface PivotGridComponentConfig {
// 데이터 소스
dataSource?: PivotDataSourceConfig;
// 필드 설정
fields?: PivotFieldConfig[];
// 표시 설정
totals?: PivotTotalsConfig;
style?: PivotStyleConfig;
// 필드 선택기
fieldChooser?: FieldChooserConfig;
// 차트 연동
chart?: PivotChartConfig;
// 기능 설정
allowSortingBySummary?: boolean;
allowFiltering?: boolean;
allowExpandAll?: boolean;
wordWrapEnabled?: boolean;
// 크기 설정
height?: string | number;
maxHeight?: string;
// 내보내기
exportConfig?: PivotExportConfig;
}

View File

@ -0,0 +1,176 @@
/**
* PivotGrid
* .
*/
import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ====================
/**
*
*/
export function sum(values: number[]): number {
return values.reduce((acc, val) => acc + (val || 0), 0);
}
/**
*
*/
export function count(values: any[]): number {
return values.length;
}
/**
*
*/
export function avg(values: number[]): number {
if (values.length === 0) return 0;
return sum(values) / values.length;
}
/**
*
*/
export function min(values: number[]): number {
if (values.length === 0) return 0;
return Math.min(...values.filter((v) => v !== null && v !== undefined));
}
/**
*
*/
export function max(values: number[]): number {
if (values.length === 0) return 0;
return Math.max(...values.filter((v) => v !== null && v !== undefined));
}
/**
*
*/
export function countDistinct(values: any[]): number {
return new Set(values.filter((v) => v !== null && v !== undefined)).size;
}
/**
*
*/
export function aggregate(
values: any[],
type: AggregationType = "sum"
): number {
const numericValues = values
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
.filter((v) => !isNaN(v));
switch (type) {
case "sum":
return sum(numericValues);
case "count":
return count(values);
case "avg":
return avg(numericValues);
case "min":
return min(numericValues);
case "max":
return max(numericValues);
case "countDistinct":
return countDistinct(values);
default:
return sum(numericValues);
}
}
// ==================== 포맷 함수 ====================
/**
*
*/
export function formatNumber(
value: number | null | undefined,
format?: PivotFieldFormat
): string {
if (value === null || value === undefined) return "-";
const {
type = "number",
precision = 0,
thousandSeparator = true,
prefix = "",
suffix = "",
} = format || {};
let formatted: string;
switch (type) {
case "currency":
formatted = value.toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
} else {
formatted = value.toFixed(precision);
}
break;
}
return `${prefix}${formatted}${suffix}`;
}
/**
*
*/
export function formatDate(
value: Date | string | null | undefined,
format: string = "YYYY-MM-DD"
): string {
if (!value) return "-";
const date = typeof value === "string" ? new Date(value) : value;
if (isNaN(date.getTime())) return "-";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const quarter = Math.ceil((date.getMonth() + 1) / 3);
return format
.replace("YYYY", String(year))
.replace("MM", month)
.replace("DD", day)
.replace("Q", `Q${quarter}`);
}
/**
*
*/
export function getAggregationLabel(type: AggregationType): string {
const labels: Record<AggregationType, string> = {
sum: "합계",
count: "개수",
avg: "평균",
min: "최소",
max: "최대",
countDistinct: "고유값",
};
return labels[type] || "합계";
}

View File

@ -0,0 +1,311 @@
/**
*
*
*/
import { ConditionalFormatRule } from "../types";
// ==================== 타입 ====================
export interface CellFormatStyle {
backgroundColor?: string;
textColor?: string;
fontWeight?: string;
dataBarWidth?: number; // 0-100%
dataBarColor?: string;
icon?: string; // 이모지 또는 아이콘 이름
}
// ==================== 색상 유틸리티 ====================
/**
* HEX RGB로
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* RGB를 HEX로
*/
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((x) => {
const hex = Math.round(x).toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
);
}
/**
*
*/
function interpolateColor(
color1: string,
color2: string,
factor: number
): string {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) return color1;
const r = rgb1.r + (rgb2.r - rgb1.r) * factor;
const g = rgb1.g + (rgb2.g - rgb1.g) * factor;
const b = rgb1.b + (rgb2.b - rgb1.b) * factor;
return rgbToHex(r, g, b);
}
// ==================== 조건부 서식 계산 ====================
/**
* Color Scale
*/
function applyColorScale(
value: number,
minValue: number,
maxValue: number,
rule: ConditionalFormatRule
): CellFormatStyle {
if (!rule.colorScale) return {};
const { minColor, midColor, maxColor } = rule.colorScale;
const range = maxValue - minValue;
if (range === 0) {
return { backgroundColor: minColor };
}
const normalizedValue = (value - minValue) / range;
let backgroundColor: string;
if (midColor) {
// 3색 그라데이션
if (normalizedValue <= 0.5) {
backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2);
} else {
backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2);
}
} else {
// 2색 그라데이션
backgroundColor = interpolateColor(minColor, maxColor, normalizedValue);
}
// 배경색에 따른 텍스트 색상 결정
const rgb = hexToRgb(backgroundColor);
const textColor =
rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186
? "#000000"
: "#ffffff";
return { backgroundColor, textColor };
}
/**
* Data Bar
*/
function applyDataBar(
value: number,
minValue: number,
maxValue: number,
rule: ConditionalFormatRule
): CellFormatStyle {
if (!rule.dataBar) return {};
const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar;
const min = ruleMin ?? minValue;
const max = ruleMax ?? maxValue;
const range = max - min;
if (range === 0) {
return { dataBarWidth: 100, dataBarColor: color };
}
const width = Math.max(0, Math.min(100, ((value - min) / range) * 100));
return {
dataBarWidth: width,
dataBarColor: color,
};
}
/**
* Icon Set
*/
function applyIconSet(
value: number,
minValue: number,
maxValue: number,
rule: ConditionalFormatRule
): CellFormatStyle {
if (!rule.iconSet) return {};
const { type, thresholds, reverse } = rule.iconSet;
const range = maxValue - minValue;
const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100;
// 아이콘 정의
const iconSets: Record<string, string[]> = {
arrows: ["↓", "→", "↑"],
traffic: ["🔴", "🟡", "🟢"],
rating: ["⭐", "⭐⭐", "⭐⭐⭐"],
flags: ["🚩", "🏳️", "🏁"],
};
const icons = iconSets[type] || iconSets.arrows;
const sortedIcons = reverse ? [...icons].reverse() : icons;
// 임계값에 따른 아이콘 선택
let iconIndex = 0;
for (let i = 0; i < thresholds.length; i++) {
if (percentage >= thresholds[i]) {
iconIndex = i + 1;
}
}
iconIndex = Math.min(iconIndex, sortedIcons.length - 1);
return {
icon: sortedIcons[iconIndex],
};
}
/**
* Cell Value
*/
function applyCellValue(
value: number,
rule: ConditionalFormatRule
): CellFormatStyle {
if (!rule.cellValue) return {};
const { operator, value1, value2, backgroundColor, textColor, bold } =
rule.cellValue;
let matches = false;
switch (operator) {
case ">":
matches = value > value1;
break;
case ">=":
matches = value >= value1;
break;
case "<":
matches = value < value1;
break;
case "<=":
matches = value <= value1;
break;
case "=":
matches = value === value1;
break;
case "!=":
matches = value !== value1;
break;
case "between":
matches = value2 !== undefined && value >= value1 && value <= value2;
break;
}
if (!matches) return {};
return {
backgroundColor,
textColor,
fontWeight: bold ? "bold" : undefined,
};
}
// ==================== 메인 함수 ====================
/**
*
*/
export function getConditionalStyle(
value: number | null | undefined,
field: string,
rules: ConditionalFormatRule[],
allValues: number[] // 해당 필드의 모든 값 (min/max 계산용)
): CellFormatStyle {
if (value === null || value === undefined || isNaN(value)) {
return {};
}
if (!rules || rules.length === 0) {
return {};
}
// min/max 계산
const numericValues = allValues.filter((v) => !isNaN(v));
const minValue = Math.min(...numericValues);
const maxValue = Math.max(...numericValues);
let resultStyle: CellFormatStyle = {};
// 해당 필드에 적용되는 규칙 필터링 및 적용
for (const rule of rules) {
// 필드 필터 확인
if (rule.field && rule.field !== field) {
continue;
}
let ruleStyle: CellFormatStyle = {};
switch (rule.type) {
case "colorScale":
ruleStyle = applyColorScale(value, minValue, maxValue, rule);
break;
case "dataBar":
ruleStyle = applyDataBar(value, minValue, maxValue, rule);
break;
case "iconSet":
ruleStyle = applyIconSet(value, minValue, maxValue, rule);
break;
case "cellValue":
ruleStyle = applyCellValue(value, rule);
break;
}
// 스타일 병합 (나중 규칙이 우선)
resultStyle = { ...resultStyle, ...ruleStyle };
}
return resultStyle;
}
/**
* React
*/
export function formatStyleToReact(
style: CellFormatStyle
): React.CSSProperties {
const result: React.CSSProperties = {};
if (style.backgroundColor) {
result.backgroundColor = style.backgroundColor;
}
if (style.textColor) {
result.color = style.textColor;
}
if (style.fontWeight) {
result.fontWeight = style.fontWeight as any;
}
return result;
}
export default getConditionalStyle;

View File

@ -0,0 +1,202 @@
/**
* Excel
* Excel
* xlsx ( )
*/
import * as XLSX from "xlsx";
import {
PivotResult,
PivotFieldConfig,
PivotTotalsConfig,
} from "../types";
import { pathToKey } from "./pivotEngine";
// ==================== 타입 ====================
export interface ExportOptions {
fileName?: string;
sheetName?: string;
title?: string;
subtitle?: string;
includeHeaders?: boolean;
includeTotals?: boolean;
}
// ==================== 메인 함수 ====================
/**
* Excel로
*/
export async function exportPivotToExcel(
pivotResult: PivotResult,
fields: PivotFieldConfig[],
totals: PivotTotalsConfig,
options: ExportOptions = {}
): Promise<void> {
const {
fileName = "pivot_export",
sheetName = "Pivot",
title,
includeHeaders = true,
includeTotals = true,
} = options;
// 필드 분류
const rowFields = fields
.filter((f) => f.area === "row" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
// 데이터 배열 생성
const data: any[][] = [];
// 제목 추가
if (title) {
data.push([title]);
data.push([]); // 빈 행
}
// 헤더 행
if (includeHeaders) {
const headerRow: any[] = [
rowFields.map((f) => f.caption).join(" / ") || "항목",
];
// 열 헤더
for (const col of pivotResult.flatColumns) {
headerRow.push(col.caption || "(전체)");
}
// 총계 헤더
if (totals?.showRowGrandTotals && includeTotals) {
headerRow.push("총계");
}
data.push(headerRow);
}
// 데이터 행
for (const row of pivotResult.flatRows) {
const excelRow: any[] = [];
// 행 헤더 (들여쓰기 포함)
const indent = " ".repeat(row.level);
excelRow.push(indent + row.caption);
// 데이터 셀
for (const col of pivotResult.flatColumns) {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
const values = pivotResult.dataMatrix.get(cellKey);
if (values && values.length > 0) {
excelRow.push(values[0].value);
} else {
excelRow.push("");
}
}
// 행 총계
if (totals?.showRowGrandTotals && includeTotals) {
const rowTotal = pivotResult.grandTotals.row.get(pathToKey(row.path));
if (rowTotal && rowTotal.length > 0) {
excelRow.push(rowTotal[0].value);
} else {
excelRow.push("");
}
}
data.push(excelRow);
}
// 열 총계 행
if (totals?.showColumnGrandTotals && includeTotals) {
const totalRow: any[] = ["총계"];
for (const col of pivotResult.flatColumns) {
const colTotal = pivotResult.grandTotals.column.get(pathToKey(col.path));
if (colTotal && colTotal.length > 0) {
totalRow.push(colTotal[0].value);
} else {
totalRow.push("");
}
}
// 대총합
if (totals?.showRowGrandTotals) {
const grandTotal = pivotResult.grandTotals.grand;
if (grandTotal && grandTotal.length > 0) {
totalRow.push(grandTotal[0].value);
} else {
totalRow.push("");
}
}
data.push(totalRow);
}
// 워크시트 생성
const worksheet = XLSX.utils.aoa_to_sheet(data);
// 컬럼 너비 설정
const colWidths: XLSX.ColInfo[] = [];
const maxCols = data.reduce((max, row) => Math.max(max, row.length), 0);
for (let i = 0; i < maxCols; i++) {
colWidths.push({ wch: i === 0 ? 25 : 15 });
}
worksheet["!cols"] = colWidths;
// 워크북 생성
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
// 파일 다운로드
XLSX.writeFile(workbook, `${fileName}.xlsx`);
}
/**
* Drill Down Excel로
*/
export async function exportDrillDownToExcel(
data: any[],
columns: { field: string; caption: string }[],
options: ExportOptions = {}
): Promise<void> {
const {
fileName = "drilldown_export",
sheetName = "Data",
title,
} = options;
// 데이터 배열 생성
const sheetData: any[][] = [];
// 제목
if (title) {
sheetData.push([title]);
sheetData.push([]); // 빈 행
}
// 헤더
const headerRow = columns.map((col) => col.caption);
sheetData.push(headerRow);
// 데이터
for (const row of data) {
const dataRow = columns.map((col) => row[col.field] ?? "");
sheetData.push(dataRow);
}
// 워크시트 생성
const worksheet = XLSX.utils.aoa_to_sheet(sheetData);
// 컬럼 너비 설정
const colWidths: XLSX.ColInfo[] = columns.map(() => ({ wch: 15 }));
worksheet["!cols"] = colWidths;
// 워크북 생성
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
// 파일 다운로드
XLSX.writeFile(workbook, `${fileName}.xlsx`);
}

View File

@ -0,0 +1,6 @@
export * from "./aggregation";
export * from "./pivotEngine";
export * from "./exportExcel";
export * from "./conditionalFormat";

View File

@ -0,0 +1,812 @@
/**
* PivotGrid
* .
*/
import {
PivotFieldConfig,
PivotResult,
PivotHeaderNode,
PivotFlatRow,
PivotFlatColumn,
PivotCellValue,
DateGroupInterval,
AggregationType,
SummaryDisplayMode,
} from "../types";
import { aggregate, formatNumber, formatDate } from "./aggregation";
// ==================== 헬퍼 함수 ====================
/**
* ( )
*/
function getFieldValue(
row: Record<string, any>,
field: PivotFieldConfig
): string {
const rawValue = row[field.field];
if (rawValue === null || rawValue === undefined) {
return "(빈 값)";
}
// 날짜 그룹핑 처리
if (field.groupInterval && field.dataType === "date") {
const date = new Date(rawValue);
if (isNaN(date.getTime())) return String(rawValue);
switch (field.groupInterval) {
case "year":
return String(date.getFullYear());
case "quarter":
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
case "month":
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
case "week":
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date, "YYYY-MM-DD");
default:
return String(rawValue);
}
}
return String(rawValue);
}
/**
*
*/
function getWeekNumber(date: Date): number {
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
);
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
*
*/
export function pathToKey(path: string[]): string {
return path.join("||");
}
/**
*
*/
export function keyToPath(key: string): string[] {
return key.split("||");
}
// ==================== 헤더 생성 ====================
/**
*
*/
function buildHeaderTree(
data: Record<string, any>[],
fields: PivotFieldConfig[],
expandedPaths: Set<string>
): PivotHeaderNode[] {
if (fields.length === 0) return [];
// 첫 번째 필드로 그룹화
const firstField = fields[0];
const groups = new Map<string, Record<string, any>[]>();
data.forEach((row) => {
const value = getFieldValue(row, firstField);
if (!groups.has(value)) {
groups.set(value, []);
}
groups.get(value)!.push(row);
});
// 정렬
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
if (firstField.sortOrder === "desc") {
return b.localeCompare(a, "ko");
}
return a.localeCompare(b, "ko");
});
// 노드 생성
const nodes: PivotHeaderNode[] = [];
const remainingFields = fields.slice(1);
for (const key of sortedKeys) {
const groupData = groups.get(key)!;
const path = [key];
const pathKey = pathToKey(path);
const node: PivotHeaderNode = {
value: key,
caption: key,
level: 0,
isExpanded: expandedPaths.has(pathKey),
path: path,
span: 1,
};
// 자식 노드 생성 (확장된 경우만)
if (remainingFields.length > 0 && node.isExpanded) {
node.children = buildChildNodes(
groupData,
remainingFields,
path,
expandedPaths,
1
);
// span 계산
node.span = calculateSpan(node.children);
}
nodes.push(node);
}
return nodes;
}
/**
*
*/
function buildChildNodes(
data: Record<string, any>[],
fields: PivotFieldConfig[],
parentPath: string[],
expandedPaths: Set<string>,
level: number
): PivotHeaderNode[] {
if (fields.length === 0) return [];
const field = fields[0];
const groups = new Map<string, Record<string, any>[]>();
data.forEach((row) => {
const value = getFieldValue(row, field);
if (!groups.has(value)) {
groups.set(value, []);
}
groups.get(value)!.push(row);
});
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
if (field.sortOrder === "desc") {
return b.localeCompare(a, "ko");
}
return a.localeCompare(b, "ko");
});
const nodes: PivotHeaderNode[] = [];
const remainingFields = fields.slice(1);
for (const key of sortedKeys) {
const groupData = groups.get(key)!;
const path = [...parentPath, key];
const pathKey = pathToKey(path);
const node: PivotHeaderNode = {
value: key,
caption: key,
level: level,
isExpanded: expandedPaths.has(pathKey),
path: path,
span: 1,
};
if (remainingFields.length > 0 && node.isExpanded) {
node.children = buildChildNodes(
groupData,
remainingFields,
path,
expandedPaths,
level + 1
);
node.span = calculateSpan(node.children);
}
nodes.push(node);
}
return nodes;
}
/**
* span (colspan/rowspan)
*/
function calculateSpan(children?: PivotHeaderNode[]): number {
if (!children || children.length === 0) return 1;
return children.reduce((sum, child) => sum + child.span, 0);
}
// ==================== 플랫 구조 변환 ====================
/**
*
*/
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
const result: PivotFlatRow[] = [];
function traverse(node: PivotHeaderNode) {
result.push({
path: node.path,
level: node.level,
caption: node.caption,
isExpanded: node.isExpanded,
hasChildren: !!(node.children && node.children.length > 0),
});
if (node.isExpanded && node.children) {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
return result;
}
/**
* ( )
*/
function flattenColumns(
nodes: PivotHeaderNode[],
maxLevel: number
): PivotFlatColumn[][] {
const levels: PivotFlatColumn[][] = Array.from(
{ length: maxLevel + 1 },
() => []
);
function traverse(node: PivotHeaderNode, currentLevel: number) {
levels[currentLevel].push({
path: node.path,
level: currentLevel,
caption: node.caption,
span: node.span,
});
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, currentLevel + 1);
}
} else if (currentLevel < maxLevel) {
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
for (let i = currentLevel + 1; i <= maxLevel; i++) {
levels[i].push({
path: node.path,
level: i,
caption: "",
span: node.span,
});
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return levels;
}
/**
*
*/
function getMaxColumnLevel(
nodes: PivotHeaderNode[],
totalFields: number
): number {
let maxLevel = 0;
function traverse(node: PivotHeaderNode, level: number) {
maxLevel = Math.max(maxLevel, level);
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, level + 1);
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return Math.min(maxLevel, totalFields - 1);
}
// ==================== 데이터 매트릭스 생성 ====================
/**
*
*/
function buildDataMatrix(
data: Record<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): Map<string, PivotCellValue[]> {
const matrix = new Map<string, PivotCellValue[]>();
// 각 셀에 대해 해당하는 데이터 집계
for (const row of flatRows) {
for (const colPath of flatColumnLeaves) {
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
// 해당 행/열 경로에 맞는 데이터 필터링
const filteredData = data.filter((record) => {
// 행 조건 확인
for (let i = 0; i < row.path.length; i++) {
const field = rowFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== row.path[i]) return false;
}
// 열 조건 확인
for (let i = 0; i < colPath.length; i++) {
const field = columnFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== colPath[i]) return false;
}
return true;
});
// 데이터 필드별 집계
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(
values,
dataField.summaryType || "sum"
);
const formattedValue = formatNumber(
aggregatedValue,
dataField.format
);
return {
field: dataField.field,
value: aggregatedValue,
formattedValue,
};
});
matrix.set(cellKey, cellValues);
}
}
return matrix;
}
/**
* leaf
*/
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
const leaves: string[][] = [];
function traverse(node: PivotHeaderNode) {
if (!node.isExpanded || !node.children || node.children.length === 0) {
leaves.push(node.path);
} else {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
// 열 필드가 없을 경우 빈 경로 추가
if (leaves.length === 0) {
leaves.push([]);
}
return leaves;
}
// ==================== Summary Display Mode 적용 ====================
/**
* Summary Display Mode에
*/
function applyDisplayMode(
value: number,
displayMode: SummaryDisplayMode | undefined,
rowTotal: number,
columnTotal: number,
grandTotal: number,
prevValue: number | null,
runningTotal: number,
format?: PivotFieldConfig["format"]
): { value: number; formattedValue: string } {
if (!displayMode || displayMode === "absoluteValue") {
return {
value,
formattedValue: formatNumber(value, format),
};
}
let resultValue: number;
let formatOverride: PivotFieldConfig["format"] | undefined;
switch (displayMode) {
case "percentOfRowTotal":
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
formatOverride = { type: "percent", precision: 2, suffix: "%" };
break;
case "percentOfColumnTotal":
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
formatOverride = { type: "percent", precision: 2, suffix: "%" };
break;
case "percentOfGrandTotal":
resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100;
formatOverride = { type: "percent", precision: 2, suffix: "%" };
break;
case "percentOfRowGrandTotal":
resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100;
formatOverride = { type: "percent", precision: 2, suffix: "%" };
break;
case "percentOfColumnGrandTotal":
resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100;
formatOverride = { type: "percent", precision: 2, suffix: "%" };
break;
case "runningTotalByRow":
case "runningTotalByColumn":
resultValue = runningTotal;
break;
case "differenceFromPrevious":
resultValue = prevValue === null ? 0 : value - prevValue;
break;
case "percentDifferenceFromPrevious":
resultValue = prevValue === null || prevValue === 0
? 0
: ((value - prevValue) / Math.abs(prevValue)) * 100;
formatOverride = { type: "percent", precision: 2, suffix: "%" };
break;
default:
resultValue = value;
}
return {
value: resultValue,
formattedValue: formatNumber(resultValue, formatOverride || format),
};
}
/**
* Summary Display Mode
*/
function applyDisplayModeToMatrix(
matrix: Map<string, PivotCellValue[]>,
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][],
rowTotals: Map<string, PivotCellValue[]>,
columnTotals: Map<string, PivotCellValue[]>,
grandTotals: PivotCellValue[]
): Map<string, PivotCellValue[]> {
// displayMode가 있는 데이터 필드가 있는지 확인
const hasDisplayMode = dataFields.some(
(df) => df.summaryDisplayMode || df.showValuesAs
);
if (!hasDisplayMode) return matrix;
const newMatrix = new Map<string, PivotCellValue[]>();
// 누계를 위한 추적 (행별, 열별)
const rowRunningTotals: Map<string, number[]> = new Map(); // fieldIndex -> 누계
const colRunningTotals: Map<string, Map<number, number>> = new Map(); // colKey -> fieldIndex -> 누계
// 행 순서대로 처리
for (const row of flatRows) {
// 이전 열 값 추적 (차이 계산용)
const prevColValues: (number | null)[] = dataFields.map(() => null);
for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) {
const colPath = flatColumnLeaves[colIdx];
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
const values = matrix.get(cellKey);
if (!values) {
newMatrix.set(cellKey, []);
continue;
}
const rowKey = pathToKey(row.path);
const colKey = pathToKey(colPath);
// 총합 가져오기
const rowTotal = rowTotals.get(rowKey);
const colTotal = columnTotals.get(colKey);
const newValues: PivotCellValue[] = values.map((val, fieldIdx) => {
const dataField = dataFields[fieldIdx];
const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs;
if (!displayMode || displayMode === "absoluteValue") {
prevColValues[fieldIdx] = val.value;
return val;
}
// 누계 계산
// 행 방향 누계
if (!rowRunningTotals.has(rowKey)) {
rowRunningTotals.set(rowKey, dataFields.map(() => 0));
}
const rowRunning = rowRunningTotals.get(rowKey)!;
rowRunning[fieldIdx] += val.value || 0;
// 열 방향 누계
if (!colRunningTotals.has(colKey)) {
colRunningTotals.set(colKey, new Map());
}
const colRunning = colRunningTotals.get(colKey)!;
if (!colRunning.has(fieldIdx)) {
colRunning.set(fieldIdx, 0);
}
colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0));
const result = applyDisplayMode(
val.value || 0,
displayMode,
rowTotal?.[fieldIdx]?.value || 0,
colTotal?.[fieldIdx]?.value || 0,
grandTotals[fieldIdx]?.value || 0,
prevColValues[fieldIdx],
displayMode === "runningTotalByRow"
? rowRunning[fieldIdx]
: colRunning.get(fieldIdx) || 0,
dataField.format
);
prevColValues[fieldIdx] = val.value;
return {
field: val.field,
value: result.value,
formattedValue: result.formattedValue,
};
});
newMatrix.set(cellKey, newValues);
}
}
return newMatrix;
}
// ==================== 총합계 계산 ====================
/**
*
*/
function calculateGrandTotals(
data: Record<string, any>[],
rowFields: PivotFieldConfig[],
columnFields: PivotFieldConfig[],
dataFields: PivotFieldConfig[],
flatRows: PivotFlatRow[],
flatColumnLeaves: string[][]
): {
row: Map<string, PivotCellValue[]>;
column: Map<string, PivotCellValue[]>;
grand: PivotCellValue[];
} {
const rowTotals = new Map<string, PivotCellValue[]>();
const columnTotals = new Map<string, PivotCellValue[]>();
// 행별 총합 (각 행의 모든 열 합계)
for (const row of flatRows) {
const filteredData = data.filter((record) => {
for (let i = 0; i < row.path.length; i++) {
const field = rowFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== row.path[i]) return false;
}
return true;
});
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
rowTotals.set(pathToKey(row.path), cellValues);
}
// 열별 총합 (각 열의 모든 행 합계)
for (const colPath of flatColumnLeaves) {
const filteredData = data.filter((record) => {
for (let i = 0; i < colPath.length; i++) {
const field = columnFields[i];
if (!field) continue;
const value = getFieldValue(record, field);
if (value !== colPath[i]) return false;
}
return true;
});
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = filteredData.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
columnTotals.set(pathToKey(colPath), cellValues);
}
// 대총합
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
const values = data.map((r) => r[dataField.field]);
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
return {
field: dataField.field,
value: aggregatedValue,
formattedValue: formatNumber(aggregatedValue, dataField.format),
};
});
return {
row: rowTotals,
column: columnTotals,
grand: grandValues,
};
}
// ==================== 메인 함수 ====================
/**
*
*/
export function processPivotData(
data: Record<string, any>[],
fields: PivotFieldConfig[],
expandedRowPaths: string[][] = [],
expandedColumnPaths: string[][] = []
): PivotResult {
// 영역별 필드 분리
const rowFields = fields
.filter((f) => f.area === "row" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const columnFields = fields
.filter((f) => f.area === "column" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const dataFields = fields
.filter((f) => f.area === "data" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
const filterFields = fields.filter(
(f) => f.area === "filter" && f.visible !== false
);
// 필터 적용
let filteredData = data;
for (const filterField of filterFields) {
if (filterField.filterValues && filterField.filterValues.length > 0) {
filteredData = filteredData.filter((row) => {
const value = getFieldValue(row, filterField);
if (filterField.filterType === "exclude") {
return !filterField.filterValues!.includes(value);
}
return filterField.filterValues!.includes(value);
});
}
}
// 확장 경로 Set 변환
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
// 기본 확장: 첫 번째 레벨 모두 확장
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
const firstField = rowFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedRowSet.add(val));
}
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
const firstField = columnFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedColSet.add(val));
}
// 헤더 트리 생성
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
const columnHeaders = buildHeaderTree(
filteredData,
columnFields,
expandedColSet
);
// 플랫 구조 변환
const flatRows = flattenRows(rowHeaders);
const flatColumnLeaves = getColumnLeaves(columnHeaders);
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
// 데이터 매트릭스 생성
let dataMatrix = buildDataMatrix(
filteredData,
rowFields,
columnFields,
dataFields,
flatRows,
flatColumnLeaves
);
// 총합계 계산
const grandTotals = calculateGrandTotals(
filteredData,
rowFields,
columnFields,
dataFields,
flatRows,
flatColumnLeaves
);
// Summary Display Mode 적용
dataMatrix = applyDisplayModeToMatrix(
dataMatrix,
dataFields,
flatRows,
flatColumnLeaves,
grandTotals.row,
grandTotals.column,
grandTotals.grand
);
return {
rowHeaders,
columnHeaders,
dataMatrix,
flatRows,
flatColumns: flatColumnLeaves.map((path, idx) => ({
path,
level: path.length - 1,
caption: path[path.length - 1] || "",
span: 1,
})),
grandTotals,
};
}

View File

@ -434,20 +434,50 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
itemsList.forEach((item, itemIndex) => { itemsList.forEach((item, itemIndex) => {
// 각 그룹의 엔트리 배열들을 준비 // 각 그룹의 엔트리 배열들을 준비
const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []); // 🔧 빈 엔트리 필터링: id만 있고 실제 필드 값이 없는 엔트리는 제외
const groupEntriesArrays: GroupEntry[][] = groups.map((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
// 실제 필드 값이 하나라도 있는 엔트리만 포함
return entries.filter((entry) => {
const hasAnyFieldValue = groupFields.some((field) => {
const value = entry[field.name];
return value !== undefined && value !== null && value !== "";
});
if (!hasAnyFieldValue && Object.keys(entry).length <= 1) {
console.log("⏭️ [generateCartesianProduct] 빈 엔트리 필터링:", {
entryId: entry.id,
groupId: group.id,
entryKeys: Object.keys(entry),
});
}
return hasAnyFieldValue;
});
});
// 🆕 모든 그룹이 비어있는지 확인 // 🆕 모든 그룹이 비어있는지 확인
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0); const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
if (allGroupsEmpty) { if (allGroupsEmpty) {
// 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성 // 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시)
// (거래처 품번/품명, 기간별 단가 없이도 저장 가능) // 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지)
console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", { if (itemsList.length === 1) {
itemIndex, console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", {
itemId: item.id, itemIndex,
}); itemId: item.id,
// 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨 });
allRecords.push({}); // 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
allRecords.push({});
} else {
console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", {
itemIndex,
itemId: item.id,
totalItems: itemsList.length,
});
}
return; return;
} }
@ -471,6 +501,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
currentGroupEntries.forEach((entry) => { currentGroupEntries.forEach((entry) => {
const newCombination = { ...currentCombination }; const newCombination = { ...currentCombination };
// 🆕 기존 레코드의 id가 있으면 포함 (UPDATE를 위해)
if (entry.id) {
newCombination.id = entry.id;
}
// 현재 그룹의 필드들을 조합에 추가 // 현재 그룹의 필드들을 조합에 추가
const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id); const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id);
groupFields.forEach((field) => { groupFields.forEach((field) => {
@ -573,6 +608,27 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys);
// 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단
const parentKeyValues = Object.values(parentKeys);
const hasEmptyParentKey = parentKeyValues.length === 0 ||
parentKeyValues.some(v => v === null || v === undefined || v === "");
if (hasEmptyParentKey) {
console.error("❌ [SelectedItemsDetailInput] parentKeys가 비어있거나 유효하지 않습니다!", parentKeys);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." },
}),
);
// 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (parentKeys 검증 실패)");
}
return;
}
// items를 Cartesian Product로 변환 // items를 Cartesian Product로 변환
const records = generateCartesianProduct(items); const records = generateCartesianProduct(items);
@ -591,24 +647,21 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}), }),
); );
// 🆕 기본 저장 건너뛰기 // 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정
if (event instanceof CustomEvent && event.detail) { if (event instanceof CustomEvent && event.detail) {
event.detail.skipDefaultSave = true; (event.detail as any).skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (targetTable 없음)");
} }
return; return;
} }
// 🆕 먼저 기본 저장 로직 건너뛰기 설정 (UPSERT 전에!) // 🔧 먼저 기본 저장 로직 건너뛰기 설정 (UPSERT 전에!)
if (event instanceof CustomEvent) { // buttonActions.ts에서 beforeSaveEventDetail 객체를 event.detail로 전달하므로 직접 수정 가능
if (!event.detail) { if (event instanceof CustomEvent && event.detail) {
console.warn("⚠️ [SelectedItemsDetailInput] event.detail이 없습니다! 새로 생성합니다."); (event.detail as any).skipDefaultSave = true;
// @ts-ignore - detail 재정의 console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)", event.detail);
event.detail = {};
}
event.detail.skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 전)", event.detail);
} else { } else {
console.error("❌ [SelectedItemsDetailInput] event가 CustomEvent가 아닙니다!", event); console.error("❌ [SelectedItemsDetailInput] event.detail이 없습니다! 기본 저장이 실행될 수 있습니다.", event);
} }
console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable); console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable);

View File

@ -871,7 +871,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: 1, size: 1,
}); });
const detail = result.items && result.items.length > 0 ? result.items[0] : null; // result.data가 EntityJoinResponse의 실제 배열 필드
const detail = result.data && result.data.length > 0 ? result.data[0] : null;
setRightData(detail); setRightData(detail);
} else if (relationshipType === "join") { } else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개) // 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
@ -940,26 +941,65 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return; return;
} }
// 🆕 복합키 지원 // 🆕 엔티티 관계 자동 감지 로직 개선
if (keys && keys.length > 0 && leftTable) { // 1. 설정된 keys가 있으면 사용
// 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회
let effectiveKeys = keys || [];
if (effectiveKeys.length === 0 && leftTable && rightTableName) {
// 엔티티 관계 자동 감지
console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName);
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName);
if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) {
effectiveKeys = relResponse.data.relations.map((rel) => ({
leftColumn: rel.leftColumn,
rightColumn: rel.rightColumn,
}));
console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys);
}
}
if (effectiveKeys.length > 0 && leftTable) {
// 복합키: 여러 조건으로 필터링 // 복합키: 여러 조건으로 필터링
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성 // 복합키 조건 생성 (다중 값 지원)
// 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함
// 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
keys.forEach((key) => { effectiveKeys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = leftItem[key.leftColumn]; const leftValue = leftItem[key.leftColumn];
// 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화
if (typeof leftValue === "string") {
if (leftValue.includes(",")) {
// "2,3" 형태면 분리해서 배열로
const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v);
searchConditions[key.rightColumn] = values;
console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values);
} else {
// 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로)
searchConditions[key.rightColumn] = [leftValue.trim()];
console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]);
}
} else {
// 숫자나 다른 타입은 배열로 감싸기
searchConditions[key.rightColumn] = [leftValue];
console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]);
}
} }
}); });
console.log("🔗 [분할패널] 복합키 조건:", searchConditions); console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
// 엔티티 조인 API로 데이터 조회 // 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달)
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions, search: searchConditions,
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: 1000,
deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
}); });
console.log("🔗 [분할패널] 복합키 조회 결과:", result); console.log("🔗 [분할패널] 복합키 조회 결과:", result);
@ -988,7 +1028,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setRightData(filteredData); setRightData(filteredData);
} else { } else {
// 단일키 (하위 호환성) // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
@ -1006,6 +1046,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
); );
setRightData(joinedData || []); // 모든 관련 레코드 (배열) setRightData(joinedData || []); // 모든 관련 레코드 (배열)
} else {
console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName);
setRightData([]);
} }
} }
} }
@ -1589,13 +1632,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 수정 버튼 핸들러 // 수정 버튼 핸들러
const handleEditClick = useCallback( const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => { async (panel: "left" | "right", item: any) => {
// 🆕 현재 활성 탭의 설정 가져오기 // 🆕 현재 활성 탭의 설정 가져오기
const currentTabConfig = const currentTabConfig =
activeTabIndex === 0 activeTabIndex === 0
? componentConfig.rightPanel ? componentConfig.rightPanel
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
// 🆕 우측 패널 수정 버튼 설정 확인 // 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") { if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") {
const modalScreenId = currentTabConfig?.editButton?.modalScreenId; const modalScreenId = currentTabConfig?.editButton?.modalScreenId;
@ -1604,27 +1646,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 커스텀 모달 화면 열기 // 커스텀 모달 화면 열기
const rightTableName = currentTabConfig?.tableName || ""; const rightTableName = currentTabConfig?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyName = "id";
let primaryKeyValue: any;
if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id";
primaryKeyValue = item.id;
} else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID";
primaryKeyValue = item.ID;
} else {
// 첫 번째 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0];
primaryKeyName = firstKey;
primaryKeyValue = item[firstKey];
}
console.log("✅ 수정 모달 열기:", { console.log("✅ 수정 모달 열기:", {
tableName: rightTableName, tableName: rightTableName,
primaryKeyName,
primaryKeyValue,
screenId: modalScreenId, screenId: modalScreenId,
fullItem: item, fullItem: item,
}); });
@ -1643,15 +1666,88 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
hasGroupByColumns: groupByColumns.length > 0, hasGroupByColumns: groupByColumns.length > 0,
}); });
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) // 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출)
let allRelatedRecords = [item]; // 기본값: 현재 아이템만
if (groupByColumns.length > 0) {
// groupByColumns 값으로 검색 조건 생성
const matchConditions: Record<string, any> = {};
groupByColumns.forEach((col: string) => {
if (item[col] !== undefined && item[col] !== null) {
matchConditions[col] = item[col];
}
});
console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", {
테이블: rightTableName,
조건: matchConditions,
});
if (Object.keys(matchConditions).length > 0) {
// 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출)
try {
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확)
const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({
id: `exact-${key}`,
columnName: key,
operator: "equals",
value: value,
valueType: "text",
}));
console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters);
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
// search 대신 dataFilter 사용 (정확 매칭)
dataFilter: {
enabled: true,
matchType: "all",
filters: exactMatchFilters,
},
enableEntityJoin: true,
size: 1000,
// 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기)
deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" },
});
// 🔍 디버깅: API 응답 구조 확인
console.log("🔍 [SplitPanel] API 응답 전체:", result);
console.log("🔍 [SplitPanel] result.data:", result.data);
console.log("🔍 [SplitPanel] result 타입:", typeof result);
// result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라)
const dataArray = Array.isArray(result) ? result : (result.data || []);
if (dataArray.length > 0) {
allRelatedRecords = dataArray;
console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", {
조건: matchConditions,
결과수: allRelatedRecords.length,
레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })),
});
} else {
console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용");
}
} catch (error) {
console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error);
allRelatedRecords = [item];
}
} else {
console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용");
}
}
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("openScreenModal", { new CustomEvent("openScreenModal", {
detail: { detail: {
screenId: modalScreenId, screenId: modalScreenId,
editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열)
urlParams: { urlParams: {
mode: "edit", mode: "edit", // 🆕 수정 모드 표시
editId: primaryKeyValue,
tableName: rightTableName,
...(groupByColumns.length > 0 && { ...(groupByColumns.length > 0 && {
groupByColumns: JSON.stringify(groupByColumns), groupByColumns: JSON.stringify(groupByColumns),
}), }),
@ -1660,10 +1756,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}), }),
); );
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
screenId: modalScreenId, screenId: modalScreenId,
editId: primaryKeyValue, editData: allRelatedRecords,
tableName: rightTableName, recordCount: allRelatedRecords.length,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
}); });
@ -1814,47 +1910,89 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
try { try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
// 🔍 중복 제거 설정 디버깅 // 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication)
console.log("🔍 중복 제거 디버깅:", { const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication;
console.log("🔍 삭제 설정 디버깅:", {
panel: deleteModalPanel, panel: deleteModalPanel,
dataFilter: componentConfig.rightPanel?.dataFilter, groupByColumns,
deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, deduplication,
enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, deduplicationEnabled: deduplication?.enabled,
}); });
let result; let result;
// 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 // 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { if (deleteModalPanel === "right") {
const deduplication = componentConfig.rightPanel.dataFilter.deduplication; // 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들)
const groupByColumn = deduplication.groupByColumn; if (groupByColumns.length > 0) {
const filterConditions: Record<string, any> = {};
if (groupByColumn && deleteModalItem[groupByColumn]) {
const groupValue = deleteModalItem[groupByColumn]; // 선택된 컬럼들의 값을 필터 조건으로 추가
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); for (const col of groupByColumns) {
if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) {
// groupByColumn 값으로 필터링하여 삭제 filterConditions[col] = deleteModalItem[col];
const filterConditions: Record<string, any> = { }
[groupByColumn]: groupValue,
};
// 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join.leftColumn;
const rightColumn = componentConfig.rightPanel.join.rightColumn;
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
} }
console.log("🗑️ 그룹 삭제 조건:", filterConditions); // 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함
// (다른 거래처의 같은 품목이 삭제되는 것을 방지)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join?.leftColumn;
const rightColumn = componentConfig.rightPanel.join?.rightColumn;
if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) {
// rightColumn이 filterConditions에 없으면 추가
if (!filterConditions[rightColumn]) {
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`);
}
}
}
// 그룹 삭제 API 호출 // 필터 조건이 있으면 그룹 삭제
result = await dataApi.deleteGroupRecords(tableName, filterConditions); if (Object.keys(filterConditions).length > 0) {
} else { console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`);
// 단일 레코드 삭제 console.log("🗑️ 그룹 삭제 조건:", filterConditions);
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else {
// 필터 조건이 없으면 단일 삭제
console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환");
result = await dataApi.deleteRecord(tableName, primaryKey);
}
}
// 2. 중복 제거(deduplication)가 활성화된 경우
else if (deduplication?.enabled && deduplication?.groupByColumn) {
const groupByColumn = deduplication.groupByColumn;
const groupValue = deleteModalItem[groupByColumn];
if (groupValue) {
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
const filterConditions: Record<string, any> = {
[groupByColumn]: groupValue,
};
// 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등)
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
const leftColumn = componentConfig.rightPanel.join.leftColumn;
const rightColumn = componentConfig.rightPanel.join.rightColumn;
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
}
console.log("🗑️ 그룹 삭제 조건:", filterConditions);
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
} else {
result = await dataApi.deleteRecord(tableName, primaryKey);
}
}
// 3. 그 외: 단일 레코드 삭제
else {
result = await dataApi.deleteRecord(tableName, primaryKey); result = await dataApi.deleteRecord(tableName, primaryKey);
} }
} else { } else {
// 단일 레코드 삭제 // 좌측 패널: 단일 레코드 삭제
result = await dataApi.deleteRecord(tableName, primaryKey); result = await dataApi.deleteRecord(tableName, primaryKey);
} }

View File

@ -1272,6 +1272,71 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
} }
}, [config.rightPanel?.tableName]); }, [config.rightPanel?.tableName]);
// 🆕 좌측/우측 테이블이 모두 선택되면 엔티티 관계 자동 감지
const [autoDetectedRelations, setAutoDetectedRelations] = useState<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
>([]);
const [isDetectingRelations, setIsDetectingRelations] = useState(false);
useEffect(() => {
const detectRelations = async () => {
const leftTable = config.leftPanel?.tableName || screenTableName;
const rightTable = config.rightPanel?.tableName;
// 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지
if (relationshipType !== "join" || !leftTable || !rightTable) {
setAutoDetectedRelations([]);
return;
}
setIsDetectingRelations(true);
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
if (response.success && response.data?.relations) {
console.log("🔍 엔티티 관계 자동 감지:", response.data.relations);
setAutoDetectedRelations(response.data.relations);
// 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정
const currentKeys = config.rightPanel?.relation?.keys || [];
if (response.data.relations.length > 0 && currentKeys.length === 0) {
// 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능)
const firstRel = response.data.relations[0];
console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel);
updateRightPanel({
relation: {
...config.rightPanel?.relation,
type: "join",
useMultipleKeys: true,
keys: [
{
leftColumn: firstRel.leftColumn,
rightColumn: firstRel.rightColumn,
},
],
},
});
}
}
} catch (error) {
console.error("❌ 엔티티 관계 감지 실패:", error);
setAutoDetectedRelations([]);
} finally {
setIsDetectingRelations(false);
}
};
detectRelations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]);
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링"); console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
console.log(" - config:", config); console.log(" - config:", config);
console.log(" - tables:", tables); console.log(" - tables:", tables);
@ -2476,234 +2541,50 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
)} )}
{/* 컬럼 매핑 - 조인 모드에서만 표시 */} {/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */}
{relationshipType !== "detail" && ( {relationshipType !== "detail" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center justify-between"> <div>
<div> <Label className="text-sm font-semibold"> ( )</Label>
<Label className="text-sm font-semibold"> ( )</Label> <p className="text-xs text-gray-600"> </p>
<p className="text-xs text-gray-600"> </p>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() => {
const currentKeys = config.rightPanel?.relation?.keys || [];
// 단일키에서 복합키로 전환 시 기존 값 유지
if (
currentKeys.length === 0 &&
config.rightPanel?.relation?.leftColumn &&
config.rightPanel?.relation?.foreignKey
) {
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys: [
{
leftColumn: config.rightPanel.relation.leftColumn,
rightColumn: config.rightPanel.relation.foreignKey,
},
{ leftColumn: "", rightColumn: "" },
],
},
});
} else {
updateRightPanel({
relation: {
...config.rightPanel?.relation,
keys: [...currentKeys, { leftColumn: "", rightColumn: "" }],
},
});
}
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div> </div>
<p className="text-[10px] text-blue-600">복합키: 여러 (: item_code + lot_number)</p> {isDetectingRelations ? (
<div className="flex items-center gap-2 text-xs text-gray-500">
{/* 복합키가 설정된 경우 */} <div className="h-3 w-3 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
{(config.rightPanel?.relation?.keys || []).length > 0 ? ( ...
<> </div>
{(config.rightPanel?.relation?.keys || []).map((key, index) => ( ) : autoDetectedRelations.length > 0 ? (
<div key={index} className="space-y-2 rounded-md border bg-white p-3"> <div className="space-y-2">
<div className="flex items-center justify-between"> {autoDetectedRelations.map((rel, index) => (
<span className="text-xs font-medium"> {index + 1}</span> <div key={index} className="flex items-center gap-2 rounded-md border border-blue-300 bg-white p-2">
<Button <span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
size="sm" {leftTableName}.{rel.leftColumn}
variant="ghost" </span>
className="text-destructive h-6 w-6 p-0" <ArrowRight className="h-3 w-3 text-blue-400" />
onClick={() => { <span className="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
const newKeys = (config.rightPanel?.relation?.keys || []).filter((_, i) => i !== index); {rightTableName}.{rel.rightColumn}
updateRightPanel({ </span>
relation: { ...config.rightPanel?.relation, keys: newKeys }, <span className="ml-auto text-[10px] text-gray-500">
}); {rel.inputType === "entity" ? "엔티티" : "카테고리"}
}} </span>
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={key.leftColumn || ""}
onValueChange={(value) => {
const newKeys = [...(config.rightPanel?.relation?.keys || [])];
newKeys[index] = { ...newKeys[index], leftColumn: value };
updateRightPanel({
relation: { ...config.rightPanel?.relation, keys: newKeys },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="좌측 컬럼" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={key.rightColumn || ""}
onValueChange={(value) => {
const newKeys = [...(config.rightPanel?.relation?.keys || [])];
newKeys[index] = { ...newKeys[index], rightColumn: value };
updateRightPanel({
relation: { ...config.rightPanel?.relation, keys: newKeys },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="우측 컬럼" />
</SelectTrigger>
<SelectContent>
{rightTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div> </div>
))} ))}
</> <p className="text-[10px] text-blue-600">
/
</p>
</div>
) : config.rightPanel?.tableName ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
<p className="mt-1 text-[10px] text-gray-400">
</p>
</div>
) : ( ) : (
/* 단일키 (하위 호환성) */ <div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<> <p className="text-xs text-gray-500"> </p>
<div className="space-y-2"> </div>
<Label className="text-xs"> </Label>
<Popover open={leftColumnOpen} onOpenChange={setLeftColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={leftColumnOpen}
className="w-full justify-between"
disabled={!config.leftPanel?.tableName}
>
{config.rightPanel?.relation?.leftColumn || "좌측 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, leftColumn: value },
});
setLeftColumnOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.relation?.leftColumn === column.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{column.columnName}
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-center">
<ArrowRight className="h-4 w-4 text-gray-400" />
</div>
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Popover open={rightColumnOpen} onOpenChange={setRightColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightColumnOpen}
className="w-full justify-between"
disabled={!config.rightPanel?.tableName}
>
{config.rightPanel?.relation?.foreignKey || "우측 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
updateRightPanel({
relation: { ...config.rightPanel?.relation, foreignKey: value },
});
setRightColumnOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.relation?.foreignKey === column.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{column.columnName}
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</>
)} )}
</div> </div>
)} )}

View File

@ -104,6 +104,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
const currentFormValue = formData?.[component.columnName]; const currentFormValue = formData?.[component.columnName];
const currentComponentValue = component.value; const currentComponentValue = component.value;
// 🆕 채번 규칙이 설정되어 있으면 항상 _numberingRuleId를 formData에 설정
// (값 생성 성공 여부와 관계없이, 저장 시점에 allocateCode를 호출하기 위함)
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
const ruleId = testAutoGeneration.options.numberingRuleId;
if (ruleId && ruleId !== "undefined" && ruleId !== "null" && ruleId !== "") {
const ruleIdKey = `${component.columnName}_numberingRuleId`;
// formData에 아직 설정되지 않은 경우에만 설정
if (isInteractive && onFormDataChange && !formData?.[ruleIdKey]) {
onFormDataChange(ruleIdKey, ruleId);
console.log("📝 채번 규칙 ID 사전 설정:", ruleIdKey, ruleId);
}
}
}
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성 // 자동생성된 값이 없고, 현재 값도 없을 때만 생성
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
isGeneratingRef.current = true; // 생성 시작 플래그 isGeneratingRef.current = true; // 생성 시작 플래그
@ -144,13 +158,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
if (isInteractive && onFormDataChange && component.columnName) { if (isInteractive && onFormDataChange && component.columnName) {
console.log("📝 formData 업데이트:", component.columnName, generatedValue); console.log("📝 formData 업데이트:", component.columnName, generatedValue);
onFormDataChange(component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue);
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
const ruleIdKey = `${component.columnName}_numberingRuleId`;
onFormDataChange(ruleIdKey, testAutoGeneration.options.numberingRuleId);
console.log("📝 채번 규칙 ID 저장:", ruleIdKey, testAutoGeneration.options.numberingRuleId);
}
} }
} }
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") { } else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {

View File

@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget"; import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
import { FileWidget } from "@/components/screen/widgets/types/FileWidget"; import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget"; import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget";
import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget"; import { EntitySearchInputWrapper } from "@/lib/registry/components/entity-search-input/EntitySearchInputWrapper";
import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget"; import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget";
// 개별적으로 설정 패널들을 import // 개별적으로 설정 패널들을 import
@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() {
name: "엔티티 선택", name: "엔티티 선택",
category: "input", category: "input",
description: "데이터베이스 엔티티 선택 필드", description: "데이터베이스 엔티티 선택 필드",
component: EntityWidget, component: EntitySearchInputWrapper,
configPanel: EntityConfigPanel, configPanel: EntityConfigPanel,
defaultConfig: { defaultConfig: {
entityType: "", entityType: "",

View File

@ -535,17 +535,26 @@ export class ButtonActionExecutor {
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
const beforeSaveEventDetail = {
formData: context.formData,
skipDefaultSave: false,
};
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("beforeFormSave", { new CustomEvent("beforeFormSave", {
detail: { detail: beforeSaveEventDetail,
formData: context.formData,
},
}), }),
); );
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기
if (beforeSaveEventDetail.skipDefaultSave) {
console.log("🚫 [handleSave] skipDefaultSave=true - 기본 저장 로직 건너뛰기 (컴포넌트에서 자체 처리)");
return true;
}
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
// 🆕 렉 구조 컴포넌트 일괄 저장 감지 // 🆕 렉 구조 컴포넌트 일괄 저장 감지
@ -806,6 +815,9 @@ export class ButtonActionExecutor {
console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
let hasAllocationFailure = false;
const failedFields: string[] = [];
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try { try {
console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
@ -816,13 +828,31 @@ export class ButtonActionExecutor {
console.log(`${fieldName} 새 코드 할당: ${formData[fieldName]}${newCode}`); console.log(`${fieldName} 새 코드 할당: ${formData[fieldName]}${newCode}`);
formData[fieldName] = newCode; formData[fieldName] = newCode;
} else { } else {
console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error); console.warn(`⚠️ ${fieldName} 코드 할당 실패:`, allocateResult.error);
// 🆕 기존 값이 빈 문자열이면 실패로 표시
if (!formData[fieldName] || formData[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
} }
} catch (allocateError) { } catch (allocateError) {
console.error(`${fieldName} 코드 할당 오류:`, allocateError); console.error(`${fieldName} 코드 할당 오류:`, allocateError);
// 오류 시 기존 값 유지 // 🆕 기존 값이 빈 문자열이면 실패로 표시
if (!formData[fieldName] || formData[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
} }
} }
// 🆕 채번 규칙 할당 실패 시 저장 중단
if (hasAllocationFailure) {
const fieldNames = failedFields.join(", ");
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
console.error(`❌ 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
console.error("💡 해결 방법: 화면관리에서 해당 필드의 채번 규칙 설정을 확인하세요.");
return false;
}
} }
console.log("✅ 채번 규칙 할당 완료"); console.log("✅ 채번 규칙 할당 완료");
@ -3053,6 +3083,7 @@ export class ButtonActionExecutor {
config: ButtonActionConfig, config: ButtonActionConfig,
rowData: any, rowData: any,
context: ButtonActionContext, context: ButtonActionContext,
isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달
): Promise<void> { ): Promise<void> {
const { groupByColumns = [] } = config; const { groupByColumns = [] } = config;
@ -3126,10 +3157,11 @@ export class ButtonActionExecutor {
const modalEvent = new CustomEvent("openEditModal", { const modalEvent = new CustomEvent("openEditModal", {
detail: { detail: {
screenId: config.targetScreenId, screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정", title: isCreateMode ? (config.editModalTitle || "데이터 복사") : (config.editModalTitle || "데이터 수정"),
description: description, description: description,
modalSize: config.modalSize || "lg", modalSize: config.modalSize || "lg",
editData: rowData, editData: rowData,
isCreateMode: isCreateMode, // 🆕 복사 모드에서 INSERT로 처리되도록
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
tableName: context.tableName, // 🆕 테이블명 전달 tableName: context.tableName, // 🆕 테이블명 전달
buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용) buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용)
@ -3244,23 +3276,61 @@ export class ButtonActionExecutor {
"code", "code",
]; ];
// 🆕 화면 설정에서 채번 규칙 가져오기
let screenNumberingRules: Record<string, string> = {};
if (config.targetScreenId) {
try {
const { screenApi } = await import("@/lib/api/screen");
const layout = await screenApi.getLayout(config.targetScreenId);
// 레이아웃에서 채번 규칙이 설정된 컴포넌트 찾기
const findNumberingRules = (components: any[]): void => {
for (const comp of components) {
const compConfig = comp.componentConfig || {};
// text-input 컴포넌트의 채번 규칙 확인
if (compConfig.autoGeneration?.type === "numbering_rule" && compConfig.autoGeneration?.options?.numberingRuleId) {
const columnName = compConfig.columnName || comp.columnName;
if (columnName) {
screenNumberingRules[columnName] = compConfig.autoGeneration.options.numberingRuleId;
console.log(`📋 화면 설정에서 채번 규칙 발견: ${columnName}${compConfig.autoGeneration.options.numberingRuleId}`);
}
}
// 중첩된 컴포넌트 확인
if (comp.children && Array.isArray(comp.children)) {
findNumberingRules(comp.children);
}
}
};
if (layout?.components) {
findNumberingRules(layout.components);
}
console.log("📋 화면 설정에서 찾은 채번 규칙:", screenNumberingRules);
} catch (error) {
console.warn("⚠️ 화면 레이아웃 조회 실패:", error);
}
}
// 품목코드 필드를 찾아서 무조건 공백으로 초기화 // 품목코드 필드를 찾아서 무조건 공백으로 초기화
let resetFieldName = ""; let resetFieldName = "";
for (const field of itemCodeFields) { for (const field of itemCodeFields) {
if (copiedData[field] !== undefined) { if (copiedData[field] !== undefined) {
const originalValue = copiedData[field]; const originalValue = copiedData[field];
const ruleIdKey = `${field}_numberingRuleId`; const ruleIdKey = `${field}_numberingRuleId`;
const hasNumberingRule =
rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; // 1순위: 원본 데이터에서 채번 규칙 ID 확인
// 2순위: 화면 설정에서 채번 규칙 ID 확인
const numberingRuleId = rowData[ruleIdKey] || screenNumberingRules[field];
const hasNumberingRule = numberingRuleId !== undefined && numberingRuleId !== null && numberingRuleId !== "";
// 품목코드를 무조건 공백으로 초기화 // 품목코드를 무조건 공백으로 초기화
copiedData[field] = ""; copiedData[field] = "";
// 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성) // 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성)
if (hasNumberingRule) { if (hasNumberingRule) {
copiedData[ruleIdKey] = rowData[ruleIdKey]; copiedData[ruleIdKey] = numberingRuleId;
console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`); console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`);
console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`); console.log(`📋 채번 규칙 ID 설정: ${ruleIdKey} = ${numberingRuleId}`);
} else { } else {
console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`); console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`);
} }
@ -3317,9 +3387,9 @@ export class ButtonActionExecutor {
switch (editMode) { switch (editMode) {
case "modal": case "modal":
// 모달로 복사 폼 열기 (편집 모달 재사용) // 모달로 복사 폼 열기 (편집 모달 재사용, INSERT 모드로)
console.log("📋 모달로 복사 폼 열기"); console.log("📋 모달로 복사 폼 열기 (INSERT 모드)");
await this.openEditModal(config, rowData, context); await this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true
break; break;
case "navigate": case "navigate":
@ -3330,8 +3400,8 @@ export class ButtonActionExecutor {
default: default:
// 기본값: 모달 // 기본값: 모달
console.log("📋 기본 모달로 복사 폼 열기"); console.log("📋 기본 모달로 복사 폼 열기 (INSERT 모드)");
this.openEditModal(config, rowData, context); this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ openCopyForm 실행 중 오류:", error); console.error("❌ openCopyForm 실행 중 오류:", error);
@ -4934,26 +5004,35 @@ export class ButtonActionExecutor {
const { oldValue, newValue } = confirmed; const { oldValue, newValue } = confirmed;
// 미리보기 표시 (옵션) // 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색)
if (config.mergeShowPreview !== false) { if (config.mergeShowPreview !== false) {
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const previewResponse = await apiClient.post("/code-merge/preview", { toast.loading("영향받는 데이터 검색 중...", { duration: Infinity });
columnName,
const previewResponse = await apiClient.post("/code-merge/preview-by-value", {
oldValue, oldValue,
}); });
toast.dismiss();
if (previewResponse.data.success) { if (previewResponse.data.success) {
const preview = previewResponse.data.data; const preview = previewResponse.data.data;
const totalRows = preview.totalAffectedRows; const totalRows = preview.totalAffectedRows;
// 상세 정보 생성
const detailList = preview.preview
.map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}`)
.join("\n");
const confirmMerge = confirm( const confirmMerge = confirm(
"⚠️ 코드 병합 확인\n\n" + "코드 병합 확인\n\n" +
`${oldValue}${newValue}\n\n` + `${oldValue}${newValue}\n\n` +
"영향받는 데이터:\n" + "영향받는 데이터:\n" +
`- 테이블 수: ${preview.preview.length}\n` + `- 테이블/컬럼 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` + `- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + (preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") +
"모든 테이블에서 해당 값이 변경됩니다.\n\n" +
"계속하시겠습니까?", "계속하시겠습니까?",
); );
@ -4963,13 +5042,12 @@ export class ButtonActionExecutor {
} }
} }
// 병합 실행 // 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼)
toast.loading("코드 병합 중...", { duration: Infinity }); toast.loading("코드 병합 중...", { duration: Infinity });
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post("/code-merge/merge-all-tables", { const response = await apiClient.post("/code-merge/merge-by-value", {
columnName,
oldValue, oldValue,
newValue, newValue,
}); });
@ -4978,9 +5056,17 @@ export class ButtonActionExecutor {
if (response.data.success) { if (response.data.success) {
const data = response.data.data; const data = response.data.data;
// 변경된 테이블/컬럼 목록 생성
const changedList = data.affectedData
.map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}`)
.join(", ");
toast.success( toast.success(
"코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, `코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`,
); );
console.log("코드 병합 결과:", data.affectedData);
// 화면 새로고침 // 화면 새로고침
context.onRefresh?.(); context.onRefresh?.();

View File

@ -59,6 +59,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.5.1", "docx": "^9.5.1",
"docx-preview": "^0.3.6", "docx-preview": "^0.3.6",
"exceljs": "^4.4.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"isomorphic-dompurify": "^2.28.0", "isomorphic-dompurify": "^2.28.0",
@ -542,6 +543,47 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@fast-csv/format": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz",
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.isboolean": "^3.0.3",
"lodash.isequal": "^4.5.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0"
}
},
"node_modules/@fast-csv/format/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@fast-csv/parse": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz",
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.1",
"lodash.escaperegexp": "^4.1.2",
"lodash.groupby": "^4.6.0",
"lodash.isfunction": "^3.0.9",
"lodash.isnil": "^4.0.0",
"lodash.isundefined": "^3.0.1",
"lodash.uniq": "^4.5.0"
}
},
"node_modules/@fast-csv/parse/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.3", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@ -6963,6 +7005,59 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/archiver": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
"buffer-crc32": "^0.2.1",
"readable-stream": "^3.6.0",
"readdir-glob": "^1.1.2",
"tar-stream": "^2.2.0",
"zip-stream": "^4.1.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/archiver-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"license": "MIT",
"dependencies": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
"lazystream": "^1.0.0",
"lodash.defaults": "^4.2.0",
"lodash.difference": "^4.5.0",
"lodash.flatten": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.union": "^4.6.0",
"normalize-path": "^3.0.0",
"readable-stream": "^2.0.0"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/archiver/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -7158,6 +7253,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/async-function": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@ -7225,7 +7326,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-arraybuffer": { "node_modules/base64-arraybuffer": {
@ -7266,6 +7366,15 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/bignumber.js": { "node_modules/bignumber.js": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@ -7275,6 +7384,68 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/bluebird": { "node_modules/bluebird": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
@ -7285,7 +7456,6 @@
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@ -7329,6 +7499,32 @@
"ieee754": "^1.2.1" "ieee754": "^1.2.1"
} }
}, },
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-indexof-polyfill": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/c12": { "node_modules/c12": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@ -7501,6 +7697,18 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -7665,11 +7873,39 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/compress-commons": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/compress-commons/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concaveman": { "node_modules/concaveman": {
@ -7731,6 +7967,33 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/crc32-stream": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/crc32-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/crelt": { "node_modules/crelt": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@ -8323,6 +8586,12 @@
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -8605,6 +8874,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/earcut": { "node_modules/earcut": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
@ -8639,6 +8917,15 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -9338,6 +9625,61 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
"license": "MIT",
"dependencies": {
"archiver": "^5.0.0",
"dayjs": "^1.8.34",
"fast-csv": "^4.3.1",
"jszip": "^3.10.1",
"readable-stream": "^3.6.0",
"saxes": "^5.0.1",
"tmp": "^0.2.0",
"unzipper": "^0.10.11",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=8.3.0"
}
},
"node_modules/exceljs/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/exceljs/node_modules/saxes": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/exceljs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/exit-on-epipe": { "node_modules/exit-on-epipe": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
@ -9377,6 +9719,19 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/fast-csv": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
"license": "MIT",
"dependencies": {
"@fast-csv/format": "4.3.5",
"@fast-csv/parse": "4.3.6"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -9586,6 +9941,34 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fstream": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.2",
"inherits": "~2.0.0",
"mkdirp": ">=0.5 0",
"rimraf": "2"
},
"engines": {
"node": ">=0.6"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -9773,6 +10156,27 @@
"giget": "dist/cli.mjs" "giget": "dist/cli.mjs"
} }
}, },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -9847,7 +10251,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": { "node_modules/graphemer": {
@ -10121,6 +10524,17 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -10843,6 +11257,18 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^2.0.5"
},
"engines": {
"node": ">= 0.6.3"
}
},
"node_modules/leaflet": { "node_modules/leaflet": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@ -11142,6 +11568,12 @@
"uc.micro": "^2.0.0" "uc.micro": "^2.0.0"
} }
}, },
"node_modules/listenercount": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
"license": "ISC"
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -11158,6 +11590,73 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"license": "MIT"
},
"node_modules/lodash.groupby": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isfunction": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
"license": "MIT"
},
"node_modules/lodash.isnil": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isundefined": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -11165,6 +11664,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -11386,7 +11897,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@ -11399,12 +11909,23 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -11557,6 +12078,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
@ -11707,6 +12237,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/option": { "node_modules/option": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
@ -12829,6 +13368,36 @@
"util-deprecate": "~1.0.1" "util-deprecate": "~1.0.1"
} }
}, },
"node_modules/readdir-glob": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.1.0"
}
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -13086,6 +13655,19 @@
"node": ">= 0.8.15" "node": ">= 0.8.15"
} }
}, },
"node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/robust-predicates": { "node_modules/robust-predicates": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
@ -13891,6 +14473,36 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/text-segmentation": { "node_modules/text-segmentation": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@ -14032,6 +14644,15 @@
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -14107,6 +14728,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/troika-three-text": { "node_modules/troika-three-text": {
"version": "0.52.4", "version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
@ -14402,6 +15032,24 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1" "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
} }
}, },
"node_modules/unzipper": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz",
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
"license": "MIT",
"dependencies": {
"big-integer": "^1.6.17",
"binary": "~0.3.0",
"bluebird": "~3.4.1",
"buffer-indexof-polyfill": "~1.0.0",
"duplexer2": "~0.1.4",
"fstream": "^1.0.12",
"graceful-fs": "^4.2.2",
"listenercount": "~1.0.1",
"readable-stream": "~2.3.6",
"setimmediate": "~1.0.4"
}
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -14754,6 +15402,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.3", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
@ -14974,6 +15628,55 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/zip-stream": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/zip-stream/node_modules/archiver-utils": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"license": "MIT",
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",
"lazystream": "^1.0.0",
"lodash.defaults": "^4.2.0",
"lodash.difference": "^4.5.0",
"lodash.flatten": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.union": "^4.6.0",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/zip-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",

View File

@ -67,6 +67,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.5.1", "docx": "^9.5.1",
"docx-preview": "^0.3.6", "docx-preview": "^0.3.6",
"exceljs": "^4.4.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"isomorphic-dompurify": "^2.28.0", "isomorphic-dompurify": "^2.28.0",

View File

@ -365,6 +365,8 @@ export interface EntityTypeConfig {
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ') separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
// UI 모드 // UI 모드
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo" uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
// 다중 선택
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
} }
/** /**