fix: SelectedItemsDetailInput 저장 시 NULL 레코드 생성 버그 수정
This commit is contained in:
commit
910d070055
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
|
|||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode });
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
mergeCodeAllTables,
|
||||
getTablesWithColumn,
|
||||
previewCodeMerge,
|
||||
mergeCodeByValue,
|
||||
previewMergeCodeByValue,
|
||||
} from "../controllers/codeMergeController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -13,7 +15,7 @@ router.use(authenticateToken);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/merge-all-tables
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
||||
* Body: { columnName, oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||
|
|
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/preview
|
||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
||||
* Body: { columnName, oldValue }
|
||||
*/
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -38,6 +39,15 @@ router.use(authenticateToken);
|
|||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1306,6 +1306,41 @@ export class TableManagementService {
|
|||
paramCount: number;
|
||||
} | null> {
|
||||
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("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(
|
||||
|
|
@ -4630,4 +4665,101 @@ export class TableManagementService {
|
|||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,17 +124,17 @@ export function PopProductionPanel({
|
|||
return (
|
||||
<div className="pop-step-form-section">
|
||||
<h4 className="pop-step-form-title">점검 항목</h4>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-sm)" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
|
||||
<input type="checkbox" disabled={isCompleted} />
|
||||
<div className="pop-checkbox-list">
|
||||
<label className="pop-checkbox-label">
|
||||
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
|
||||
<span>장비 상태 확인</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
|
||||
<input type="checkbox" disabled={isCompleted} />
|
||||
<label className="pop-checkbox-label">
|
||||
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
|
||||
<span>안전 장비 착용</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
|
||||
<input type="checkbox" disabled={isCompleted} />
|
||||
<label className="pop-checkbox-label">
|
||||
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
|
||||
<span>작업 환경 확인</span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -177,14 +177,12 @@ export function PopProductionPanel({
|
|||
<span className="pop-badge pop-badge-primary">{workOrder.processName}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
|
||||
<div style={{ fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
|
||||
<span>{formatDate(currentDateTime)}</span>
|
||||
<span style={{ marginLeft: "var(--spacing-sm)", color: "rgb(var(--neon-cyan))", fontWeight: 700 }}>
|
||||
{formatTime(currentDateTime)}
|
||||
</span>
|
||||
<div className="pop-panel-datetime">
|
||||
<span className="pop-panel-date">{formatDate(currentDateTime)}</span>
|
||||
<span className="pop-panel-time">{formatTime(currentDateTime)}</span>
|
||||
</div>
|
||||
<button className="pop-icon-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -142,9 +142,10 @@
|
|||
line-height: 1.5;
|
||||
color: rgb(var(--text-primary));
|
||||
background: rgb(var(--bg-deepest));
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -197,8 +198,7 @@
|
|||
.pop-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
min-height: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
padding-bottom: calc(60px + var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
|
@ -209,7 +209,9 @@
|
|||
border: 1px solid rgb(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -227,8 +229,8 @@
|
|||
.pop-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pop-top-bar.row-1 {
|
||||
|
|
@ -243,8 +245,8 @@
|
|||
.pop-datetime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-xs);
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.pop-date {
|
||||
|
|
@ -254,7 +256,7 @@
|
|||
.pop-time {
|
||||
color: rgb(var(--neon-cyan));
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* 생산유형 버튼 */
|
||||
|
|
@ -264,12 +266,12 @@
|
|||
}
|
||||
|
||||
.pop-type-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
padding: 4px var(--spacing-sm);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--text-muted));
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
|
@ -291,12 +293,13 @@
|
|||
.pop-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
gap: 2px;
|
||||
padding: 4px var(--spacing-sm);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--text-secondary));
|
||||
font-size: var(--text-2xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
|
@ -343,6 +346,9 @@
|
|||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.pop-status-tab {
|
||||
|
|
@ -395,7 +401,7 @@
|
|||
/* ==================== 메인 콘텐츠 ==================== */
|
||||
.pop-main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pop-work-list {
|
||||
|
|
@ -675,6 +681,7 @@
|
|||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--text-2xs);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
@ -851,10 +858,10 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
|
@ -1105,8 +1112,9 @@
|
|||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-width: none;
|
||||
background: rgb(var(--bg-primary));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1133,11 +1141,29 @@
|
|||
}
|
||||
|
||||
.pop-slide-panel-title {
|
||||
font-size: var(--text-lg);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
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 {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
|
|
@ -1174,7 +1200,7 @@
|
|||
|
||||
.pop-work-steps-header {
|
||||
padding: var(--spacing-md);
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: rgb(var(--text-muted));
|
||||
border-bottom: 1px solid rgb(var(--border));
|
||||
|
|
@ -1239,7 +1265,7 @@
|
|||
}
|
||||
|
||||
.pop-work-step-name {
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: rgb(var(--text-primary));
|
||||
white-space: nowrap;
|
||||
|
|
@ -1248,13 +1274,13 @@
|
|||
}
|
||||
|
||||
.pop-work-step-time {
|
||||
font-size: var(--text-2xs);
|
||||
font-size: var(--text-xs);
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
.pop-work-step-status {
|
||||
font-size: var(--text-2xs);
|
||||
padding: 2px 6px;
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
|
|
@ -1288,14 +1314,14 @@
|
|||
}
|
||||
|
||||
.pop-step-title {
|
||||
font-size: var(--text-lg);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: rgb(var(--text-primary));
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.pop-step-description {
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-base);
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
|
|
@ -1311,20 +1337,20 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--text-secondary));
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pop-time-control-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pop-time-control-btn:hover:not(:disabled) {
|
||||
|
|
@ -1358,7 +1384,7 @@
|
|||
}
|
||||
|
||||
.pop-step-form-title {
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: rgb(var(--text-primary));
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
|
@ -1374,20 +1400,53 @@
|
|||
|
||||
.pop-form-label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: rgb(var(--text-secondary));
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(var(--bg-tertiary), 0.5);
|
||||
border: 1px solid rgb(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
color: rgb(var(--text-primary));
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-base);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
|
|
@ -1441,12 +1500,12 @@
|
|||
}
|
||||
|
||||
.pop-work-order-info-item .label {
|
||||
font-size: var(--text-2xs);
|
||||
font-size: var(--text-xs);
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
.pop-work-order-info-item .value {
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
|
@ -1634,9 +1693,9 @@
|
|||
|
||||
/* 헤더 인라인 테마 토글 버튼 */
|
||||
.pop-theme-toggle-inline {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: var(--spacing-sm);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-left: var(--spacing-xs);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgb(var(--border));
|
||||
|
|
@ -1656,8 +1715,8 @@
|
|||
}
|
||||
|
||||
.pop-theme-toggle-inline svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ==================== 아이콘 버튼 ==================== */
|
||||
|
|
@ -1722,7 +1781,144 @@
|
|||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
/* 상태 탭 sticky 위치 조정 - 데스크톱 */
|
||||
.pop-status-tabs {
|
||||
top: 110px;
|
||||
}
|
||||
|
||||
.pop-work-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
|
|
@ -1741,11 +1942,217 @@
|
|||
}
|
||||
|
||||
.pop-slide-panel-content {
|
||||
max-width: 700px;
|
||||
max-width: none;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -761,10 +761,74 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// INSERT 모드
|
||||
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({
|
||||
screenId: modalState.screenId!,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
data: formData,
|
||||
data: dataToSave,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
|
|
|
|||
|
|
@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
|
||||
case "entity": {
|
||||
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
|
||||
const widget = comp as WidgetComponent;
|
||||
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
|
||||
|
||||
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
|
||||
componentId: widget.id,
|
||||
widgetType: widget.widgetType,
|
||||
config,
|
||||
appliedSettings: {
|
||||
entityName: config?.entityName,
|
||||
displayField: config?.displayField,
|
||||
valueField: config?.valueField,
|
||||
multiple: config?.multiple,
|
||||
defaultValue: config?.defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
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>,
|
||||
return applyStyles(
|
||||
<DynamicWebTypeRenderer
|
||||
webType="entity"
|
||||
config={widget.webTypeConfig}
|
||||
props={{
|
||||
component: widget,
|
||||
value: currentValue,
|
||||
onChange: (value: any) => updateFormData(fieldName, value),
|
||||
onFormDataChange: updateFormData,
|
||||
formData: formData,
|
||||
readonly: readonly,
|
||||
required: required,
|
||||
placeholder: widget.placeholder || "엔티티를 선택하세요",
|
||||
isInteractive: true,
|
||||
className: "w-full h-full",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, Database, Link, X, Plus } from "lucide-react";
|
||||
import { EntityTypeConfig } from "@/types/screen";
|
||||
|
|
@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
placeholder: "",
|
||||
displayFormat: "simple",
|
||||
separator: " - ",
|
||||
multiple: false, // 다중 선택
|
||||
uiMode: "select", // UI 모드: select, combo, modal, autocomplete
|
||||
...config,
|
||||
};
|
||||
|
||||
|
|
@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
placeholder: safeConfig.placeholder,
|
||||
displayFormat: safeConfig.displayFormat,
|
||||
separator: safeConfig.separator,
|
||||
multiple: safeConfig.multiple,
|
||||
uiMode: safeConfig.uiMode,
|
||||
});
|
||||
|
||||
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
|
||||
|
|
@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
placeholder: safeConfig.placeholder,
|
||||
displayFormat: safeConfig.displayFormat,
|
||||
separator: safeConfig.separator,
|
||||
multiple: safeConfig.multiple,
|
||||
uiMode: safeConfig.uiMode,
|
||||
});
|
||||
}, [
|
||||
safeConfig.referenceTable,
|
||||
|
|
@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
safeConfig.placeholder,
|
||||
safeConfig.displayFormat,
|
||||
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) => {
|
||||
// 로컬 상태 즉시 업데이트
|
||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||
|
|
@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
/>
|
||||
</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">
|
||||
<Label className="text-sm font-medium">데이터 필터</Label>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export function EntitySearchInputComponent({
|
|||
parentValue: parentValueProp,
|
||||
parentFieldId,
|
||||
formData,
|
||||
// 🆕 추가 props
|
||||
// 다중선택 props
|
||||
multiple: multipleProp,
|
||||
// 추가 props
|
||||
component,
|
||||
isInteractive,
|
||||
onFormDataChange,
|
||||
|
|
@ -49,8 +51,11 @@ export function EntitySearchInputComponent({
|
|||
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||
|
||||
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
|
||||
const config = component?.componentConfig || {};
|
||||
// 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서)
|
||||
const config = component?.componentConfig || component?.webTypeConfig || {};
|
||||
const isMultiple = multipleProp ?? config.multiple ?? false;
|
||||
|
||||
// 연쇄관계 설정 추출
|
||||
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
||||
// cascadingParentField: ConfigPanel에서 저장되는 필드명
|
||||
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
|
||||
|
|
@ -68,11 +73,27 @@ export function EntitySearchInputComponent({
|
|||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||
|
||||
// 다중선택 상태 (콤마로 구분된 값들)
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const [selectedDataList, setSelectedDataList] = useState<EntitySearchResult[]>([]);
|
||||
|
||||
// 연쇄관계 상태
|
||||
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
|
||||
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
|
||||
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에서 추출) - 자식 역할일 때만 필요
|
||||
const parentValue = isChildRole
|
||||
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
|
||||
|
|
@ -249,23 +270,75 @@ export function EntitySearchInputComponent({
|
|||
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
|
||||
|
||||
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
||||
setSelectedData(fullData);
|
||||
setDisplayValue(fullData[displayField] || "");
|
||||
onChange?.(newValue, fullData);
|
||||
if (isMultiple) {
|
||||
// 다중선택 모드
|
||||
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) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
||||
onFormDataChange(component.columnName, joinedValue || null);
|
||||
console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
if (isMultiple) {
|
||||
setSelectedValues([]);
|
||||
setSelectedDataList([]);
|
||||
onChange?.(null, []);
|
||||
} else {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
}
|
||||
|
||||
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
|
||||
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||
onFormDataChange(component.columnName, null);
|
||||
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
|
||||
|
|
@ -280,7 +353,10 @@ export function EntitySearchInputComponent({
|
|||
|
||||
const handleSelectOption = (option: EntitySearchResult) => {
|
||||
handleSelect(option[valueField], option);
|
||||
setSelectOpen(false);
|
||||
// 다중선택이 아닌 경우에만 드롭다운 닫기
|
||||
if (!isMultiple) {
|
||||
setSelectOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
|
||||
|
|
@ -289,6 +365,111 @@ export function EntitySearchInputComponent({
|
|||
|
||||
// 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 (
|
||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||
{/* 라벨 렌더링 */}
|
||||
|
|
@ -366,6 +547,95 @@ export function EntitySearchInputComponent({
|
|||
}
|
||||
|
||||
// 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 (
|
||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||
{/* 라벨 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({
|
|||
</p>
|
||||
</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">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -11,7 +11,9 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { EntitySearchResult } from "./types";
|
||||
|
||||
|
|
@ -26,6 +28,9 @@ interface EntitySearchModalProps {
|
|||
modalTitle?: string;
|
||||
modalColumns?: string[];
|
||||
onSelect: (value: any, fullData: EntitySearchResult) => void;
|
||||
// 다중선택 관련
|
||||
multiple?: boolean;
|
||||
selectedValues?: string[]; // 이미 선택된 값들
|
||||
}
|
||||
|
||||
export function EntitySearchModal({
|
||||
|
|
@ -39,6 +44,8 @@ export function EntitySearchModal({
|
|||
modalTitle = "검색",
|
||||
modalColumns = [],
|
||||
onSelect,
|
||||
multiple = false,
|
||||
selectedValues = [],
|
||||
}: EntitySearchModalProps) {
|
||||
const [localSearchText, setLocalSearchText] = useState("");
|
||||
const {
|
||||
|
|
@ -71,7 +78,15 @@ export function EntitySearchModal({
|
|||
|
||||
const handleSelect = (item: EntitySearchResult) => {
|
||||
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="overflow-x-auto">
|
||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
{/* 다중선택 시 체크박스 컬럼 */}
|
||||
{multiple && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
선택
|
||||
</th>
|
||||
)}
|
||||
{displayColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
|
|
@ -135,54 +156,72 @@ export function EntitySearchModal({
|
|||
{col}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
선택
|
||||
</th>
|
||||
{!multiple && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
선택
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && results.length === 0 ? (
|
||||
<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" />
|
||||
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : results.length === 0 ? (
|
||||
<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>
|
||||
</tr>
|
||||
) : (
|
||||
results.map((item, index) => {
|
||||
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
|
||||
const isSelected = isItemSelected(item);
|
||||
return (
|
||||
<tr
|
||||
<tr
|
||||
key={uniqueKey}
|
||||
className="border-t hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
className={cn(
|
||||
"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) => (
|
||||
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(item);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
{!multiple && (
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(item);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
|
|
@ -211,12 +250,18 @@ export function EntitySearchModal({
|
|||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{/* 다중선택 시 선택된 항목 수 표시 */}
|
||||
{multiple && selectedValues.length > 0 && (
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{selectedValues.length}개 항목 선택됨
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
{multiple ? "완료" : "취소"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export interface EntitySearchInputConfig {
|
|||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
|
||||
// 다중 선택 설정
|
||||
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||
|
||||
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
|
||||
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
||||
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config";
|
|||
|
||||
// 컴포넌트 내보내기
|
||||
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper";
|
||||
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
|
||||
export { EntitySearchModal } from "./EntitySearchModal";
|
||||
export { useEntitySearch } from "./useEntitySearch";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ export interface EntitySearchInputProps {
|
|||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
// 다중선택
|
||||
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||
|
||||
// 필터링
|
||||
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
||||
companyCode?: string; // 멀티테넌시
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
|||
// 🆕 연관 데이터 버튼 컴포넌트
|
||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||
|
||||
// 🆕 피벗 그리드 컴포넌트
|
||||
import "./pivot-grid/PivotGridRenderer"; // 다차원 데이터 분석 피벗 테이블
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,644 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 메인 컴포넌트
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridProps,
|
||||
PivotResult,
|
||||
PivotFieldConfig,
|
||||
PivotCellData,
|
||||
PivotFlatRow,
|
||||
PivotCellValue,
|
||||
PivotGridState,
|
||||
} from "./types";
|
||||
import { processPivotData, pathToKey } from "./utils/pivotEngine";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} 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;
|
||||
}
|
||||
|
||||
const DataCell: React.FC<DataCellProps> = ({
|
||||
values,
|
||||
isTotal = false,
|
||||
onClick,
|
||||
}) => {
|
||||
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"
|
||||
)}
|
||||
>
|
||||
-
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// 단일 데이터 필드인 경우
|
||||
if (values.length === 1) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||
isTotal && "bg-primary/5 font-medium",
|
||||
onClick && "cursor-pointer hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{values[0].formattedValue}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// 다중 데이터 필드인 경우
|
||||
return (
|
||||
<>
|
||||
{values.map((val, idx) => (
|
||||
<td
|
||||
key={idx}
|
||||
className={cn(
|
||||
"border-r border-b border-border",
|
||||
"px-2 py-1.5 text-right text-sm tabular-nums",
|
||||
isTotal && "bg-primary/5 font-medium",
|
||||
onClick && "cursor-pointer hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{val.formattedValue}
|
||||
</td>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||
title,
|
||||
fields = [],
|
||||
totals = {
|
||||
showRowGrandTotals: true,
|
||||
showColumnGrandTotals: true,
|
||||
showRowTotals: true,
|
||||
showColumnTotals: true,
|
||||
},
|
||||
style = {
|
||||
theme: "default",
|
||||
headerStyle: "default",
|
||||
cellPadding: "normal",
|
||||
borderStyle: "light",
|
||||
alternateRowColors: true,
|
||||
highlightTotals: true,
|
||||
},
|
||||
allowExpandAll = true,
|
||||
height = "auto",
|
||||
maxHeight,
|
||||
exportConfig,
|
||||
data: externalData,
|
||||
onCellClick,
|
||||
onExpandChange,
|
||||
}) => {
|
||||
// ==================== 상태 ====================
|
||||
|
||||
const [pivotState, setPivotState] = useState<PivotGridState>({
|
||||
expandedRowPaths: [],
|
||||
expandedColumnPaths: [],
|
||||
sortConfig: null,
|
||||
filterConfig: {},
|
||||
});
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// 데이터
|
||||
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 pivotResult = useMemo<PivotResult | null>(() => {
|
||||
if (!data || data.length === 0 || fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return processPivotData(
|
||||
data,
|
||||
fields,
|
||||
pivotState.expandedRowPaths,
|
||||
pivotState.expandedColumnPaths
|
||||
);
|
||||
}, [data, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
|
||||
|
||||
// ==================== 이벤트 핸들러 ====================
|
||||
|
||||
// 행 확장/축소
|
||||
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]
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// ==================== 렌더링 ====================
|
||||
|
||||
// 빈 상태
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 필드 미설정
|
||||
if (fields.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"
|
||||
)}
|
||||
>
|
||||
<Settings className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">필드가 설정되지 않았습니다</p>
|
||||
<p className="text-xs mt-1">
|
||||
행, 열, 데이터 영역에 필드를 배치해주세요
|
||||
</p>
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{/* 헤더 툴바 */}
|
||||
<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">
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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={() => 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) || [];
|
||||
|
||||
return (
|
||||
<DataCell
|
||||
key={colIdx}
|
||||
values={values}
|
||||
onClick={
|
||||
onCellClick
|
||||
? () => handleCellClick(row.path, col.path, values)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 행 총계 */}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotGridComponent;
|
||||
|
|
@ -0,0 +1,751 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PivotGrid 설정 패널
|
||||
* 화면 관리에서 PivotGrid 컴포넌트를 설정하는 UI
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridComponentConfig,
|
||||
PivotFieldConfig,
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
DateGroupInterval,
|
||||
FieldDataType,
|
||||
} from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Settings2,
|
||||
Rows,
|
||||
Columns,
|
||||
Database,
|
||||
Filter,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// ==================== 타입 ====================
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_comment?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
column_comment?: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface PivotGridConfigPanelProps {
|
||||
config: PivotGridComponentConfig;
|
||||
onChange: (config: PivotGridComponentConfig) => void;
|
||||
}
|
||||
|
||||
// ==================== 유틸리티 ====================
|
||||
|
||||
const AREA_LABELS: Record<PivotAreaType, { label: string; icon: React.ReactNode }> = {
|
||||
row: { label: "행 영역", icon: <Rows className="h-4 w-4" /> },
|
||||
column: { label: "열 영역", icon: <Columns className="h-4 w-4" /> },
|
||||
data: { label: "데이터 영역", icon: <Database className="h-4 w-4" /> },
|
||||
filter: { label: "필터 영역", icon: <Filter className="h-4 w-4" /> },
|
||||
};
|
||||
|
||||
const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [
|
||||
{ value: "sum", label: "합계" },
|
||||
{ value: "count", label: "개수" },
|
||||
{ value: "avg", label: "평균" },
|
||||
{ value: "min", label: "최소" },
|
||||
{ value: "max", label: "최대" },
|
||||
{ value: "countDistinct", label: "고유값 개수" },
|
||||
];
|
||||
|
||||
const DATE_GROUP_OPTIONS: { value: DateGroupInterval; label: string }[] = [
|
||||
{ value: "year", label: "연도" },
|
||||
{ value: "quarter", label: "분기" },
|
||||
{ value: "month", label: "월" },
|
||||
{ value: "week", label: "주" },
|
||||
{ value: "day", label: "일" },
|
||||
];
|
||||
|
||||
const DATA_TYPE_OPTIONS: { value: FieldDataType; label: string }[] = [
|
||||
{ value: "string", label: "문자열" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "boolean", label: "부울" },
|
||||
];
|
||||
|
||||
// DB 타입을 FieldDataType으로 변환
|
||||
function mapDbTypeToFieldType(dbType: string): FieldDataType {
|
||||
const type = dbType.toLowerCase();
|
||||
if (
|
||||
type.includes("int") ||
|
||||
type.includes("numeric") ||
|
||||
type.includes("decimal") ||
|
||||
type.includes("float") ||
|
||||
type.includes("double") ||
|
||||
type.includes("real")
|
||||
) {
|
||||
return "number";
|
||||
}
|
||||
if (
|
||||
type.includes("date") ||
|
||||
type.includes("time") ||
|
||||
type.includes("timestamp")
|
||||
) {
|
||||
return "date";
|
||||
}
|
||||
if (type.includes("bool")) {
|
||||
return "boolean";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
// ==================== 필드 설정 컴포넌트 ====================
|
||||
|
||||
interface FieldConfigItemProps {
|
||||
field: PivotFieldConfig;
|
||||
index: number;
|
||||
onChange: (field: PivotFieldConfig) => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
const FieldConfigItem: React.FC<FieldConfigItemProps> = ({
|
||||
field,
|
||||
index,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-2 rounded border border-border bg-background">
|
||||
{/* 드래그 핸들 & 순서 버튼 */}
|
||||
<div className="flex flex-col items-center gap-0.5 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 설정 */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* 필드명 & 라벨 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">필드명</Label>
|
||||
<Input
|
||||
value={field.field}
|
||||
onChange={(e) => onChange({ ...field, field: e.target.value })}
|
||||
placeholder="column_name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">표시 라벨</Label>
|
||||
<Input
|
||||
value={field.caption}
|
||||
onChange={(e) => onChange({ ...field, caption: e.target.value })}
|
||||
placeholder="표시명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 타입 & 집계 함수 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">데이터 타입</Label>
|
||||
<Select
|
||||
value={field.dataType || "string"}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...field, dataType: v as FieldDataType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{field.area === "data" && (
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">집계 함수</Label>
|
||||
<Select
|
||||
value={field.summaryType || "sum"}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...field, summaryType: v as AggregationType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGGREGATION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.dataType === "date" &&
|
||||
(field.area === "row" || field.area === "column") && (
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">그룹 단위</Label>
|
||||
<Select
|
||||
value={field.groupInterval || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...field,
|
||||
groupInterval:
|
||||
v === "__none__" ? undefined : (v as DateGroupInterval),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">원본값</SelectItem>
|
||||
{DATE_GROUP_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 영역별 필드 목록 ====================
|
||||
|
||||
interface AreaFieldListProps {
|
||||
area: PivotAreaType;
|
||||
fields: PivotFieldConfig[];
|
||||
allColumns: ColumnInfo[];
|
||||
onFieldsChange: (fields: PivotFieldConfig[]) => void;
|
||||
}
|
||||
|
||||
const AreaFieldList: React.FC<AreaFieldListProps> = ({
|
||||
area,
|
||||
fields,
|
||||
allColumns,
|
||||
onFieldsChange,
|
||||
}) => {
|
||||
const areaFields = fields.filter((f) => f.area === area);
|
||||
const { label, icon } = AREA_LABELS[area];
|
||||
|
||||
const handleAddField = () => {
|
||||
const newField: PivotFieldConfig = {
|
||||
field: "",
|
||||
caption: "",
|
||||
area,
|
||||
areaIndex: areaFields.length,
|
||||
dataType: "string",
|
||||
visible: true,
|
||||
};
|
||||
if (area === "data") {
|
||||
newField.summaryType = "sum";
|
||||
}
|
||||
onFieldsChange([...fields, newField]);
|
||||
};
|
||||
|
||||
const handleAddFromColumn = (column: ColumnInfo) => {
|
||||
const dataType = mapDbTypeToFieldType(column.data_type);
|
||||
const newField: PivotFieldConfig = {
|
||||
field: column.column_name,
|
||||
caption: column.column_comment || column.column_name,
|
||||
area,
|
||||
areaIndex: areaFields.length,
|
||||
dataType,
|
||||
visible: true,
|
||||
};
|
||||
if (area === "data") {
|
||||
newField.summaryType = "sum";
|
||||
}
|
||||
onFieldsChange([...fields, newField]);
|
||||
};
|
||||
|
||||
const handleFieldChange = (index: number, updatedField: PivotFieldConfig) => {
|
||||
const newFields = [...fields];
|
||||
const globalIndex = fields.findIndex(
|
||||
(f) => f.area === area && f.areaIndex === index
|
||||
);
|
||||
if (globalIndex >= 0) {
|
||||
newFields[globalIndex] = updatedField;
|
||||
onFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
const newFields = fields.filter(
|
||||
(f) => !(f.area === area && f.areaIndex === index)
|
||||
);
|
||||
// 인덱스 재정렬
|
||||
let idx = 0;
|
||||
newFields.forEach((f) => {
|
||||
if (f.area === area) {
|
||||
f.areaIndex = idx++;
|
||||
}
|
||||
});
|
||||
onFieldsChange(newFields);
|
||||
};
|
||||
|
||||
const handleMoveField = (fromIndex: number, direction: "up" | "down") => {
|
||||
const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= areaFields.length) return;
|
||||
|
||||
const newAreaFields = [...areaFields];
|
||||
const [moved] = newAreaFields.splice(fromIndex, 1);
|
||||
newAreaFields.splice(toIndex, 0, moved);
|
||||
|
||||
// 인덱스 재정렬
|
||||
newAreaFields.forEach((f, idx) => {
|
||||
f.areaIndex = idx;
|
||||
});
|
||||
|
||||
// 전체 필드 업데이트
|
||||
const newFields = fields.filter((f) => f.area !== area);
|
||||
onFieldsChange([...newFields, ...newAreaFields]);
|
||||
};
|
||||
|
||||
// 이미 추가된 컬럼 제외
|
||||
const availableColumns = allColumns.filter(
|
||||
(col) => !fields.some((f) => f.field === col.column_name)
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem value={area}>
|
||||
<AccordionTrigger className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{areaFields.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pt-2">
|
||||
{/* 필드 목록 */}
|
||||
{areaFields
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0))
|
||||
.map((field, idx) => (
|
||||
<FieldConfigItem
|
||||
key={`${field.field}-${idx}`}
|
||||
field={field}
|
||||
index={field.areaIndex || idx}
|
||||
onChange={(f) => handleFieldChange(field.areaIndex || idx, f)}
|
||||
onRemove={() => handleRemoveField(field.areaIndex || idx)}
|
||||
onMoveUp={() => handleMoveField(idx, "up")}
|
||||
onMoveDown={() => handleMoveField(idx, "down")}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === areaFields.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 필드 추가 */}
|
||||
<div className="flex gap-2">
|
||||
<Select onValueChange={(v) => {
|
||||
const col = allColumns.find(c => c.column_name === v);
|
||||
if (col) handleAddFromColumn(col);
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.length === 0 ? (
|
||||
<SelectItem value="__none__" disabled>
|
||||
추가 가능한 컬럼이 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
availableColumns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.column_name}</span>
|
||||
{col.column_comment && (
|
||||
<span className="text-muted-foreground">
|
||||
({col.column_comment})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleAddField}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
직접 추가
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 메인 컴포넌트 ====================
|
||||
|
||||
export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await apiClient.get("/api/table-management/list");
|
||||
if (response.data.success) {
|
||||
setTables(response.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.dataSource?.tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/api/table-management/columns/${config.dataSource.tableName}`
|
||||
);
|
||||
if (response.data.success) {
|
||||
setColumns(response.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.dataSource?.tableName]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<PivotGridComponentConfig>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">데이터 소스</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Select
|
||||
value={config.dataSource?.tableName || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({
|
||||
dataSource: {
|
||||
...config.dataSource,
|
||||
type: "table",
|
||||
tableName: v === "__none__" ? undefined : v,
|
||||
},
|
||||
fields: [], // 테이블 변경 시 필드 초기화
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{table.table_name}</span>
|
||||
{table.table_comment && (
|
||||
<span className="text-muted-foreground">
|
||||
({table.table_comment})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 필드 설정 */}
|
||||
{config.dataSource?.tableName && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">필드 설정</Label>
|
||||
<Badge variant="outline">
|
||||
{columns.length}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loadingColumns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
컬럼 로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={["row", "column", "data"]}
|
||||
className="w-full"
|
||||
>
|
||||
{(["row", "column", "data", "filter"] as PivotAreaType[]).map(
|
||||
(area) => (
|
||||
<AreaFieldList
|
||||
key={area}
|
||||
area={area}
|
||||
fields={config.fields || []}
|
||||
allColumns={columns}
|
||||
onFieldsChange={(fields) => updateConfig({ fields })}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 표시 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">표시 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">행 총계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showRowGrandTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
totals: { ...config.totals, showRowGrandTotals: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">열 총계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnGrandTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
totals: { ...config.totals, showColumnGrandTotals: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">행 소계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showRowTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
totals: { ...config.totals, showRowTotals: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">열 소계</Label>
|
||||
<Switch
|
||||
checked={config.totals?.showColumnTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
totals: { ...config.totals, showColumnTotals: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">줄 교차 색상</Label>
|
||||
<Switch
|
||||
checked={config.style?.alternateRowColors !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
style: { ...config.style, alternateRowColors: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">총계 강조</Label>
|
||||
<Switch
|
||||
checked={config.style?.highlightTotals !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
style: { ...config.style, highlightTotals: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 기능 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">기능 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">전체 확장/축소 버튼</Label>
|
||||
<Switch
|
||||
checked={config.allowExpandAll !== false}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({ allowExpandAll: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">CSV 내보내기</Label>
|
||||
<Switch
|
||||
checked={config.exportConfig?.excel === true}
|
||||
onCheckedChange={(v) =>
|
||||
updateConfig({
|
||||
exportConfig: { ...config.exportConfig, excel: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 크기 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">크기 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
value={config.height || ""}
|
||||
onChange={(e) => updateConfig({ height: e.target.value })}
|
||||
placeholder="auto 또는 400px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">최대 높이</Label>
|
||||
<Input
|
||||
value={config.maxHeight || ""}
|
||||
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
|
||||
placeholder="600px"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PivotGridConfigPanel;
|
||||
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"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";
|
||||
|
||||
/**
|
||||
* PivotGrid 컴포넌트 정의
|
||||
*/
|
||||
const PivotGridDefinition = createComponentDefinition({
|
||||
id: "pivot-grid",
|
||||
name: "피벗 그리드",
|
||||
nameEng: "PivotGrid Component",
|
||||
description: "다차원 데이터 분석을 위한 피벗 테이블 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: PivotGridComponent,
|
||||
defaultConfig: {
|
||||
dataSource: {
|
||||
type: "table",
|
||||
tableName: "",
|
||||
},
|
||||
fields: [],
|
||||
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 {
|
||||
return (
|
||||
<PivotGridComponent
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
PivotGridRenderer.registerSelf();
|
||||
|
||||
// 강제 등록 (디버깅용)
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
PivotGridRenderer.registerSelf();
|
||||
} catch (error) {
|
||||
console.error("❌ PivotGrid 강제 등록 실패:", error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
|
@ -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`를 통해 영역 내 필드 순서를 지정하세요.
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* PivotGrid 컴포넌트 모듈
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블
|
||||
*/
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
// 기본 타입
|
||||
PivotAreaType,
|
||||
AggregationType,
|
||||
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";
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
/**
|
||||
* PivotGrid 컴포넌트 타입 정의
|
||||
* 다차원 데이터 분석을 위한 피벗 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
// ==================== 기본 타입 ====================
|
||||
|
||||
// 필드 영역 타입
|
||||
export type PivotAreaType = "row" | "column" | "data" | "filter";
|
||||
|
||||
// 집계 함수 타입
|
||||
export type AggregationType = "sum" | "count" | "avg" | "min" | "max" | "countDistinct";
|
||||
|
||||
// 정렬 방향
|
||||
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; // 집계 함수
|
||||
|
||||
// 정렬 설정
|
||||
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 PivotStyleConfig {
|
||||
theme: "default" | "compact" | "modern";
|
||||
headerStyle: "default" | "dark" | "light";
|
||||
cellPadding: "compact" | "normal" | "comfortable";
|
||||
borderStyle: "none" | "light" | "heavy";
|
||||
alternateRowColors?: boolean;
|
||||
highlightTotals?: boolean; // 총합계 강조
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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] || "합계";
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./aggregation";
|
||||
export * from "./pivotEngine";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
/**
|
||||
* PivotGrid 데이터 처리 엔진
|
||||
* 원시 데이터를 피벗 구조로 변환합니다.
|
||||
*/
|
||||
|
||||
import {
|
||||
PivotFieldConfig,
|
||||
PivotResult,
|
||||
PivotHeaderNode,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
PivotCellValue,
|
||||
DateGroupInterval,
|
||||
AggregationType,
|
||||
} 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;
|
||||
}
|
||||
|
||||
// ==================== 총합계 계산 ====================
|
||||
|
||||
/**
|
||||
* 총합계 계산
|
||||
*/
|
||||
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);
|
||||
|
||||
// 데이터 매트릭스 생성
|
||||
const dataMatrix = buildDataMatrix(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// 총합계 계산
|
||||
const grandTotals = calculateGrandTotals(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
return {
|
||||
rowHeaders,
|
||||
columnHeaders,
|
||||
dataMatrix,
|
||||
flatRows,
|
||||
flatColumns: flatColumnLeaves.map((path, idx) => ({
|
||||
path,
|
||||
level: path.length - 1,
|
||||
caption: path[path.length - 1] || "",
|
||||
span: 1,
|
||||
})),
|
||||
grandTotals,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -830,7 +830,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
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);
|
||||
} else if (relationshipType === "join") {
|
||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||
|
|
@ -899,16 +900,54 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
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");
|
||||
|
||||
// 복합키 조건 생성
|
||||
// 복합키 조건 생성 (다중 값 지원)
|
||||
// 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함
|
||||
// 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록
|
||||
const searchConditions: Record<string, any> = {};
|
||||
keys.forEach((key) => {
|
||||
effectiveKeys.forEach((key) => {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -947,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
setRightData(filteredData);
|
||||
} else {
|
||||
// 단일키 (하위 호환성)
|
||||
// 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
||||
|
||||
|
|
@ -965,6 +1004,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||
);
|
||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||
} else {
|
||||
console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName);
|
||||
setRightData([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1409,27 +1451,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 커스텀 모달 화면 열기
|
||||
const rightTableName = componentConfig.rightPanel?.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("✅ 수정 모달 열기:", {
|
||||
tableName: rightTableName,
|
||||
primaryKeyName,
|
||||
primaryKeyValue,
|
||||
screenId: modalScreenId,
|
||||
fullItem: item,
|
||||
});
|
||||
|
|
@ -1448,27 +1471,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
hasGroupByColumns: groupByColumns.length > 0,
|
||||
});
|
||||
|
||||
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
|
||||
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
||||
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
urlParams: {
|
||||
mode: "edit",
|
||||
editId: primaryKeyValue,
|
||||
tableName: rightTableName,
|
||||
...(groupByColumns.length > 0 && {
|
||||
editData: item, // 전체 데이터를 직접 전달
|
||||
...(groupByColumns.length > 0 && {
|
||||
urlParams: {
|
||||
groupByColumns: JSON.stringify(groupByColumns),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
|
||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
||||
screenId: modalScreenId,
|
||||
editId: primaryKeyValue,
|
||||
tableName: rightTableName,
|
||||
editData: item,
|
||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -429,6 +429,71 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
}
|
||||
}, [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(" - config:", config);
|
||||
console.log(" - tables:", tables);
|
||||
|
|
@ -1633,234 +1698,50 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
||||
{/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */}
|
||||
{relationshipType !== "detail" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">컬럼 매핑 (외래키 관계)</Label>
|
||||
<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 className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">테이블 관계 (자동 감지)</Label>
|
||||
<p className="text-xs text-gray-600">테이블 타입관리에서 정의된 엔티티 관계입니다</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-blue-600">복합키: 여러 컬럼으로 조인 (예: item_code + lot_number)</p>
|
||||
|
||||
{/* 복합키가 설정된 경우 */}
|
||||
{(config.rightPanel?.relation?.keys || []).length > 0 ? (
|
||||
<>
|
||||
{(config.rightPanel?.relation?.keys || []).map((key, index) => (
|
||||
<div key={index} className="space-y-2 rounded-md border bg-white p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">조인 키 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive h-6 w-6 p-0"
|
||||
onClick={() => {
|
||||
const newKeys = (config.rightPanel?.relation?.keys || []).filter((_, i) => i !== index);
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, keys: newKeys },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{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" />
|
||||
관계 감지 중...
|
||||
</div>
|
||||
) : autoDetectedRelations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{autoDetectedRelations.map((rel, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border border-blue-300 bg-white p-2">
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
{leftTableName}.{rel.leftColumn}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-blue-400" />
|
||||
<span className="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
{rightTableName}.{rel.rightColumn}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-gray-500">
|
||||
{rel.inputType === "entity" ? "엔티티" : "카테고리"}
|
||||
</span>
|
||||
</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="space-y-2">
|
||||
<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 className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||
<p className="text-xs text-gray-500">우측 테이블을 선택하면 관계를 자동 감지합니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
const currentFormValue = formData?.[component.columnName];
|
||||
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) {
|
||||
isGeneratingRef.current = true; // 생성 시작 플래그
|
||||
|
|
@ -144,13 +158,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
console.log("📝 formData 업데이트:", 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") {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget
|
|||
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
|
||||
import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
|
||||
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
|
||||
|
|
@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() {
|
|||
name: "엔티티 선택",
|
||||
category: "input",
|
||||
description: "데이터베이스 엔티티 선택 필드",
|
||||
component: EntityWidget,
|
||||
component: EntitySearchInputWrapper,
|
||||
configPanel: EntityConfigPanel,
|
||||
defaultConfig: {
|
||||
entityType: "",
|
||||
|
|
|
|||
|
|
@ -815,6 +815,9 @@ export class ButtonActionExecutor {
|
|||
console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)");
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
|
||||
let hasAllocationFailure = false;
|
||||
const failedFields: string[] = [];
|
||||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
try {
|
||||
console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
|
|
@ -825,13 +828,31 @@ export class ButtonActionExecutor {
|
|||
console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`);
|
||||
formData[fieldName] = newCode;
|
||||
} else {
|
||||
console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error);
|
||||
console.warn(`⚠️ ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
// 🆕 기존 값이 빈 문자열이면 실패로 표시
|
||||
if (!formData[fieldName] || formData[fieldName] === "") {
|
||||
hasAllocationFailure = true;
|
||||
failedFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
} catch (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("✅ 채번 규칙 할당 완료");
|
||||
|
|
@ -3062,6 +3083,7 @@ export class ButtonActionExecutor {
|
|||
config: ButtonActionConfig,
|
||||
rowData: any,
|
||||
context: ButtonActionContext,
|
||||
isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달
|
||||
): Promise<void> {
|
||||
const { groupByColumns = [] } = config;
|
||||
|
||||
|
|
@ -3135,10 +3157,11 @@ export class ButtonActionExecutor {
|
|||
const modalEvent = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
title: config.editModalTitle || "데이터 수정",
|
||||
title: isCreateMode ? (config.editModalTitle || "데이터 복사") : (config.editModalTitle || "데이터 수정"),
|
||||
description: description,
|
||||
modalSize: config.modalSize || "lg",
|
||||
editData: rowData,
|
||||
isCreateMode: isCreateMode, // 🆕 복사 모드에서 INSERT로 처리되도록
|
||||
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
|
||||
tableName: context.tableName, // 🆕 테이블명 전달
|
||||
buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용)
|
||||
|
|
@ -3253,23 +3276,61 @@ export class ButtonActionExecutor {
|
|||
"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 = "";
|
||||
for (const field of itemCodeFields) {
|
||||
if (copiedData[field] !== undefined) {
|
||||
const originalValue = copiedData[field];
|
||||
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] = "";
|
||||
|
||||
// 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성)
|
||||
if (hasNumberingRule) {
|
||||
copiedData[ruleIdKey] = rowData[ruleIdKey];
|
||||
copiedData[ruleIdKey] = numberingRuleId;
|
||||
console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`);
|
||||
console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`);
|
||||
console.log(`📋 채번 규칙 ID 설정: ${ruleIdKey} = ${numberingRuleId}`);
|
||||
} else {
|
||||
console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`);
|
||||
}
|
||||
|
|
@ -3326,9 +3387,9 @@ export class ButtonActionExecutor {
|
|||
|
||||
switch (editMode) {
|
||||
case "modal":
|
||||
// 모달로 복사 폼 열기 (편집 모달 재사용)
|
||||
console.log("📋 모달로 복사 폼 열기");
|
||||
await this.openEditModal(config, rowData, context);
|
||||
// 모달로 복사 폼 열기 (편집 모달 재사용, INSERT 모드로)
|
||||
console.log("📋 모달로 복사 폼 열기 (INSERT 모드)");
|
||||
await this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true
|
||||
break;
|
||||
|
||||
case "navigate":
|
||||
|
|
@ -3339,8 +3400,8 @@ export class ButtonActionExecutor {
|
|||
|
||||
default:
|
||||
// 기본값: 모달
|
||||
console.log("📋 기본 모달로 복사 폼 열기");
|
||||
this.openEditModal(config, rowData, context);
|
||||
console.log("📋 기본 모달로 복사 폼 열기 (INSERT 모드)");
|
||||
this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ openCopyForm 실행 중 오류:", error);
|
||||
|
|
@ -4943,26 +5004,35 @@ export class ButtonActionExecutor {
|
|||
|
||||
const { oldValue, newValue } = confirmed;
|
||||
|
||||
// 미리보기 표시 (옵션)
|
||||
// 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색)
|
||||
if (config.mergeShowPreview !== false) {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const previewResponse = await apiClient.post("/code-merge/preview", {
|
||||
columnName,
|
||||
toast.loading("영향받는 데이터 검색 중...", { duration: Infinity });
|
||||
|
||||
const previewResponse = await apiClient.post("/code-merge/preview-by-value", {
|
||||
oldValue,
|
||||
});
|
||||
|
||||
toast.dismiss();
|
||||
|
||||
if (previewResponse.data.success) {
|
||||
const preview = previewResponse.data.data;
|
||||
const totalRows = preview.totalAffectedRows;
|
||||
|
||||
// 상세 정보 생성
|
||||
const detailList = preview.preview
|
||||
.map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`)
|
||||
.join("\n");
|
||||
|
||||
const confirmMerge = confirm(
|
||||
"⚠️ 코드 병합 확인\n\n" +
|
||||
"코드 병합 확인\n\n" +
|
||||
`${oldValue} → ${newValue}\n\n` +
|
||||
"영향받는 데이터:\n" +
|
||||
`- 테이블 수: ${preview.preview.length}개\n` +
|
||||
`- 테이블/컬럼 수: ${preview.preview.length}개\n` +
|
||||
`- 총 행 수: ${totalRows}개\n\n` +
|
||||
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
||||
(preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") +
|
||||
"모든 테이블에서 해당 값이 변경됩니다.\n\n" +
|
||||
"계속하시겠습니까?",
|
||||
);
|
||||
|
||||
|
|
@ -4972,13 +5042,12 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 병합 실행
|
||||
// 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼)
|
||||
toast.loading("코드 병합 중...", { duration: Infinity });
|
||||
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const response = await apiClient.post("/code-merge/merge-all-tables", {
|
||||
columnName,
|
||||
const response = await apiClient.post("/code-merge/merge-by-value", {
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
|
@ -4987,9 +5056,17 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
|
||||
// 변경된 테이블/컬럼 목록 생성
|
||||
const changedList = data.affectedData
|
||||
.map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`)
|
||||
.join(", ");
|
||||
|
||||
toast.success(
|
||||
"코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`,
|
||||
`코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`,
|
||||
);
|
||||
|
||||
console.log("코드 병합 결과:", data.affectedData);
|
||||
|
||||
// 화면 새로고침
|
||||
context.onRefresh?.();
|
||||
|
|
|
|||
|
|
@ -365,6 +365,8 @@ export interface EntityTypeConfig {
|
|||
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||
// UI 모드
|
||||
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
|
||||
// 다중 선택
|
||||
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue