Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
5bbbd37553
|
|
@ -427,7 +427,8 @@ export const updateFieldValue = async (
|
||||||
): Promise<Response | void> => {
|
): Promise<Response | void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
const { tableName, keyField, keyValue, updateField, updateValue } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
console.log("🔄 [updateFieldValue] 요청:", {
|
console.log("🔄 [updateFieldValue] 요청:", {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -440,16 +441,27 @@ export const updateFieldValue = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
if (
|
||||||
|
!tableName ||
|
||||||
|
!keyField ||
|
||||||
|
keyValue === undefined ||
|
||||||
|
!updateField ||
|
||||||
|
updateValue === undefined
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
if (
|
||||||
|
!validNamePattern.test(tableName) ||
|
||||||
|
!validNamePattern.test(keyField) ||
|
||||||
|
!validNamePattern.test(updateField)
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||||
|
|
|
||||||
|
|
@ -837,4 +837,53 @@ export class FlowController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스텝 데이터 업데이트 (인라인 편집)
|
||||||
|
*/
|
||||||
|
updateStepData = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { flowId, stepId, recordId } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
const userId = (req as any).user?.userId || "system";
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
if (!flowId || !stepId || !recordId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "flowId, stepId, and recordId are required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateData || Object.keys(updateData).length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Update data is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.flowExecutionService.updateStepData(
|
||||||
|
parseInt(flowId),
|
||||||
|
parseInt(stepId),
|
||||||
|
recordId,
|
||||||
|
updateData,
|
||||||
|
userId,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Data updated successfully",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating step data:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Failed to update step data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1811,3 +1811,333 @@ export async function getCategoryColumnsByMenu(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 범용 다중 테이블 저장 API
|
||||||
|
*
|
||||||
|
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
||||||
|
*
|
||||||
|
* 요청 본문:
|
||||||
|
* {
|
||||||
|
* mainTable: { tableName: string, primaryKeyColumn: string },
|
||||||
|
* mainData: Record<string, any>,
|
||||||
|
* subTables: Array<{
|
||||||
|
* tableName: string,
|
||||||
|
* linkColumn: { mainField: string, subColumn: string },
|
||||||
|
* items: Record<string, any>[],
|
||||||
|
* options?: {
|
||||||
|
* saveMainAsFirst?: boolean,
|
||||||
|
* mainFieldMappings?: Array<{ formField: string, targetColumn: string }>,
|
||||||
|
* mainMarkerColumn?: string,
|
||||||
|
* mainMarkerValue?: any,
|
||||||
|
* subMarkerValue?: any,
|
||||||
|
* deleteExistingBefore?: boolean,
|
||||||
|
* }
|
||||||
|
* }>,
|
||||||
|
* isUpdate?: boolean
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function multiTableSave(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = require("../database/db").getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { mainTable, mainData, subTables, isUpdate } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
logger.info("=== 다중 테이블 저장 시작 ===", {
|
||||||
|
mainTable,
|
||||||
|
mainDataKeys: Object.keys(mainData || {}),
|
||||||
|
subTablesCount: subTables?.length || 0,
|
||||||
|
isUpdate,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "메인 테이블 설정이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainData || Object.keys(mainData).length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "저장할 메인 데이터가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 메인 테이블 저장
|
||||||
|
const mainTableName = mainTable.tableName;
|
||||||
|
const pkColumn = mainTable.primaryKeyColumn;
|
||||||
|
const pkValue = mainData[pkColumn];
|
||||||
|
|
||||||
|
// company_code 자동 추가 (최고 관리자가 아닌 경우)
|
||||||
|
if (companyCode !== "*" && !mainData.company_code) {
|
||||||
|
mainData.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainResult: any;
|
||||||
|
|
||||||
|
if (isUpdate && pkValue) {
|
||||||
|
// UPDATE
|
||||||
|
const updateColumns = Object.keys(mainData)
|
||||||
|
.filter(col => col !== pkColumn)
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
const updateValues = Object.keys(mainData)
|
||||||
|
.filter(col => col !== pkColumn)
|
||||||
|
.map(col => mainData[col]);
|
||||||
|
|
||||||
|
// updated_at 컬럼 존재 여부 확인
|
||||||
|
const hasUpdatedAt = await client.query(`
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'updated_at'
|
||||||
|
`, [mainTableName]);
|
||||||
|
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE "${mainTableName}"
|
||||||
|
SET ${updateColumns}${updatedAtClause}
|
||||||
|
WHERE "${pkColumn}" = $${updateValues.length + 1}
|
||||||
|
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateParams = companyCode !== "*"
|
||||||
|
? [...updateValues, pkValue, companyCode]
|
||||||
|
: [...updateValues, pkValue];
|
||||||
|
|
||||||
|
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
|
||||||
|
mainResult = await client.query(updateQuery, updateParams);
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
|
||||||
|
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||||
|
const values = Object.values(mainData);
|
||||||
|
|
||||||
|
// updated_at 컬럼 존재 여부 확인
|
||||||
|
const hasUpdatedAt = await client.query(`
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'updated_at'
|
||||||
|
`, [mainTableName]);
|
||||||
|
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
|
||||||
|
|
||||||
|
const updateSetClause = Object.keys(mainData)
|
||||||
|
.filter(col => col !== pkColumn)
|
||||||
|
.map(col => `"${col}" = EXCLUDED."${col}"`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO "${mainTableName}" (${columns})
|
||||||
|
VALUES (${placeholders})
|
||||||
|
ON CONFLICT ("${pkColumn}") DO UPDATE SET
|
||||||
|
${updateSetClause}${updatedAtClause}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
|
||||||
|
mainResult = await client.query(insertQuery, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainResult.rowCount === 0) {
|
||||||
|
throw new Error("메인 테이블 저장 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedMainData = mainResult.rows[0];
|
||||||
|
const savedPkValue = savedMainData[pkColumn];
|
||||||
|
logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue });
|
||||||
|
|
||||||
|
// 2. 서브 테이블 저장
|
||||||
|
const subTableResults: any[] = [];
|
||||||
|
|
||||||
|
for (const subTableConfig of subTables || []) {
|
||||||
|
const { tableName, linkColumn, items, options } = subTableConfig;
|
||||||
|
|
||||||
|
if (!tableName || !items || items.length === 0) {
|
||||||
|
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||||
|
itemsCount: items.length,
|
||||||
|
linkColumn,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 데이터 삭제 옵션
|
||||||
|
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
|
||||||
|
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
|
||||||
|
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
|
||||||
|
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
|
||||||
|
|
||||||
|
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
|
||||||
|
? [savedPkValue, options.subMarkerValue ?? false]
|
||||||
|
: [savedPkValue];
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
|
||||||
|
await client.query(deleteQuery, deleteParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||||
|
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
|
||||||
|
const mainSubItem: Record<string, any> = {
|
||||||
|
[linkColumn.subColumn]: savedPkValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메인 필드 매핑 적용
|
||||||
|
for (const mapping of options.mainFieldMappings) {
|
||||||
|
if (mapping.formField && mapping.targetColumn) {
|
||||||
|
mainSubItem[mapping.targetColumn] = mainData[mapping.formField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 마커 설정
|
||||||
|
if (options.mainMarkerColumn) {
|
||||||
|
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// company_code 추가
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
mainSubItem.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
|
||||||
|
const checkQuery = `
|
||||||
|
SELECT * FROM "${tableName}"
|
||||||
|
WHERE "${linkColumn.subColumn}" = $1
|
||||||
|
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
|
||||||
|
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const checkParams: any[] = [savedPkValue];
|
||||||
|
if (options.mainMarkerColumn) {
|
||||||
|
checkParams.push(options.mainMarkerValue ?? true);
|
||||||
|
}
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResult = await client.query(checkQuery, checkParams);
|
||||||
|
|
||||||
|
if (existingResult.rows.length > 0) {
|
||||||
|
// UPDATE
|
||||||
|
const updateColumns = Object.keys(mainSubItem)
|
||||||
|
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const updateValues = Object.keys(mainSubItem)
|
||||||
|
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
|
||||||
|
.map(col => mainSubItem[col]);
|
||||||
|
|
||||||
|
if (updateColumns) {
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE "${tableName}"
|
||||||
|
SET ${updateColumns}
|
||||||
|
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
|
||||||
|
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
|
||||||
|
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const updateParams = [...updateValues, savedPkValue];
|
||||||
|
if (options.mainMarkerColumn) {
|
||||||
|
updateParams.push(options.mainMarkerValue ?? true);
|
||||||
|
}
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
updateParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = await client.query(updateQuery, updateParams);
|
||||||
|
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
|
||||||
|
} else {
|
||||||
|
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
|
||||||
|
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||||
|
const mainSubValues = Object.values(mainSubItem);
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO "${tableName}" (${mainSubColumns})
|
||||||
|
VALUES (${mainSubPlaceholders})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const insertResult = await client.query(insertQuery, mainSubValues);
|
||||||
|
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서브 아이템들 저장
|
||||||
|
for (const item of items) {
|
||||||
|
// 연결 컬럼 값 설정
|
||||||
|
if (linkColumn?.subColumn) {
|
||||||
|
item[linkColumn.subColumn] = savedPkValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// company_code 추가
|
||||||
|
if (companyCode !== "*" && !item.company_code) {
|
||||||
|
item.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
|
||||||
|
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||||
|
const subValues = Object.values(item);
|
||||||
|
|
||||||
|
const subInsertQuery = `
|
||||||
|
INSERT INTO "${tableName}" (${subColumns})
|
||||||
|
VALUES (${subPlaceholders})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
|
||||||
|
const subResult = await client.query(subInsertQuery, subValues);
|
||||||
|
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 저장 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("=== 다중 테이블 저장 완료 ===", {
|
||||||
|
mainTable: mainTableName,
|
||||||
|
mainPk: savedPkValue,
|
||||||
|
subTableResultsCount: subTableResults.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "다중 테이블 저장이 완료되었습니다.",
|
||||||
|
data: {
|
||||||
|
main: savedMainData,
|
||||||
|
subTables: subTableResults,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
|
||||||
|
logger.error("다중 테이블 저장 실패:", {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "다중 테이블 저장에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
||||||
router.post("/move", flowController.moveData);
|
router.post("/move", flowController.moveData);
|
||||||
router.post("/move-batch", flowController.moveBatchData);
|
router.post("/move-batch", flowController.moveBatchData);
|
||||||
|
|
||||||
|
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
|
||||||
|
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
|
||||||
|
|
||||||
// ==================== 오딧 로그 ====================
|
// ==================== 오딧 로그 ====================
|
||||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
getLogData,
|
getLogData,
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
||||||
*/
|
*/
|
||||||
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 범용 다중 테이블 저장 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 테이블 저장 (메인 + 서브 테이블)
|
||||||
|
* POST /api/table-management/multi-table-save
|
||||||
|
*
|
||||||
|
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
||||||
|
* 사원+부서, 주문+주문상세 등 1:N 관계 데이터 저장에 사용됩니다.
|
||||||
|
*/
|
||||||
|
router.post("/multi-table-save", multiTableSave);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,18 @@ export class BatchSchedulerService {
|
||||||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const task = cron.schedule(config.cron_schedule, async () => {
|
const task = cron.schedule(
|
||||||
logger.info(
|
config.cron_schedule,
|
||||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
async () => {
|
||||||
);
|
logger.info(
|
||||||
await this.executeBatchConfig(config);
|
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||||
});
|
);
|
||||||
|
await this.executeBatchConfig(config);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.scheduledTasks.set(config.id, task);
|
this.scheduledTasks.set(config.id, task);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ export class FlowDataMoveService {
|
||||||
// 내부 DB 처리 (기존 로직)
|
// 내부 DB 처리 (기존 로직)
|
||||||
return await db.transaction(async (client) => {
|
return await db.transaction(async (client) => {
|
||||||
try {
|
try {
|
||||||
|
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId || "system",
|
||||||
|
]);
|
||||||
|
|
||||||
// 1. 단계 정보 조회
|
// 1. 단계 정보 조회
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
@ -684,6 +689,14 @@ export class FlowDataMoveService {
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
async (externalClient, dbType) => {
|
async (externalClient, dbType) => {
|
||||||
try {
|
try {
|
||||||
|
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
||||||
|
if (dbType.toLowerCase() === "postgresql") {
|
||||||
|
await externalClient.query(
|
||||||
|
"SELECT set_config('app.user_id', $1, true)",
|
||||||
|
[userId || "system"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 단계 정보 조회 (내부 DB에서)
|
// 1. 단계 정보 조회 (내부 DB에서)
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
|
||||||
|
|
@ -263,4 +263,139 @@ export class FlowExecutionService {
|
||||||
tableName: result[0].table_name,
|
tableName: result[0].table_name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스텝 데이터 업데이트 (인라인 편집)
|
||||||
|
* 원본 테이블의 데이터를 직접 업데이트합니다.
|
||||||
|
*/
|
||||||
|
async updateStepData(
|
||||||
|
flowId: number,
|
||||||
|
stepId: number,
|
||||||
|
recordId: string,
|
||||||
|
updateData: Record<string, any>,
|
||||||
|
userId: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
// 1. 플로우 정의 조회
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(flowId);
|
||||||
|
if (!flowDef) {
|
||||||
|
throw new Error(`Flow definition not found: ${flowId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 스텝 조회
|
||||||
|
const step = await this.flowStepService.findById(stepId);
|
||||||
|
if (!step) {
|
||||||
|
throw new Error(`Flow step not found: ${stepId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 테이블명 결정
|
||||||
|
const tableName = step.tableName || flowDef.tableName;
|
||||||
|
if (!tableName) {
|
||||||
|
throw new Error("Table name not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Primary Key 컬럼 결정 (기본값: id)
|
||||||
|
const primaryKeyColumn = flowDef.primaryKey || "id";
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. SET 절 생성
|
||||||
|
const updateColumns = Object.keys(updateData);
|
||||||
|
if (updateColumns.length === 0) {
|
||||||
|
throw new Error("No columns to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 외부 DB vs 내부 DB 구분
|
||||||
|
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||||
|
// 외부 DB 업데이트
|
||||||
|
console.log(
|
||||||
|
"✅ [updateStepData] Using EXTERNAL DB:",
|
||||||
|
flowDef.dbConnectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connectionResult = await db.query(
|
||||||
|
"SELECT * FROM external_db_connection WHERE id = $1",
|
||||||
|
[flowDef.dbConnectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectionResult.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`External DB connection not found: ${flowDef.dbConnectionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = connectionResult[0];
|
||||||
|
const dbType = connection.db_type?.toLowerCase();
|
||||||
|
|
||||||
|
// DB 타입에 따른 placeholder 및 쿼리 생성
|
||||||
|
let setClause: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (dbType === "mysql" || dbType === "mariadb") {
|
||||||
|
// MySQL/MariaDB: ? placeholder
|
||||||
|
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
} else if (dbType === "mssql") {
|
||||||
|
// MSSQL: @p1, @p2 placeholder
|
||||||
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `[${col}] = @p${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
} else {
|
||||||
|
// PostgreSQL: $1, $2 placeholder
|
||||||
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
|
||||||
|
|
||||||
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
|
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
|
||||||
|
} else {
|
||||||
|
// 내부 DB 업데이트
|
||||||
|
console.log("✅ [updateStepData] Using INTERNAL DB");
|
||||||
|
|
||||||
|
const setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
const params = [...Object.values(updateData), recordId];
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
|
||||||
|
|
||||||
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
|
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
|
||||||
|
// (트리거에서 changed_by를 기록하기 위함)
|
||||||
|
await db.transaction(async (client) => {
|
||||||
|
// 안전한 파라미터 바인딩 방식 사용
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
await client.query(updateQuery, params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
|
||||||
|
{
|
||||||
|
updatedFields: updateColumns,
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [updateStepData] Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,12 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await transaction(async (client) => {
|
result = await transaction(async (client) => {
|
||||||
|
// 🔥 사용자 ID 세션 변수 설정 (트리거용)
|
||||||
|
const userId = context.buttonContext?.userId || "system";
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
// 트랜잭션 내에서 레벨별 실행
|
// 트랜잭션 내에서 레벨별 실행
|
||||||
for (const level of levels) {
|
for (const level of levels) {
|
||||||
await this.executeLevel(level, nodes, edges, context, client);
|
await this.executeLevel(level, nodes, edges, context, client);
|
||||||
|
|
|
||||||
|
|
@ -897,13 +897,13 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||||
const length = autoConfig.sequenceLength || 4;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
// 숫자 (고정 자릿수)
|
// 숫자 (고정 자릿수)
|
||||||
const length = autoConfig.numberLength || 4;
|
const length = autoConfig.numberLength || 3;
|
||||||
const value = autoConfig.numberValue || 1;
|
const value = autoConfig.numberValue || 1;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
@ -957,13 +957,13 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (자동 증가 숫자)
|
// 순번 (자동 증가 숫자)
|
||||||
const length = autoConfig.sequenceLength || 4;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
// 숫자 (고정 자릿수)
|
// 숫자 (고정 자릿수)
|
||||||
const length = autoConfig.numberLength || 4;
|
const length = autoConfig.numberLength || 3;
|
||||||
const value = autoConfig.numberValue || 1;
|
const value = autoConfig.numberValue || 1;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import {
|
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
||||||
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
|
|
||||||
|
|
@ -9,208 +7,206 @@ import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
*/
|
*/
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none px-4 pt-12 pb-16 space-y-16">
|
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||||
|
{/* 주요 관리 기능 */}
|
||||||
{/* 주요 관리 기능 */}
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mb-8 text-center">
|
||||||
<div className="text-center mb-8">
|
<h2 className="text-foreground mb-2 text-2xl font-bold">주요 관리 기능</h2>
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">주요 관리 기능</h2>
|
<p className="text-muted-foreground">시스템의 핵심 관리 기능들을 제공합니다</p>
|
||||||
<p className="text-muted-foreground">시스템의 핵심 관리 기능들을 제공합니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Link href="/admin/userMng" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Users className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">사용자 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">사용자 계정 및 권한 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Link href="/admin/userMng" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Users className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">사용자 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">사용자 계정 및 권한 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
{/* <div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
<Shield className="h-6 w-6 text-success" />
|
<Shield className="text-success h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">권한 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">메뉴 및 기능 권한 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">권한 관리</h3>
|
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<p className="text-sm text-muted-foreground">메뉴 및 기능 권한 설정</p>
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Settings className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">시스템 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">기본 설정 및 환경 구성</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-warning/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<BarChart3 className="text-warning h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">통계 및 리포트</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">시스템 사용 현황 분석</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<Link href="/admin/screenMng" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Palette className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">화면관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">드래그앤드롭으로 화면 설계 및 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
{/* 표준 관리 섹션 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<div className="mb-8 text-center">
|
||||||
<Settings className="h-6 w-6 text-primary" />
|
<h2 className="text-foreground mb-2 text-2xl font-bold">표준 관리</h2>
|
||||||
</div>
|
<p className="text-muted-foreground">시스템 표준 및 컴포넌트를 통합 관리합니다</p>
|
||||||
<div>
|
</div>
|
||||||
<h3 className="font-semibold text-foreground">시스템 설정</h3>
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<p className="text-sm text-muted-foreground">기본 설정 및 환경 구성</p>
|
{/* <Link href="/admin/standards" className="block h-full">
|
||||||
</div>
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Database className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">웹타입 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">입력 컴포넌트 웹타입 표준 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/templates" className="block h-full">
|
||||||
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Layout className="text-success h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">템플릿 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">화면 디자이너 템플릿 표준 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link> */}
|
||||||
|
|
||||||
|
<Link href="/admin/tableMng" className="block h-full">
|
||||||
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Database className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">테이블 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">데이터베이스 테이블 및 웹타입 매핑</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* <Link href="/admin/components" className="block h-full">
|
||||||
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Package className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">컴포넌트 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">화면 디자이너 컴포넌트 표준 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
{/* 빠른 액세스 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-warning/10">
|
<div className="mb-8 text-center">
|
||||||
<BarChart3 className="h-6 w-6 text-warning" />
|
<h2 className="text-foreground mb-2 text-2xl font-bold">빠른 액세스</h2>
|
||||||
</div>
|
<p className="text-muted-foreground">자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다</p>
|
||||||
<div>
|
</div>
|
||||||
<h3 className="font-semibold text-foreground">통계 및 리포트</h3>
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<p className="text-sm text-muted-foreground">시스템 사용 현황 분석</p>
|
<Link href="/admin/menu" className="block">
|
||||||
</div>
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Layout className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">메뉴 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">시스템 메뉴 및 네비게이션 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/external-connections" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Database className="text-success h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">외부 연결 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">외부 데이터베이스 연결 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/commonCode" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Settings className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">공통 코드 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">시스템 공통 코드 및 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/admin/screenMng" className="block">
|
{/* 전역 파일 관리 */}
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="mb-6 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<h2 className="text-foreground mb-2 text-2xl font-bold">전역 파일 관리</h2>
|
||||||
<Palette className="h-6 w-6 text-primary" />
|
<p className="text-muted-foreground">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">화면관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">드래그앤드롭으로 화면 설계 및 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<GlobalFileViewer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표준 관리 섹션 */}
|
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">표준 관리</h2>
|
|
||||||
<p className="text-muted-foreground">시스템 표준 및 컴포넌트를 통합 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Link href="/admin/standards" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Database className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">웹타입 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">입력 컴포넌트 웹타입 표준 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/templates" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
|
||||||
<Layout className="h-6 w-6 text-success" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">템플릿 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">화면 디자이너 템플릿 표준 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/tableMng" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Database className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">테이블 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">데이터베이스 테이블 및 웹타입 매핑</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/components" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Package className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">컴포넌트 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">화면 디자이너 컴포넌트 표준 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 빠른 액세스 */}
|
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">빠른 액세스</h2>
|
|
||||||
<p className="text-muted-foreground">자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
<Link href="/admin/menu" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Layout className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">메뉴 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템 메뉴 및 네비게이션 설정</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/external-connections" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
|
||||||
<Database className="h-6 w-6 text-success" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">외부 연결 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 연결 설정</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/commonCode" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Settings className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">공통 코드 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템 공통 코드 및 설정</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 전역 파일 관리 */}
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">전역 파일 관리</h2>
|
|
||||||
<p className="text-muted-foreground">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<GlobalFileViewer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -276,12 +276,12 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
|
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="flex-1 space-y-6 overflow-y-auto py-4 pr-2">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||||
|
|
@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-shrink-0 border-t pt-4">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
취소
|
취소
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,47 @@ export interface ListWidgetConfig {
|
||||||
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
||||||
compactMode: boolean; // 압축 모드 (기본: false)
|
compactMode: boolean; // 압축 모드 (기본: false)
|
||||||
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
||||||
|
// 행 클릭 팝업 설정
|
||||||
|
rowDetailPopup?: RowDetailPopupConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행 상세 팝업 설정
|
||||||
|
export interface RowDetailPopupConfig {
|
||||||
|
enabled: boolean; // 팝업 활성화 여부
|
||||||
|
title?: string; // 팝업 제목 (기본: "상세 정보")
|
||||||
|
// 추가 데이터 조회 설정
|
||||||
|
additionalQuery?: {
|
||||||
|
enabled: boolean;
|
||||||
|
tableName: string; // 조회할 테이블명 (예: vehicles)
|
||||||
|
matchColumn: string; // 매칭할 컬럼 (예: id)
|
||||||
|
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
||||||
|
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
||||||
|
displayColumns?: DisplayColumnConfig[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 표시 컬럼 설정
|
||||||
|
export interface DisplayColumnConfig {
|
||||||
|
column: string; // DB 컬럼명
|
||||||
|
label: string; // 표시 라벨 (사용자 정의)
|
||||||
|
// 필드 그룹 설정
|
||||||
|
fieldGroups?: FieldGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 그룹 (팝업 내 섹션)
|
||||||
|
export interface FieldGroup {
|
||||||
|
id: string;
|
||||||
|
title: string; // 그룹 제목 (예: "운행 정보")
|
||||||
|
icon?: string; // 아이콘 (예: "truck", "clock")
|
||||||
|
color?: "blue" | "orange" | "green" | "red" | "purple" | "gray";
|
||||||
|
fields: FieldConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 설정
|
||||||
|
export interface FieldConfig {
|
||||||
|
column: string; // DB 컬럼명
|
||||||
|
label: string; // 표시 라벨
|
||||||
|
format?: "text" | "number" | "date" | "datetime" | "currency" | "boolean" | "distance" | "duration";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리스트 컬럼
|
// 리스트 컬럼
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { ListWidgetConfig, QueryResult } from "../types";
|
import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
||||||
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
|
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
|
||||||
|
import { Plus, Trash2, ChevronDown, ChevronUp, X, Check } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
interface ListWidgetSectionProps {
|
interface ListWidgetSectionProps {
|
||||||
queryResult: QueryResult | null;
|
queryResult: QueryResult | null;
|
||||||
|
|
@ -16,8 +23,91 @@ interface ListWidgetSectionProps {
|
||||||
* 리스트 위젯 설정 섹션
|
* 리스트 위젯 설정 섹션
|
||||||
* - 컬럼 설정
|
* - 컬럼 설정
|
||||||
* - 테이블 옵션
|
* - 테이블 옵션
|
||||||
|
* - 행 클릭 팝업 설정
|
||||||
*/
|
*/
|
||||||
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
|
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 팝업 설정 초기화
|
||||||
|
const popupConfig = config.rowDetailPopup || {
|
||||||
|
enabled: false,
|
||||||
|
title: "상세 정보",
|
||||||
|
additionalQuery: { enabled: false, tableName: "", matchColumn: "" },
|
||||||
|
fieldGroups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 팝업 설정 업데이트 헬퍼
|
||||||
|
const updatePopupConfig = (updates: Partial<typeof popupConfig>) => {
|
||||||
|
onConfigChange({
|
||||||
|
rowDetailPopup: { ...popupConfig, ...updates },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 추가
|
||||||
|
const addFieldGroup = () => {
|
||||||
|
const newGroup: FieldGroup = {
|
||||||
|
id: `group-${Date.now()}`,
|
||||||
|
title: "새 그룹",
|
||||||
|
icon: "info",
|
||||||
|
color: "gray",
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: [...(popupConfig.fieldGroups || []), newGroup],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 삭제
|
||||||
|
const removeFieldGroup = (groupId: string) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).filter((g) => g.id !== groupId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 업데이트
|
||||||
|
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) => (g.id === groupId ? { ...g, ...updates } : g)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 추가
|
||||||
|
const addField = (groupId: string) => {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
column: "",
|
||||||
|
label: "",
|
||||||
|
format: "text",
|
||||||
|
};
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
|
||||||
|
g.id === groupId ? { ...g, fields: [...g.fields, newField] } : g,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 삭제
|
||||||
|
const removeField = (groupId: string, fieldIndex: number) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
|
||||||
|
g.id === groupId ? { ...g, fields: g.fields.filter((_, i) => i !== fieldIndex) } : g,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 업데이트
|
||||||
|
const updateField = (groupId: string, fieldIndex: number, updates: Partial<FieldConfig>) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
|
||||||
|
g.id === groupId ? { ...g, fields: g.fields.map((f, i) => (i === fieldIndex ? { ...f, ...updates } : f)) } : g,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 확장/축소 토글
|
||||||
|
const toggleGroupExpand = (groupId: string) => {
|
||||||
|
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||||
|
|
@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 행 클릭 팝업 설정 */}
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold">행 클릭 팝업</Label>
|
||||||
|
<Switch
|
||||||
|
checked={popupConfig.enabled}
|
||||||
|
onCheckedChange={(enabled) => updatePopupConfig({ enabled })}
|
||||||
|
aria-label="행 클릭 팝업 활성화"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{popupConfig.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 팝업 제목 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">팝업 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.title || ""}
|
||||||
|
onChange={(e) => updatePopupConfig({ title: e.target.value })}
|
||||||
|
placeholder="상세 정보"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 데이터 조회 설정 */}
|
||||||
|
<div className="space-y-2 rounded border p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">추가 데이터 조회</Label>
|
||||||
|
<Switch
|
||||||
|
checked={popupConfig.additionalQuery?.enabled || false}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label="추가 데이터 조회 활성화"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{popupConfig.additionalQuery?.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.tableName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="vehicles"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.matchColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="id"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="비워두면 매칭 컬럼과 동일"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
||||||
|
<span className="truncate">
|
||||||
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
||||||
|
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
||||||
|
: "전체 표시 (클릭하여 선택)"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-2" align="start">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">컬럼 선택</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{/* 쿼리 결과 컬럼 목록 */}
|
||||||
|
{queryResult?.columns.map((col) => {
|
||||||
|
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
||||||
|
const existingConfig = currentColumns.find((c) =>
|
||||||
|
typeof c === 'object' ? c.column === col : c === col
|
||||||
|
);
|
||||||
|
const isSelected = !!existingConfig;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = isSelected
|
||||||
|
? currentColumns.filter((c) =>
|
||||||
|
typeof c === 'object' ? c.column !== col : c !== col
|
||||||
|
)
|
||||||
|
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} className="h-3 w-3" />
|
||||||
|
<span className="text-xs">{col}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
쿼리를 먼저 실행해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 라벨 편집 */}
|
||||||
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Label className="text-xs">컬럼 라벨 설정</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return (
|
||||||
|
<div key={column} className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground w-24 truncate text-xs" title={column}>
|
||||||
|
{column}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||||
|
newColumns[index] = { column, label: e.target.value };
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
|
||||||
|
(c) => (typeof c === 'object' ? c.column : c) !== column
|
||||||
|
);
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 그룹 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">필드 그룹 (선택사항)</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={addFieldGroup} className="h-7 gap-1 text-xs">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">설정하지 않으면 모든 필드가 자동으로 표시됩니다.</p>
|
||||||
|
|
||||||
|
{/* 필드 그룹 목록 */}
|
||||||
|
{(popupConfig.fieldGroups || []).map((group) => (
|
||||||
|
<div key={group.id} className="rounded border p-2">
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroupExpand(group.id)}
|
||||||
|
className="flex flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
{expandedGroups[group.id] ? (
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">{group.title || "새 그룹"}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">({group.fields.length}개 필드)</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeFieldGroup(group.id)}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 상세 (확장 시) */}
|
||||||
|
{expandedGroups[group.id] && (
|
||||||
|
<div className="mt-2 space-y-2 border-t pt-2">
|
||||||
|
{/* 그룹 제목 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">제목</Label>
|
||||||
|
<Input
|
||||||
|
value={group.title}
|
||||||
|
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">색상</Label>
|
||||||
|
<Select
|
||||||
|
value={group.color || "gray"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFieldGroup(group.id, {
|
||||||
|
color: value as "blue" | "orange" | "green" | "red" | "purple" | "gray",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="gray">회색</SelectItem>
|
||||||
|
<SelectItem value="blue">파랑</SelectItem>
|
||||||
|
<SelectItem value="orange">주황</SelectItem>
|
||||||
|
<SelectItem value="green">초록</SelectItem>
|
||||||
|
<SelectItem value="red">빨강</SelectItem>
|
||||||
|
<SelectItem value="purple">보라</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">아이콘</Label>
|
||||||
|
<Select
|
||||||
|
value={group.icon || "info"}
|
||||||
|
onValueChange={(value) => updateFieldGroup(group.id, { icon: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="info">정보</SelectItem>
|
||||||
|
<SelectItem value="truck">트럭</SelectItem>
|
||||||
|
<SelectItem value="clock">시계</SelectItem>
|
||||||
|
<SelectItem value="map">지도</SelectItem>
|
||||||
|
<SelectItem value="package">박스</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">필드</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addField(group.id)}
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.fields.map((field, fieldIndex) => (
|
||||||
|
<div key={fieldIndex} className="flex items-center gap-1 rounded bg-muted/50 p-1">
|
||||||
|
<Input
|
||||||
|
value={field.column}
|
||||||
|
onChange={(e) => updateField(group.id, fieldIndex, { column: e.target.value })}
|
||||||
|
placeholder="컬럼명"
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => updateField(group.id, fieldIndex, { label: e.target.value })}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={field.format || "text"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateField(group.id, fieldIndex, {
|
||||||
|
format: value as FieldConfig["format"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-20 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number">숫자</SelectItem>
|
||||||
|
<SelectItem value="date">날짜</SelectItem>
|
||||||
|
<SelectItem value="datetime">날짜시간</SelectItem>
|
||||||
|
<SelectItem value="currency">통화</SelectItem>
|
||||||
|
<SelectItem value="distance">거리</SelectItem>
|
||||||
|
<SelectItem value="duration">시간</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeField(group.id, fieldIndex)}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
import { Truck, Clock, MapPin, Package, Info } from "lucide-react";
|
||||||
|
|
||||||
interface ListWidgetProps {
|
interface ListWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 행 상세 팝업 상태
|
||||||
|
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
||||||
|
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
|
||||||
|
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
|
||||||
|
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
const config = element.listConfig || {
|
const config = element.listConfig || {
|
||||||
columnMode: "auto",
|
columnMode: "auto",
|
||||||
viewMode: "table",
|
viewMode: "table",
|
||||||
|
|
@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
cardColumns: 3,
|
cardColumns: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 행 클릭 핸들러 - 팝업 열기
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
async (row: Record<string, any>) => {
|
||||||
|
// 팝업이 비활성화되어 있으면 무시
|
||||||
|
if (!config.rowDetailPopup?.enabled) return;
|
||||||
|
|
||||||
|
setDetailPopupData(row);
|
||||||
|
setDetailPopupOpen(true);
|
||||||
|
setAdditionalDetailData(null);
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
|
||||||
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
|
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("추가 데이터 로드 실패:", error);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.rowDetailPopup],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 값 포맷팅 함수
|
||||||
|
const formatValue = (value: any, format?: string): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "date":
|
||||||
|
return new Date(value).toLocaleDateString("ko-KR");
|
||||||
|
case "datetime":
|
||||||
|
return new Date(value).toLocaleString("ko-KR");
|
||||||
|
case "number":
|
||||||
|
return Number(value).toLocaleString("ko-KR");
|
||||||
|
case "currency":
|
||||||
|
return `${Number(value).toLocaleString("ko-KR")}원`;
|
||||||
|
case "boolean":
|
||||||
|
return value ? "예" : "아니오";
|
||||||
|
case "distance":
|
||||||
|
return typeof value === "number" ? `${value.toFixed(1)} km` : String(value);
|
||||||
|
case "duration":
|
||||||
|
return typeof value === "number" ? `${value}분` : String(value);
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아이콘 렌더링
|
||||||
|
const renderIcon = (icon?: string, color?: string) => {
|
||||||
|
const colorClass =
|
||||||
|
color === "blue"
|
||||||
|
? "text-blue-600"
|
||||||
|
: color === "orange"
|
||||||
|
? "text-orange-600"
|
||||||
|
: color === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: color === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: color === "purple"
|
||||||
|
? "text-purple-600"
|
||||||
|
: "text-gray-600";
|
||||||
|
|
||||||
|
switch (icon) {
|
||||||
|
case "truck":
|
||||||
|
return <Truck className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "clock":
|
||||||
|
return <Clock className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "map":
|
||||||
|
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "package":
|
||||||
|
return <Package className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
default:
|
||||||
|
return <Info className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 렌더링
|
||||||
|
const renderFieldGroup = (group: FieldGroup, data: Record<string, any>) => {
|
||||||
|
const colorClass =
|
||||||
|
group.color === "blue"
|
||||||
|
? "text-blue-600"
|
||||||
|
: group.color === "orange"
|
||||||
|
? "text-orange-600"
|
||||||
|
: group.color === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: group.color === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: group.color === "purple"
|
||||||
|
? "text-purple-600"
|
||||||
|
: "text-gray-600";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="rounded-lg border p-4">
|
||||||
|
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
|
||||||
|
{renderIcon(group.icon, group.color)}
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
||||||
|
{group.fields.map((field) => (
|
||||||
|
<div key={field.column} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium break-words">{formatValue(data[field.column], field.format)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 필드 그룹 생성 (설정이 없을 경우)
|
||||||
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
|
const groups: FieldGroup[] = [];
|
||||||
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
|
||||||
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
|
// DisplayColumnConfig 형식 지원
|
||||||
|
basicFields = displayColumns
|
||||||
|
.map((colConfig) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return { column, label };
|
||||||
|
})
|
||||||
|
.filter((item) => item.column in row);
|
||||||
|
} else {
|
||||||
|
// 전체 컬럼
|
||||||
|
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: "basic",
|
||||||
|
title: "기본 정보",
|
||||||
|
icon: "info",
|
||||||
|
color: "gray",
|
||||||
|
fields: basicFields.map((item) => ({
|
||||||
|
column: item.column,
|
||||||
|
label: item.label,
|
||||||
|
format: "text",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
||||||
|
if (additional && Object.keys(additional).length > 0) {
|
||||||
|
// 운행 정보
|
||||||
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
|
groups.push({
|
||||||
|
id: "trip",
|
||||||
|
title: "운행 정보",
|
||||||
|
icon: "truck",
|
||||||
|
color: "blue",
|
||||||
|
fields: [
|
||||||
|
{ column: "last_trip_start", label: "시작", format: "datetime" },
|
||||||
|
{ column: "last_trip_end", label: "종료", format: "datetime" },
|
||||||
|
{ column: "last_trip_distance", label: "거리", format: "distance" },
|
||||||
|
{ column: "last_trip_time", label: "시간", format: "duration" },
|
||||||
|
{ column: "departure", label: "출발지", format: "text" },
|
||||||
|
{ column: "arrival", label: "도착지", format: "text" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차 정보
|
||||||
|
if (additional.last_empty_start) {
|
||||||
|
groups.push({
|
||||||
|
id: "empty",
|
||||||
|
title: "공차 정보",
|
||||||
|
icon: "package",
|
||||||
|
color: "orange",
|
||||||
|
fields: [
|
||||||
|
{ column: "last_empty_start", label: "시작", format: "datetime" },
|
||||||
|
{ column: "last_empty_end", label: "종료", format: "datetime" },
|
||||||
|
{ column: "last_empty_distance", label: "거리", format: "distance" },
|
||||||
|
{ column: "last_empty_time", label: "시간", format: "duration" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
// 데이터 로드
|
// 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|
@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedRows.map((row, idx) => (
|
paginatedRows.map((row, idx) => (
|
||||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
<TableRow
|
||||||
|
key={idx}
|
||||||
|
className={`${config.stripedRows ? "" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-muted/50" : ""}`}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
{displayColumns
|
{displayColumns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
|
|
@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{paginatedRows.map((row, idx) => (
|
{paginatedRows.map((row, idx) => (
|
||||||
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
<Card
|
||||||
|
key={idx}
|
||||||
|
className={`p-4 transition-shadow hover:shadow-md ${config.rowDetailPopup?.enabled ? "cursor-pointer" : ""}`}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{displayColumns
|
{displayColumns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
|
|
@ -345,6 +577,49 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 행 상세 팝업 */}
|
||||||
|
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{detailPopupLoading
|
||||||
|
? "추가 정보를 로딩 중입니다..."
|
||||||
|
: detailPopupData
|
||||||
|
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
|
||||||
|
: "선택된 항목의 상세 정보입니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailPopupLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{detailPopupData && (
|
||||||
|
<>
|
||||||
|
{/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */}
|
||||||
|
{config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0
|
||||||
|
? // 설정된 필드 그룹 렌더링
|
||||||
|
config.rowDetailPopup.fieldGroups.map((group) =>
|
||||||
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
||||||
|
)
|
||||||
|
: // 기본 필드 그룹 렌더링
|
||||||
|
getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) =>
|
||||||
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDetailPopupOpen(false)}>닫기</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -526,7 +526,8 @@ function MaterialBox({
|
||||||
case "location-temp":
|
case "location-temp":
|
||||||
case "location-dest":
|
case "location-dest":
|
||||||
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||||
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
|
// 자재가 없으면 0, 있으면 해당 개수 표시 (기본값 5 제거)
|
||||||
|
const locPlateCount = placement.material_count ?? placement.quantity ?? 0;
|
||||||
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
||||||
const locPlateThickness = 0.15; // 각 철판 두께
|
const locPlateThickness = 0.15; // 각 철판 두께
|
||||||
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
||||||
|
|
@ -538,8 +539,32 @@ function MaterialBox({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
{/* 자재가 없을 때: 흰색 실선 테두리 바닥판 */}
|
||||||
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
{locPlateCount === 0 && (
|
||||||
|
<>
|
||||||
|
{/* 얇은 흰색 바닥판 */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth, 0.05, boxDepth]}
|
||||||
|
position={[0, locYOffset + 0.025, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f5f5f5"
|
||||||
|
roughness={0.6}
|
||||||
|
metalness={0.1}
|
||||||
|
emissive={isSelected ? "#e5e5e5" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 흰색 실선 테두리 */}
|
||||||
|
<lineSegments position={[0, locYOffset + 0.06, 0]}>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, 0.05, boxDepth)]} />
|
||||||
|
<lineBasicMaterial color="#9ca3af" linewidth={2} />
|
||||||
|
</lineSegments>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 */}
|
||||||
|
{locPlateCount > 0 && Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
||||||
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
||||||
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
||||||
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||||
|
|
@ -570,7 +595,7 @@ function MaterialBox({
|
||||||
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||||
{placement.name && (
|
{placement.name && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
|
position={[0, locYOffset + (locPlateCount > 0 ? locVisibleStackHeight : 0.1) + 0.3, boxDepth * 0.3]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||||
color="#374151"
|
color="#374151"
|
||||||
|
|
|
||||||
|
|
@ -115,24 +115,33 @@ export function TableHistoryModal({
|
||||||
const getOperationBadge = (type: string) => {
|
const getOperationBadge = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "INSERT":
|
case "INSERT":
|
||||||
return <Badge className="bg-green-100 text-xs text-green-800">추가</Badge>;
|
return <span className="text-sm font-medium text-green-600">추가</span>;
|
||||||
case "UPDATE":
|
case "UPDATE":
|
||||||
return <Badge className="bg-blue-100 text-xs text-blue-800">수정</Badge>;
|
return <span className="text-sm font-medium text-blue-600">수정</span>;
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return <Badge className="bg-red-100 text-xs text-red-800">삭제</Badge>;
|
return <span className="text-sm font-medium text-red-600">삭제</span>;
|
||||||
default:
|
default:
|
||||||
return (
|
return <span className="text-sm font-medium text-gray-600">{type}</span>;
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{type}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
try {
|
try {
|
||||||
// DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환
|
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
// 🚨 타임존 보정 로직
|
||||||
|
// 실 서비스 DB는 UTC로 저장되는데, 프론트엔드에서 이를 KST로 인식하지 못하고
|
||||||
|
// UTC 시간 그대로(예: 02:55)를 한국 시간 02:55로 보여주는 문제가 있음 (9시간 느림).
|
||||||
|
// 반면 로컬 DB는 이미 KST로 저장되어 있어서 변환하면 안 됨.
|
||||||
|
// 따라서 로컬 환경이 아닐 때만 강제로 9시간을 더해줌.
|
||||||
|
const isLocal =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
||||||
|
|
||||||
|
if (!isLocal) {
|
||||||
|
date.setHours(date.getHours() + 9);
|
||||||
|
}
|
||||||
|
|
||||||
return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
||||||
} catch {
|
} catch {
|
||||||
return dateString;
|
return dateString;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource, FieldGroup } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react";
|
||||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
|
||||||
|
|
@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 행 상세 팝업 상태
|
||||||
|
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
||||||
|
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
|
||||||
|
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
|
||||||
|
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
// // console.log("🧪 ListTestWidget 렌더링!", element);
|
// // console.log("🧪 ListTestWidget 렌더링!", element);
|
||||||
|
|
||||||
const dataSources = useMemo(() => {
|
const dataSources = useMemo(() => {
|
||||||
|
|
@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
cardColumns: 3,
|
cardColumns: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 행 클릭 핸들러 - 팝업 열기
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
async (row: Record<string, any>) => {
|
||||||
|
// 팝업이 비활성화되어 있으면 무시
|
||||||
|
if (!config.rowDetailPopup?.enabled) return;
|
||||||
|
|
||||||
|
setDetailPopupData(row);
|
||||||
|
setDetailPopupOpen(true);
|
||||||
|
setAdditionalDetailData(null);
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
|
||||||
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
|
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("추가 데이터 로드 실패:", err);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.rowDetailPopup],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 값 포맷팅 함수
|
||||||
|
const formatValue = (value: any, format?: string): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "date":
|
||||||
|
return new Date(value).toLocaleDateString("ko-KR");
|
||||||
|
case "datetime":
|
||||||
|
return new Date(value).toLocaleString("ko-KR");
|
||||||
|
case "number":
|
||||||
|
return Number(value).toLocaleString("ko-KR");
|
||||||
|
case "currency":
|
||||||
|
return `${Number(value).toLocaleString("ko-KR")}원`;
|
||||||
|
case "boolean":
|
||||||
|
return value ? "예" : "아니오";
|
||||||
|
case "distance":
|
||||||
|
return typeof value === "number" ? `${value.toFixed(1)} km` : String(value);
|
||||||
|
case "duration":
|
||||||
|
return typeof value === "number" ? `${value}분` : String(value);
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아이콘 렌더링
|
||||||
|
const renderIcon = (icon?: string, color?: string) => {
|
||||||
|
const colorClass =
|
||||||
|
color === "blue"
|
||||||
|
? "text-blue-600"
|
||||||
|
: color === "orange"
|
||||||
|
? "text-orange-600"
|
||||||
|
: color === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: color === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: color === "purple"
|
||||||
|
? "text-purple-600"
|
||||||
|
: "text-gray-600";
|
||||||
|
|
||||||
|
switch (icon) {
|
||||||
|
case "truck":
|
||||||
|
return <Truck className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "clock":
|
||||||
|
return <Clock className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "map":
|
||||||
|
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "package":
|
||||||
|
return <Package className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
default:
|
||||||
|
return <Info className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 렌더링
|
||||||
|
const renderFieldGroup = (group: FieldGroup, groupData: Record<string, any>) => {
|
||||||
|
const colorClass =
|
||||||
|
group.color === "blue"
|
||||||
|
? "text-blue-600"
|
||||||
|
: group.color === "orange"
|
||||||
|
? "text-orange-600"
|
||||||
|
: group.color === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: group.color === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: group.color === "purple"
|
||||||
|
? "text-purple-600"
|
||||||
|
: "text-gray-600";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="rounded-lg border p-4">
|
||||||
|
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
|
||||||
|
{renderIcon(group.icon, group.color)}
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
||||||
|
{group.fields.map((field) => (
|
||||||
|
<div key={field.column} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium break-words">{formatValue(groupData[field.column], field.format)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 필드 그룹 생성 (설정이 없을 경우)
|
||||||
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
|
const groups: FieldGroup[] = [];
|
||||||
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
|
||||||
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
|
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
||||||
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
|
// DisplayColumnConfig 형식 지원
|
||||||
|
basicFields = displayColumns
|
||||||
|
.map((colConfig) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return { column, label };
|
||||||
|
})
|
||||||
|
.filter((item) => allKeys.includes(item.column));
|
||||||
|
} else {
|
||||||
|
// 전체 컬럼
|
||||||
|
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: "basic",
|
||||||
|
title: "기본 정보",
|
||||||
|
icon: "info",
|
||||||
|
color: "gray",
|
||||||
|
fields: basicFields.map((item) => ({
|
||||||
|
column: item.column,
|
||||||
|
label: item.label,
|
||||||
|
format: "text" as const,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
||||||
|
if (additional && Object.keys(additional).length > 0) {
|
||||||
|
// 운행 정보
|
||||||
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
|
groups.push({
|
||||||
|
id: "trip",
|
||||||
|
title: "운행 정보",
|
||||||
|
icon: "truck",
|
||||||
|
color: "blue",
|
||||||
|
fields: [
|
||||||
|
{ column: "last_trip_start", label: "시작", format: "datetime" as const },
|
||||||
|
{ column: "last_trip_end", label: "종료", format: "datetime" as const },
|
||||||
|
{ column: "last_trip_distance", label: "거리", format: "distance" as const },
|
||||||
|
{ column: "last_trip_time", label: "시간", format: "duration" as const },
|
||||||
|
{ column: "departure", label: "출발지", format: "text" as const },
|
||||||
|
{ column: "arrival", label: "도착지", format: "text" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차 정보
|
||||||
|
if (additional.last_empty_start) {
|
||||||
|
groups.push({
|
||||||
|
id: "empty",
|
||||||
|
title: "공차 정보",
|
||||||
|
icon: "package",
|
||||||
|
color: "orange",
|
||||||
|
fields: [
|
||||||
|
{ column: "last_empty_start", label: "시작", format: "datetime" as const },
|
||||||
|
{ column: "last_empty_end", label: "종료", format: "datetime" as const },
|
||||||
|
{ column: "last_empty_distance", label: "거리", format: "distance" as const },
|
||||||
|
{ column: "last_empty_time", label: "시간", format: "duration" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
// visible 컬럼 설정 객체 배열 (field + label)
|
// visible 컬럼 설정 객체 배열 (field + label)
|
||||||
const visibleColumnConfigs = useMemo(() => {
|
const visibleColumnConfigs = useMemo(() => {
|
||||||
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
||||||
|
|
@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
)}
|
)}
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedRows.map((row, idx) => (
|
{paginatedRows.map((row, idx) => (
|
||||||
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
|
<TableRow
|
||||||
|
key={idx}
|
||||||
|
className={`${config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-accent" : ""}`}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
{displayColumns.map((field) => (
|
{displayColumns.map((field) => (
|
||||||
<TableCell key={field} className="whitespace-nowrap">
|
<TableCell key={field} className="whitespace-nowrap">
|
||||||
{String(row[field] ?? "")}
|
{String(row[field] ?? "")}
|
||||||
|
|
@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
||||||
{paginatedRows.map((row, idx) => (
|
{paginatedRows.map((row, idx) => (
|
||||||
<Card key={idx} className="p-4">
|
<Card
|
||||||
|
key={idx}
|
||||||
|
className={`p-4 ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-shadow hover:shadow-md" : ""}`}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
{displayColumns.map((field) => (
|
{displayColumns.map((field) => (
|
||||||
<div key={field} className="mb-2">
|
<div key={field} className="mb-2">
|
||||||
<span className="font-semibold">{getLabel(field)}: </span>
|
<span className="font-semibold">{getLabel(field)}: </span>
|
||||||
|
|
@ -489,6 +721,49 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 행 상세 팝업 */}
|
||||||
|
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{detailPopupLoading
|
||||||
|
? "추가 정보를 로딩 중입니다..."
|
||||||
|
: detailPopupData
|
||||||
|
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
|
||||||
|
: "선택된 항목의 상세 정보입니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailPopupLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{detailPopupData && (
|
||||||
|
<>
|
||||||
|
{/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */}
|
||||||
|
{config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0
|
||||||
|
? // 설정된 필드 그룹 렌더링
|
||||||
|
config.rowDetailPopup.fieldGroups.map((group) =>
|
||||||
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
||||||
|
)
|
||||||
|
: // 기본 필드 그룹 렌더링
|
||||||
|
getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) =>
|
||||||
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDetailPopupOpen(false)}>닫기</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
import { regionOptions, filterVehiclesByRegion } from "@/lib/constants/regionBounds";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
// Popup 말풍선 꼬리 제거 스타일
|
// Popup 말풍선 꼬리 제거 스타일
|
||||||
|
|
@ -101,6 +103,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [routeLoading, setRouteLoading] = useState(false);
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
||||||
|
|
||||||
|
// 공차/운행 정보 상태
|
||||||
|
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});
|
||||||
|
const [tripInfoLoading, setTripInfoLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Popup 열림 상태 (자동 새로고침 일시 중지용)
|
||||||
|
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||||
|
|
||||||
|
// 지역 필터 상태
|
||||||
|
const [selectedRegion, setSelectedRegion] = useState<string>("all");
|
||||||
|
|
||||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||||
const dataSources = useMemo(() => {
|
const dataSources = useMemo(() => {
|
||||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
|
@ -182,6 +194,151 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
setRoutePoints([]);
|
setRoutePoints([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 공차/운행 정보 로드 함수
|
||||||
|
const loadTripInfo = useCallback(async (identifier: string) => {
|
||||||
|
if (!identifier || tripInfo[identifier]) {
|
||||||
|
return; // 이미 로드됨
|
||||||
|
}
|
||||||
|
|
||||||
|
setTripInfoLoading(identifier);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// user_id 또는 vehicle_number로 조회
|
||||||
|
const query = `SELECT
|
||||||
|
id, vehicle_number, user_id,
|
||||||
|
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
||||||
|
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
||||||
|
departure, arrival, status
|
||||||
|
FROM vehicles
|
||||||
|
WHERE user_id = '${identifier}'
|
||||||
|
OR vehicle_number = '${identifier}'
|
||||||
|
LIMIT 1`;
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.data.rows.length > 0) {
|
||||||
|
setTripInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[identifier]: result.data.rows[0],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 데이터가 없는 경우에도 "로드 완료" 상태로 표시 (빈 객체 저장)
|
||||||
|
setTripInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[identifier]: { _noData: true },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// API 실패 시에도 "로드 완료" 상태로 표시
|
||||||
|
setTripInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[identifier]: { _noData: true },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공차/운행 정보 로드 실패:", err);
|
||||||
|
// 에러 시에도 "로드 완료" 상태로 표시
|
||||||
|
setTripInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[identifier]: { _noData: true },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTripInfoLoading(null);
|
||||||
|
}, [tripInfo]);
|
||||||
|
|
||||||
|
// 마커 로드 시 운행/공차 정보 미리 일괄 조회
|
||||||
|
const preloadTripInfo = useCallback(async (loadedMarkers: MarkerData[]) => {
|
||||||
|
if (!loadedMarkers || loadedMarkers.length === 0) return;
|
||||||
|
|
||||||
|
// 마커에서 identifier 추출 (user_id 또는 vehicle_number)
|
||||||
|
const identifiers: string[] = [];
|
||||||
|
loadedMarkers.forEach((marker) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(marker.description || "{}");
|
||||||
|
const identifier = parsed.user_id || parsed.vehicle_number || parsed.id;
|
||||||
|
if (identifier && !tripInfo[identifier]) {
|
||||||
|
identifiers.push(identifier);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 파싱 실패 시 무시
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (identifiers.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 마커의 운행/공차 정보를 한 번에 조회
|
||||||
|
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
const query = `SELECT
|
||||||
|
id, vehicle_number, user_id,
|
||||||
|
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
||||||
|
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
||||||
|
departure, arrival, status
|
||||||
|
FROM vehicles
|
||||||
|
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
|
||||||
|
OR vehicle_number IN (${identifiers.map(id => `'${id}'`).join(", ")})`;
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.data.rows.length > 0) {
|
||||||
|
const newTripInfo: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 조회된 데이터를 identifier별로 매핑
|
||||||
|
result.data.rows.forEach((row: any) => {
|
||||||
|
const hasData = row.last_trip_start || row.last_trip_end ||
|
||||||
|
row.last_trip_distance || row.last_trip_time ||
|
||||||
|
row.last_empty_start || row.last_empty_end ||
|
||||||
|
row.last_empty_distance || row.last_empty_time;
|
||||||
|
|
||||||
|
if (row.user_id) {
|
||||||
|
newTripInfo[row.user_id] = hasData ? row : { _noData: true };
|
||||||
|
}
|
||||||
|
if (row.vehicle_number) {
|
||||||
|
newTripInfo[row.vehicle_number] = hasData ? row : { _noData: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 조회되지 않은 identifier는 _noData로 표시
|
||||||
|
identifiers.forEach((id) => {
|
||||||
|
if (!newTripInfo[id]) {
|
||||||
|
newTripInfo[id] = { _noData: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTripInfo((prev) => ({ ...prev, ...newTripInfo }));
|
||||||
|
} else {
|
||||||
|
// 결과가 없으면 모든 identifier를 _noData로 표시
|
||||||
|
const noDataInfo: Record<string, any> = {};
|
||||||
|
identifiers.forEach((id) => {
|
||||||
|
noDataInfo[id] = { _noData: true };
|
||||||
|
});
|
||||||
|
setTripInfo((prev) => ({ ...prev, ...noDataInfo }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("운행/공차 정보 미리 로드 실패:", err);
|
||||||
|
}
|
||||||
|
}, [tripInfo]);
|
||||||
|
|
||||||
// 다중 데이터 소스 로딩
|
// 다중 데이터 소스 로딩
|
||||||
const loadMultipleDataSources = useCallback(async () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
if (!dataSources || dataSources.length === 0) {
|
if (!dataSources || dataSources.length === 0) {
|
||||||
|
|
@ -254,6 +411,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
setMarkers(markersWithHeading);
|
setMarkers(markersWithHeading);
|
||||||
setPolygons(allPolygons);
|
setPolygons(allPolygons);
|
||||||
setLastRefreshTime(new Date());
|
setLastRefreshTime(new Date());
|
||||||
|
|
||||||
|
// 마커 로드 후 운행/공차 정보 미리 일괄 조회
|
||||||
|
preloadTripInfo(markersWithHeading);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1130,14 +1290,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
loadMultipleDataSources();
|
// Popup이 열려있으면 자동 새로고침 건너뛰기
|
||||||
|
if (!isPopupOpen) {
|
||||||
|
loadMultipleDataSources();
|
||||||
|
}
|
||||||
}, refreshInterval * 1000);
|
}, refreshInterval * 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
}, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]);
|
||||||
|
|
||||||
// 타일맵 URL (VWorld 한국 지도)
|
// 타일맵 URL (VWorld 한국 지도)
|
||||||
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||||||
|
|
@ -1165,6 +1328,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 지역 필터 */}
|
||||||
|
<Select value={selectedRegion} onValueChange={setSelectedRegion}>
|
||||||
|
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||||||
|
<SelectValue placeholder="지역 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{regionOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* 이동경로 날짜 선택 */}
|
{/* 이동경로 날짜 선택 */}
|
||||||
{selectedUserId && (
|
{selectedUserId && (
|
||||||
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
||||||
|
|
@ -1371,6 +1548,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
fillOpacity: 0.3,
|
fillOpacity: 0.3,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
}}
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
popupopen: () => setIsPopupOpen(true),
|
||||||
|
popupclose: () => setIsPopupOpen(false),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="min-w-[200px]">
|
<div className="min-w-[200px]">
|
||||||
|
|
@ -1442,8 +1623,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 마커 렌더링 */}
|
{/* 마커 렌더링 (지역 필터 적용) */}
|
||||||
{markers.map((marker) => {
|
{filterVehiclesByRegion(markers, selectedRegion).map((marker) => {
|
||||||
// 마커의 소스에 해당하는 데이터 소스 찾기
|
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||||||
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
|
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
|
||||||
const markerType = sourceDataSource?.markerType || "circle";
|
const markerType = sourceDataSource?.markerType || "circle";
|
||||||
|
|
@ -1602,7 +1783,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker key={marker.id} position={[marker.lat, marker.lng]} icon={markerIcon}>
|
<Marker
|
||||||
|
key={marker.id}
|
||||||
|
position={[marker.lat, marker.lng]}
|
||||||
|
icon={markerIcon}
|
||||||
|
eventHandlers={{
|
||||||
|
popupopen: () => setIsPopupOpen(true),
|
||||||
|
popupclose: () => setIsPopupOpen(false),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Popup maxWidth={350}>
|
<Popup maxWidth={350}>
|
||||||
<div className="max-w-[350px] min-w-[250px]" dir="ltr">
|
<div className="max-w-[350px] min-w-[250px]" dir="ltr">
|
||||||
{/* 데이터 소스명만 표시 */}
|
{/* 데이터 소스명만 표시 */}
|
||||||
|
|
@ -1713,6 +1902,161 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* 공차/운행 정보 (동적 로딩) */}
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(marker.description || "{}");
|
||||||
|
|
||||||
|
// 식별자 찾기 (user_id 또는 vehicle_number)
|
||||||
|
const identifier = parsed.user_id || parsed.userId || parsed.vehicle_number ||
|
||||||
|
parsed.vehicleNumber || parsed.plate_no || parsed.plateNo ||
|
||||||
|
parsed.car_number || parsed.carNumber || marker.name;
|
||||||
|
|
||||||
|
if (!identifier) return null;
|
||||||
|
|
||||||
|
// 동적으로 로드된 정보 또는 marker.description에서 가져온 정보 사용
|
||||||
|
const info = tripInfo[identifier] || parsed;
|
||||||
|
|
||||||
|
// 공차 정보가 있는지 확인
|
||||||
|
const hasEmptyTripInfo = info.last_empty_start || info.last_empty_end ||
|
||||||
|
info.last_empty_distance || info.last_empty_time;
|
||||||
|
// 운행 정보가 있는지 확인
|
||||||
|
const hasTripInfo = info.last_trip_start || info.last_trip_end ||
|
||||||
|
info.last_trip_distance || info.last_trip_time;
|
||||||
|
|
||||||
|
// 날짜/시간 포맷팅 함수
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString("ko-KR", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 거리 포맷팅 (km)
|
||||||
|
const formatDistance = (dist: number | string) => {
|
||||||
|
if (dist === null || dist === undefined) return "-";
|
||||||
|
const num = typeof dist === "string" ? parseFloat(dist) : dist;
|
||||||
|
if (isNaN(num)) return "-";
|
||||||
|
return `${num.toFixed(1)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시간 포맷팅 (분)
|
||||||
|
const formatTime = (minutes: number | string) => {
|
||||||
|
if (minutes === null || minutes === undefined) return "-";
|
||||||
|
const num = typeof minutes === "string" ? parseInt(minutes) : minutes;
|
||||||
|
if (isNaN(num)) return "-";
|
||||||
|
if (num < 60) return `${num}분`;
|
||||||
|
const hours = Math.floor(num / 60);
|
||||||
|
const mins = num % 60;
|
||||||
|
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미 로드했는데 데이터가 없는 경우 (버튼 숨김)
|
||||||
|
const loadedInfo = tripInfo[identifier];
|
||||||
|
if (loadedInfo && loadedInfo._noData) {
|
||||||
|
return null; // 데이터 없음 - 버튼도 정보도 표시 안 함
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시
|
||||||
|
if (!hasEmptyTripInfo && !hasTripInfo && !tripInfo[identifier]) {
|
||||||
|
return (
|
||||||
|
<div className="border-t pt-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => loadTripInfo(identifier)}
|
||||||
|
disabled={tripInfoLoading === identifier}
|
||||||
|
className="w-full rounded bg-gray-100 px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{tripInfoLoading === identifier ? "로딩 중..." : "📊 운행/공차 정보 보기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터가 없으면 표시 안 함
|
||||||
|
if (!hasEmptyTripInfo && !hasTripInfo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t pt-2 mt-2">
|
||||||
|
{/* 운행 정보 */}
|
||||||
|
{hasTripInfo && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="text-xs font-semibold text-blue-600 mb-1">🚛 최근 운행</div>
|
||||||
|
<div className="bg-blue-50 rounded p-2 space-y-1">
|
||||||
|
{(info.last_trip_start || info.last_trip_end) && (
|
||||||
|
<div className="text-[10px] text-gray-600">
|
||||||
|
<span className="font-medium">시간:</span>{" "}
|
||||||
|
{formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 text-[10px]">
|
||||||
|
{info.last_trip_distance !== undefined && info.last_trip_distance !== null && (
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-600">거리:</span>{" "}
|
||||||
|
<span className="text-blue-700 font-semibold">{formatDistance(info.last_trip_distance)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{info.last_trip_time !== undefined && info.last_trip_time !== null && (
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-600">소요:</span>{" "}
|
||||||
|
<span className="text-blue-700 font-semibold">{formatTime(info.last_trip_time)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 출발지/도착지 */}
|
||||||
|
{(info.departure || info.arrival) && (
|
||||||
|
<div className="text-[10px] text-gray-600 pt-1 border-t border-blue-100">
|
||||||
|
{info.departure && <span>출발: {info.departure}</span>}
|
||||||
|
{info.departure && info.arrival && " → "}
|
||||||
|
{info.arrival && <span>도착: {info.arrival}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 공차 정보 */}
|
||||||
|
{hasEmptyTripInfo && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-orange-600 mb-1">📦 최근 공차</div>
|
||||||
|
<div className="bg-orange-50 rounded p-2 space-y-1">
|
||||||
|
{(info.last_empty_start || info.last_empty_end) && (
|
||||||
|
<div className="text-[10px] text-gray-600">
|
||||||
|
<span className="font-medium">시간:</span>{" "}
|
||||||
|
{formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 text-[10px]">
|
||||||
|
{info.last_empty_distance !== undefined && info.last_empty_distance !== null && (
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-600">거리:</span>{" "}
|
||||||
|
<span className="text-orange-700 font-semibold">{formatDistance(info.last_empty_distance)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{info.last_empty_time !== undefined && info.last_empty_time !== null && (
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-gray-600">소요:</span>{" "}
|
||||||
|
<span className="text-orange-700 font-semibold">{formatTime(info.last_empty_time)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 좌표 */}
|
{/* 좌표 */}
|
||||||
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||||
|
|
@ -1771,7 +2115,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
{/* 하단 정보 */}
|
{/* 하단 정보 */}
|
||||||
{(markers.length > 0 || polygons.length > 0) && (
|
{(markers.length > 0 || polygons.length > 0) && (
|
||||||
<div className="text-muted-foreground border-t p-2 text-xs">
|
<div className="text-muted-foreground border-t p-2 text-xs">
|
||||||
{markers.length > 0 && `마커 ${markers.length}개`}
|
{markers.length > 0 && (
|
||||||
|
<>
|
||||||
|
마커 {filterVehiclesByRegion(markers, selectedRegion).length}개
|
||||||
|
{selectedRegion !== "all" && ` (전체 ${markers.length}개)`}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{markers.length > 0 && polygons.length > 0 && " · "}
|
{markers.length > 0 && polygons.length > 0 && " · "}
|
||||||
{polygons.length > 0 && `영역 ${polygons.length}개`}
|
{polygons.length > 0 && `영역 ${polygons.length}개`}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||||
|
|
@ -130,11 +130,11 @@ export function FlowDataListModal({
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<ResizableDialogTitle className="flex items-center gap-2">
|
||||||
{stepName}
|
{stepName}
|
||||||
<Badge variant="secondary">{data.length}건</Badge>
|
<Badge variant="secondary">{data.length}건</Badge>
|
||||||
</DialogTitle>
|
</ResizableDialogTitle>
|
||||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</DialogDescription>
|
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</ResizableDialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,20 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
||||||
<Select
|
<Select
|
||||||
value={part.partType}
|
value={part.partType}
|
||||||
onValueChange={(value) => onUpdate({ partType: value as CodePartType })}
|
onValueChange={(value) => {
|
||||||
|
const newPartType = value as CodePartType;
|
||||||
|
// 타입 변경 시 해당 타입의 기본 autoConfig 설정
|
||||||
|
const defaultAutoConfig: Record<string, any> = {
|
||||||
|
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||||
|
number: { numberLength: 4, numberValue: 1 },
|
||||||
|
date: { dateFormat: "YYYYMMDD" },
|
||||||
|
text: { textValue: "CODE" },
|
||||||
|
};
|
||||||
|
onUpdate({
|
||||||
|
partType: newPartType,
|
||||||
|
autoConfig: defaultAutoConfig[newPartType] || {}
|
||||||
|
});
|
||||||
|
}}
|
||||||
disabled={isPreview}
|
disabled={isPreview}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,31 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
try {
|
try {
|
||||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||||
|
|
||||||
|
// 파트별 기본 autoConfig 정의
|
||||||
|
const defaultAutoConfigs: Record<string, any> = {
|
||||||
|
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||||
|
number: { numberLength: 4, numberValue: 1 },
|
||||||
|
date: { dateFormat: "YYYYMMDD" },
|
||||||
|
text: { textValue: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
|
||||||
|
const partsWithDefaults = currentRule.parts.map((part) => {
|
||||||
|
if (part.generationMethod === "auto") {
|
||||||
|
const defaults = defaultAutoConfigs[part.partType] || {};
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
autoConfig: { ...defaults, ...part.autoConfig },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||||
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
||||||
const ruleToSave = {
|
const ruleToSave = {
|
||||||
...currentRule,
|
...currentRule,
|
||||||
|
parts: partsWithDefaults,
|
||||||
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -62,7 +62,7 @@ import {
|
||||||
CostType,
|
CostType,
|
||||||
costTypeLabels,
|
costTypeLabels,
|
||||||
} from "@/lib/api/taxInvoice";
|
} from "@/lib/api/taxInvoice";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { uploadFiles } from "@/lib/api/file";
|
||||||
|
|
||||||
interface TaxInvoiceFormProps {
|
interface TaxInvoiceFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -223,36 +223,35 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 파일 업로드
|
// 파일 업로드 (화면 관리 파일 업로드 컴포넌트와 동일한 방식 사용)
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
for (const file of Array.from(files)) {
|
// 화면 관리 파일 업로드 컴포넌트와 동일한 uploadFiles 함수 사용
|
||||||
const formDataUpload = new FormData();
|
const response = await uploadFiles({
|
||||||
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
|
files: files,
|
||||||
formDataUpload.append("category", "tax-invoice");
|
tableName: "tax_invoice",
|
||||||
|
fieldName: "attachments",
|
||||||
|
recordId: invoice?.id,
|
||||||
|
docType: "tax-invoice",
|
||||||
|
docTypeName: "세금계산서",
|
||||||
|
});
|
||||||
|
|
||||||
const response = await apiClient.post("/files/upload", formDataUpload, {
|
if (response.success && response.files?.length > 0) {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
const newAttachments: TaxInvoiceAttachment[] = response.files.map((uploadedFile) => ({
|
||||||
});
|
id: uploadedFile.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
file_name: uploadedFile.name,
|
||||||
if (response.data.success && response.data.files?.length > 0) {
|
file_path: uploadedFile.serverPath || "",
|
||||||
const uploadedFile = response.data.files[0];
|
file_size: uploadedFile.size,
|
||||||
const newAttachment: TaxInvoiceAttachment = {
|
file_type: uploadedFile.type,
|
||||||
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
uploaded_at: uploadedFile.uploadedAt || new Date().toISOString(),
|
||||||
file_name: uploadedFile.realFileName || file.name,
|
uploaded_by: "",
|
||||||
file_path: uploadedFile.filePath,
|
}));
|
||||||
file_size: uploadedFile.fileSize || file.size,
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||||
file_type: file.type,
|
toast.success(`${response.files.length}개 파일 업로드 완료`);
|
||||||
uploaded_at: new Date().toISOString(),
|
|
||||||
uploaded_by: "",
|
|
||||||
};
|
|
||||||
setAttachments((prev) => [...prev, newAttachment]);
|
|
||||||
toast.success(`'${file.name}' 업로드 완료`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error("파일 업로드 실패", { description: error.message });
|
toast.error("파일 업로드 실패", { description: error.message });
|
||||||
|
|
|
||||||
|
|
@ -525,3 +525,37 @@ export async function getFlowAuditLogs(flowId: number, limit: number = 100): Pro
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 플로우 스텝 데이터 수정 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 스텝 데이터 업데이트 (인라인 편집)
|
||||||
|
* @param flowId 플로우 정의 ID
|
||||||
|
* @param stepId 스텝 ID
|
||||||
|
* @param recordId 레코드의 primary key 값
|
||||||
|
* @param updateData 업데이트할 데이터
|
||||||
|
*/
|
||||||
|
export async function updateFlowStepData(
|
||||||
|
flowId: number,
|
||||||
|
stepId: number,
|
||||||
|
recordId: string | number,
|
||||||
|
updateData: Record<string, any>,
|
||||||
|
): Promise<ApiResponse<{ success: boolean }>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/data/${recordId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
/**
|
||||||
|
* 전국 시/도별 좌표 범위 (경계 좌표)
|
||||||
|
* 차량 위치 필터링에 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RegionBounds {
|
||||||
|
south: number; // 최남단 위도
|
||||||
|
north: number; // 최북단 위도
|
||||||
|
west: number; // 최서단 경도
|
||||||
|
east: number; // 최동단 경도
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
bounds?: RegionBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전국 시/도별 좌표 범위
|
||||||
|
export const regionBounds: Record<string, RegionBounds> = {
|
||||||
|
// 서울특별시
|
||||||
|
seoul: {
|
||||||
|
south: 37.413,
|
||||||
|
north: 37.715,
|
||||||
|
west: 126.734,
|
||||||
|
east: 127.183,
|
||||||
|
},
|
||||||
|
// 부산광역시
|
||||||
|
busan: {
|
||||||
|
south: 34.879,
|
||||||
|
north: 35.389,
|
||||||
|
west: 128.758,
|
||||||
|
east: 129.314,
|
||||||
|
},
|
||||||
|
// 대구광역시
|
||||||
|
daegu: {
|
||||||
|
south: 35.601,
|
||||||
|
north: 36.059,
|
||||||
|
west: 128.349,
|
||||||
|
east: 128.761,
|
||||||
|
},
|
||||||
|
// 인천광역시
|
||||||
|
incheon: {
|
||||||
|
south: 37.166,
|
||||||
|
north: 37.592,
|
||||||
|
west: 126.349,
|
||||||
|
east: 126.775,
|
||||||
|
},
|
||||||
|
// 광주광역시
|
||||||
|
gwangju: {
|
||||||
|
south: 35.053,
|
||||||
|
north: 35.267,
|
||||||
|
west: 126.652,
|
||||||
|
east: 127.013,
|
||||||
|
},
|
||||||
|
// 대전광역시
|
||||||
|
daejeon: {
|
||||||
|
south: 36.197,
|
||||||
|
north: 36.488,
|
||||||
|
west: 127.246,
|
||||||
|
east: 127.538,
|
||||||
|
},
|
||||||
|
// 울산광역시
|
||||||
|
ulsan: {
|
||||||
|
south: 35.360,
|
||||||
|
north: 35.710,
|
||||||
|
west: 128.958,
|
||||||
|
east: 129.464,
|
||||||
|
},
|
||||||
|
// 세종특별자치시
|
||||||
|
sejong: {
|
||||||
|
south: 36.432,
|
||||||
|
north: 36.687,
|
||||||
|
west: 127.044,
|
||||||
|
east: 127.364,
|
||||||
|
},
|
||||||
|
// 경기도
|
||||||
|
gyeonggi: {
|
||||||
|
south: 36.893,
|
||||||
|
north: 38.284,
|
||||||
|
west: 126.387,
|
||||||
|
east: 127.839,
|
||||||
|
},
|
||||||
|
// 강원도 (강원특별자치도)
|
||||||
|
gangwon: {
|
||||||
|
south: 37.017,
|
||||||
|
north: 38.613,
|
||||||
|
west: 127.085,
|
||||||
|
east: 129.359,
|
||||||
|
},
|
||||||
|
// 충청북도
|
||||||
|
chungbuk: {
|
||||||
|
south: 36.012,
|
||||||
|
north: 37.261,
|
||||||
|
west: 127.282,
|
||||||
|
east: 128.657,
|
||||||
|
},
|
||||||
|
// 충청남도
|
||||||
|
chungnam: {
|
||||||
|
south: 35.972,
|
||||||
|
north: 37.029,
|
||||||
|
west: 125.927,
|
||||||
|
east: 127.380,
|
||||||
|
},
|
||||||
|
// 전라북도 (전북특별자치도)
|
||||||
|
jeonbuk: {
|
||||||
|
south: 35.287,
|
||||||
|
north: 36.133,
|
||||||
|
west: 126.392,
|
||||||
|
east: 127.923,
|
||||||
|
},
|
||||||
|
// 전라남도
|
||||||
|
jeonnam: {
|
||||||
|
south: 33.959,
|
||||||
|
north: 35.507,
|
||||||
|
west: 125.979,
|
||||||
|
east: 127.921,
|
||||||
|
},
|
||||||
|
// 경상북도
|
||||||
|
gyeongbuk: {
|
||||||
|
south: 35.571,
|
||||||
|
north: 37.144,
|
||||||
|
west: 128.113,
|
||||||
|
east: 130.922,
|
||||||
|
},
|
||||||
|
// 경상남도
|
||||||
|
gyeongnam: {
|
||||||
|
south: 34.599,
|
||||||
|
north: 35.906,
|
||||||
|
west: 127.555,
|
||||||
|
east: 129.224,
|
||||||
|
},
|
||||||
|
// 제주특별자치도
|
||||||
|
jeju: {
|
||||||
|
south: 33.106,
|
||||||
|
north: 33.959,
|
||||||
|
west: 126.117,
|
||||||
|
east: 126.978,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 지역 선택 옵션 (드롭다운용)
|
||||||
|
export const regionOptions: RegionOption[] = [
|
||||||
|
{ value: "all", label: "전체" },
|
||||||
|
{ value: "seoul", label: "서울특별시", bounds: regionBounds.seoul },
|
||||||
|
{ value: "busan", label: "부산광역시", bounds: regionBounds.busan },
|
||||||
|
{ value: "daegu", label: "대구광역시", bounds: regionBounds.daegu },
|
||||||
|
{ value: "incheon", label: "인천광역시", bounds: regionBounds.incheon },
|
||||||
|
{ value: "gwangju", label: "광주광역시", bounds: regionBounds.gwangju },
|
||||||
|
{ value: "daejeon", label: "대전광역시", bounds: regionBounds.daejeon },
|
||||||
|
{ value: "ulsan", label: "울산광역시", bounds: regionBounds.ulsan },
|
||||||
|
{ value: "sejong", label: "세종특별자치시", bounds: regionBounds.sejong },
|
||||||
|
{ value: "gyeonggi", label: "경기도", bounds: regionBounds.gyeonggi },
|
||||||
|
{ value: "gangwon", label: "강원특별자치도", bounds: regionBounds.gangwon },
|
||||||
|
{ value: "chungbuk", label: "충청북도", bounds: regionBounds.chungbuk },
|
||||||
|
{ value: "chungnam", label: "충청남도", bounds: regionBounds.chungnam },
|
||||||
|
{ value: "jeonbuk", label: "전북특별자치도", bounds: regionBounds.jeonbuk },
|
||||||
|
{ value: "jeonnam", label: "전라남도", bounds: regionBounds.jeonnam },
|
||||||
|
{ value: "gyeongbuk", label: "경상북도", bounds: regionBounds.gyeongbuk },
|
||||||
|
{ value: "gyeongnam", label: "경상남도", bounds: regionBounds.gyeongnam },
|
||||||
|
{ value: "jeju", label: "제주특별자치도", bounds: regionBounds.jeju },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표가 특정 지역 범위 내에 있는지 확인
|
||||||
|
*/
|
||||||
|
export function isInRegion(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
region: string
|
||||||
|
): boolean {
|
||||||
|
if (region === "all") return true;
|
||||||
|
|
||||||
|
const bounds = regionBounds[region];
|
||||||
|
if (!bounds) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
latitude >= bounds.south &&
|
||||||
|
latitude <= bounds.north &&
|
||||||
|
longitude >= bounds.west &&
|
||||||
|
longitude <= bounds.east
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표로 지역 찾기 (해당하는 첫 번째 지역 반환)
|
||||||
|
*/
|
||||||
|
export function findRegionByCoords(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number
|
||||||
|
): string | null {
|
||||||
|
for (const [region, bounds] of Object.entries(regionBounds)) {
|
||||||
|
if (
|
||||||
|
latitude >= bounds.south &&
|
||||||
|
latitude <= bounds.north &&
|
||||||
|
longitude >= bounds.west &&
|
||||||
|
longitude <= bounds.east
|
||||||
|
) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차량 목록을 지역별로 필터링
|
||||||
|
*/
|
||||||
|
export function filterVehiclesByRegion<
|
||||||
|
T extends { latitude?: number; longitude?: number; lat?: number; lng?: number }
|
||||||
|
>(vehicles: T[], region: string): T[] {
|
||||||
|
if (region === "all") return vehicles;
|
||||||
|
|
||||||
|
const bounds = regionBounds[region];
|
||||||
|
if (!bounds) return vehicles;
|
||||||
|
|
||||||
|
return vehicles.filter((v) => {
|
||||||
|
const lat = v.latitude ?? v.lat;
|
||||||
|
const lng = v.longitude ?? v.lng;
|
||||||
|
|
||||||
|
if (lat === undefined || lng === undefined) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
lat >= bounds.south &&
|
||||||
|
lat <= bounds.north &&
|
||||||
|
lng >= bounds.west &&
|
||||||
|
lng <= bounds.east
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지역명(한글) 가져오기
|
||||||
|
*/
|
||||||
|
export function getRegionLabel(regionValue: string): string {
|
||||||
|
const option = regionOptions.find((opt) => opt.value === regionValue);
|
||||||
|
return option?.label ?? regionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -447,7 +447,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분
|
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분
|
||||||
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
|
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
|
||||||
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
|
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
|
||||||
groupedData: props.groupedData,
|
// Note: 이 props들은 DOM 요소에 전달되면 안 됨
|
||||||
|
// 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함
|
||||||
|
_groupedData: props.groupedData,
|
||||||
|
// 🆕 UniversalFormModal용 initialData 전달
|
||||||
|
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
||||||
|
_initialData: originalData || formData,
|
||||||
|
_originalData: originalData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2, Loader2, X } from "lucide-react";
|
import { Trash2, Loader2, X, Plus } from "lucide-react";
|
||||||
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
|
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { useCalculation } from "./useCalculation";
|
import { useCalculation } from "./useCalculation";
|
||||||
|
|
@ -21,6 +21,7 @@ export interface SimpleRepeaterTableComponentProps extends ComponentRendererProp
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
showRowNumber?: boolean;
|
showRowNumber?: boolean;
|
||||||
allowDelete?: boolean;
|
allowDelete?: boolean;
|
||||||
|
allowAdd?: boolean;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,10 +45,31 @@ export function SimpleRepeaterTableComponent({
|
||||||
readOnly: propReadOnly,
|
readOnly: propReadOnly,
|
||||||
showRowNumber: propShowRowNumber,
|
showRowNumber: propShowRowNumber,
|
||||||
allowDelete: propAllowDelete,
|
allowDelete: propAllowDelete,
|
||||||
|
allowAdd: propAllowAdd,
|
||||||
maxHeight: propMaxHeight,
|
maxHeight: propMaxHeight,
|
||||||
|
|
||||||
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
||||||
|
_initialData,
|
||||||
|
_originalData,
|
||||||
|
_groupedData,
|
||||||
|
// 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음)
|
||||||
|
initialData: legacyInitialData,
|
||||||
|
originalData: legacyOriginalData,
|
||||||
|
groupedData: legacyGroupedData,
|
||||||
|
|
||||||
...props
|
...props
|
||||||
}: SimpleRepeaterTableComponentProps) {
|
}: SimpleRepeaterTableComponentProps & {
|
||||||
|
_initialData?: any;
|
||||||
|
_originalData?: any;
|
||||||
|
_groupedData?: any;
|
||||||
|
initialData?: any;
|
||||||
|
originalData?: any;
|
||||||
|
groupedData?: any;
|
||||||
|
}) {
|
||||||
|
// 실제 사용할 데이터 (새 props 우선, 레거시 fallback)
|
||||||
|
const effectiveInitialData = _initialData || legacyInitialData;
|
||||||
|
const effectiveOriginalData = _originalData || legacyOriginalData;
|
||||||
|
const effectiveGroupedData = _groupedData || legacyGroupedData;
|
||||||
// config 또는 component.config 또는 개별 prop 우선순위로 병합
|
// config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -60,6 +82,13 @@ export function SimpleRepeaterTableComponent({
|
||||||
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
||||||
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
|
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
|
||||||
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
|
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
|
||||||
|
const allowAdd = componentConfig?.allowAdd ?? propAllowAdd ?? false;
|
||||||
|
const addButtonText = componentConfig?.addButtonText || "행 추가";
|
||||||
|
const addButtonPosition = componentConfig?.addButtonPosition || "bottom";
|
||||||
|
const minRows = componentConfig?.minRows ?? 0;
|
||||||
|
const maxRows = componentConfig?.maxRows ?? Infinity;
|
||||||
|
const newRowDefaults = componentConfig?.newRowDefaults || {};
|
||||||
|
const summaryConfig = componentConfig?.summaryConfig;
|
||||||
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
||||||
|
|
||||||
// value는 formData[columnName] 우선, 없으면 prop 사용
|
// value는 formData[columnName] 우선, 없으면 prop 사용
|
||||||
|
|
@ -256,7 +285,7 @@ export function SimpleRepeaterTableComponent({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSaveRequest = async (event: Event) => {
|
const handleSaveRequest = async (event: Event) => {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
// console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,7 +326,7 @@ export function SimpleRepeaterTableComponent({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
// console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
||||||
|
|
||||||
// CustomEvent의 detail에 테이블별 데이터 추가
|
// CustomEvent의 detail에 테이블별 데이터 추가
|
||||||
if (event instanceof CustomEvent && event.detail) {
|
if (event instanceof CustomEvent && event.detail) {
|
||||||
|
|
@ -310,10 +339,10 @@ export function SimpleRepeaterTableComponent({
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
// console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
||||||
tables: Object.keys(dataByTable),
|
// tables: Object.keys(dataByTable),
|
||||||
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
// totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 onFormDataChange도 호출 (호환성)
|
// 기존 onFormDataChange도 호출 (호환성)
|
||||||
|
|
@ -345,10 +374,137 @@ export function SimpleRepeaterTableComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowDelete = (rowIndex: number) => {
|
const handleRowDelete = (rowIndex: number) => {
|
||||||
|
// 최소 행 수 체크
|
||||||
|
if (value.length <= minRows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newData = value.filter((_, i) => i !== rowIndex);
|
const newData = value.filter((_, i) => i !== rowIndex);
|
||||||
handleChange(newData);
|
handleChange(newData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 행 추가 함수
|
||||||
|
const handleAddRow = () => {
|
||||||
|
// 최대 행 수 체크
|
||||||
|
if (value.length >= maxRows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 행 생성 (기본값 적용)
|
||||||
|
const newRow: Record<string, any> = { ...newRowDefaults };
|
||||||
|
|
||||||
|
// 각 컬럼의 기본값 설정
|
||||||
|
columns.forEach((col) => {
|
||||||
|
if (newRow[col.field] === undefined) {
|
||||||
|
if (col.defaultValue !== undefined) {
|
||||||
|
newRow[col.field] = col.defaultValue;
|
||||||
|
} else if (col.type === "number") {
|
||||||
|
newRow[col.field] = 0;
|
||||||
|
} else if (col.type === "date") {
|
||||||
|
newRow[col.field] = new Date().toISOString().split("T")[0];
|
||||||
|
} else {
|
||||||
|
newRow[col.field] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 계산 필드 적용
|
||||||
|
const calculatedRow = calculateRow(newRow);
|
||||||
|
|
||||||
|
const newData = [...value, calculatedRow];
|
||||||
|
handleChange(newData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 합계 계산
|
||||||
|
const summaryValues = useMemo(() => {
|
||||||
|
if (!summaryConfig?.enabled || !summaryConfig.fields || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
|
||||||
|
// 먼저 기본 집계 함수 계산
|
||||||
|
summaryConfig.fields.forEach((field) => {
|
||||||
|
if (field.formula) return; // 수식 필드는 나중에 처리
|
||||||
|
|
||||||
|
const values = value.map((row) => {
|
||||||
|
const val = row[field.field];
|
||||||
|
return typeof val === "number" ? val : parseFloat(val) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (field.type || "sum") {
|
||||||
|
case "sum":
|
||||||
|
result[field.field] = values.reduce((a, b) => a + b, 0);
|
||||||
|
break;
|
||||||
|
case "avg":
|
||||||
|
result[field.field] = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||||
|
break;
|
||||||
|
case "count":
|
||||||
|
result[field.field] = values.length;
|
||||||
|
break;
|
||||||
|
case "min":
|
||||||
|
result[field.field] = Math.min(...values);
|
||||||
|
break;
|
||||||
|
case "max":
|
||||||
|
result[field.field] = Math.max(...values);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result[field.field] = values.reduce((a, b) => a + b, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 수식 필드 계산 (다른 합계 필드 참조)
|
||||||
|
summaryConfig.fields.forEach((field) => {
|
||||||
|
if (!field.formula) return;
|
||||||
|
|
||||||
|
let formula = field.formula;
|
||||||
|
// 다른 필드 참조 치환
|
||||||
|
Object.keys(result).forEach((key) => {
|
||||||
|
formula = formula.replace(new RegExp(`\\b${key}\\b`, "g"), result[key].toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
result[field.field] = new Function(`return ${formula}`)();
|
||||||
|
} catch {
|
||||||
|
result[field.field] = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [value, summaryConfig]);
|
||||||
|
|
||||||
|
// 합계 값 포맷팅
|
||||||
|
const formatSummaryValue = (field: SummaryFieldConfig, value: number): string => {
|
||||||
|
const decimals = field.decimals ?? 0;
|
||||||
|
const formatted = value.toFixed(decimals);
|
||||||
|
|
||||||
|
switch (field.format) {
|
||||||
|
case "currency":
|
||||||
|
return Number(formatted).toLocaleString() + "원";
|
||||||
|
case "percent":
|
||||||
|
return formatted + "%";
|
||||||
|
default:
|
||||||
|
return Number(formatted).toLocaleString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행 추가 버튼 컴포넌트
|
||||||
|
const AddRowButton = () => {
|
||||||
|
if (!allowAdd || readOnly || value.length >= maxRows) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddRow}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderCell = (
|
const renderCell = (
|
||||||
row: any,
|
row: any,
|
||||||
column: SimpleRepeaterColumnConfig,
|
column: SimpleRepeaterColumnConfig,
|
||||||
|
|
@ -457,8 +613,18 @@ export function SimpleRepeaterTableComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 컬럼 수 계산
|
||||||
|
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||||
|
{/* 상단 행 추가 버튼 */}
|
||||||
|
{allowAdd && addButtonPosition !== "bottom" && (
|
||||||
|
<div className="p-2 border-b bg-muted/50">
|
||||||
|
<AddRowButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="overflow-x-auto overflow-y-auto"
|
className="overflow-x-auto overflow-y-auto"
|
||||||
style={{ maxHeight }}
|
style={{ maxHeight }}
|
||||||
|
|
@ -492,10 +658,17 @@ export function SimpleRepeaterTableComponent({
|
||||||
{value.length === 0 ? (
|
{value.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
|
colSpan={totalColumns}
|
||||||
className="px-4 py-8 text-center text-muted-foreground"
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
표시할 데이터가 없습니다
|
{allowAdd ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span>표시할 데이터가 없습니다</span>
|
||||||
|
<AddRowButton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"표시할 데이터가 없습니다"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -517,7 +690,8 @@ export function SimpleRepeaterTableComponent({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRowDelete(rowIndex)}
|
onClick={() => handleRowDelete(rowIndex)}
|
||||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
disabled={value.length <= minRows}
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -529,6 +703,58 @@ export function SimpleRepeaterTableComponent({
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 표시 */}
|
||||||
|
{summaryConfig?.enabled && summaryValues && (
|
||||||
|
<div className={cn(
|
||||||
|
"border-t bg-muted/30 p-3",
|
||||||
|
summaryConfig.position === "bottom-right" && "flex justify-end"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
|
||||||
|
)}>
|
||||||
|
{summaryConfig.title && (
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
{summaryConfig.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn(
|
||||||
|
"grid gap-2",
|
||||||
|
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
|
||||||
|
)}>
|
||||||
|
{summaryConfig.fields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.field}
|
||||||
|
className={cn(
|
||||||
|
"flex justify-between items-center px-3 py-1.5 rounded",
|
||||||
|
field.highlight ? "bg-primary/10 font-semibold" : "bg-background"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">{field.label}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
field.highlight && "text-primary"
|
||||||
|
)}>
|
||||||
|
{formatSummaryValue(field, summaryValues[field.field] || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 하단 행 추가 버튼 */}
|
||||||
|
{allowAdd && addButtonPosition !== "top" && value.length > 0 && (
|
||||||
|
<div className="p-2 border-t bg-muted/50 flex justify-between items-center">
|
||||||
|
<AddRowButton />
|
||||||
|
{maxRows !== Infinity && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{value.length} / {maxRows}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
ColumnTargetConfig,
|
ColumnTargetConfig,
|
||||||
InitialDataConfig,
|
InitialDataConfig,
|
||||||
DataFilterCondition,
|
DataFilterCondition,
|
||||||
|
SummaryConfig,
|
||||||
|
SummaryFieldConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
@ -482,6 +484,81 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">행 추가 허용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.allowAdd ?? false}
|
||||||
|
onCheckedChange={(checked) => updateConfig({ allowAdd: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
사용자가 새 행을 추가할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localConfig.allowAdd && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">행 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.addButtonText || "행 추가"}
|
||||||
|
onChange={(e) => updateConfig({ addButtonText: e.target.value })}
|
||||||
|
placeholder="행 추가"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">버튼 위치</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.addButtonPosition || "bottom"}
|
||||||
|
onValueChange={(value) => updateConfig({ addButtonPosition: value as "top" | "bottom" | "both" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="top">상단</SelectItem>
|
||||||
|
<SelectItem value="bottom">하단</SelectItem>
|
||||||
|
<SelectItem value="both">상단 + 하단</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">최소 행 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={localConfig.minRows ?? 0}
|
||||||
|
onChange={(e) => updateConfig({ minRows: parseInt(e.target.value) || 0 })}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
0이면 제한 없음
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">최대 행 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={localConfig.maxRows ?? ""}
|
||||||
|
onChange={(e) => updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||||
|
placeholder="무제한"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
비워두면 무제한
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">최대 높이</Label>
|
<Label className="text-xs sm:text-sm">최대 높이</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -508,21 +585,29 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">소스 테이블</Label>
|
<Label className="text-xs sm:text-sm">소스 테이블</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localConfig.initialDataConfig?.sourceTable || ""}
|
value={localConfig.initialDataConfig?.sourceTable || "__none__"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
updateConfig({
|
if (value === "__none__") {
|
||||||
initialDataConfig: {
|
// 선택 안 함: initialDataConfig 초기화
|
||||||
...localConfig.initialDataConfig,
|
updateConfig({
|
||||||
sourceTable: value,
|
initialDataConfig: undefined,
|
||||||
},
|
});
|
||||||
})
|
} else {
|
||||||
}
|
updateConfig({
|
||||||
|
initialDataConfig: {
|
||||||
|
...localConfig.initialDataConfig,
|
||||||
|
sourceTable: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isLoadingTables}
|
disabled={isLoadingTables}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 text-sm w-full">
|
<SelectTrigger className="h-10 text-sm w-full">
|
||||||
<SelectValue placeholder={isLoadingTables ? "로딩 중..." : "데이터를 가져올 테이블 선택"} />
|
<SelectValue placeholder={isLoadingTables ? "로딩 중..." : "선택 안 함 (빈 테이블로 시작)"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안 함 (빈 테이블로 시작)</SelectItem>
|
||||||
{allTables.map((table) => (
|
{allTables.map((table) => (
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
{table.displayName || table.tableName}
|
{table.displayName || table.tableName}
|
||||||
|
|
@ -531,7 +616,7 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
초기 데이터를 조회할 테이블 (예: sales_order_mng)
|
선택 안 하면 빈 테이블로 시작합니다 (새 데이터 입력용)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1002,48 +1087,71 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 데이터 타겟 설정 (어디에 저장할지) */}
|
{/* 🆕 데이터 타겟 설정 - 부모-자식 모드면 숨김 */}
|
||||||
<div className="space-y-3 border-t pt-4">
|
{localConfig.parentChildConfig?.enabled ? (
|
||||||
<div className="flex items-center gap-2">
|
// 부모-자식 모드: 간단한 안내만 표시
|
||||||
<div className="h-1 w-1 rounded-full bg-green-500"></div>
|
<div className="border-t pt-4">
|
||||||
<Label className="text-xs font-semibold text-green-600">데이터 타겟 설정 (어디에 저장?)</Label>
|
<div className="p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong className="text-green-700 dark:text-green-400">부모-자식 모드</strong>
|
||||||
|
<br />
|
||||||
|
→ <code className="bg-green-100 dark:bg-green-900 px-1 rounded">{localConfig.parentChildConfig.childTable || "자식 테이블"}.{col.field || "필드명"}</code> 에 저장
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// 일반 모드: 타겟 설정 (선택사항)
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1 w-1 rounded-full bg-gray-400"></div>
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">저장 설정 (선택사항)</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">타겟 테이블</Label>
|
<Label className="text-xs">타겟 테이블</Label>
|
||||||
<Select
|
<Select
|
||||||
value={col.targetConfig?.targetTable || ""}
|
value={col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" ? col.targetConfig.targetTable : "__none__"}
|
||||||
onValueChange={(value) => updateColumn(index, {
|
onValueChange={(value) => {
|
||||||
targetConfig: {
|
if (value === "__none__") {
|
||||||
...col.targetConfig,
|
// 선택 안 함: targetConfig 초기화
|
||||||
targetTable: value,
|
updateColumn(index, {
|
||||||
saveEnabled: true,
|
targetConfig: undefined
|
||||||
}
|
});
|
||||||
})}
|
} else {
|
||||||
>
|
// 테이블 선택: targetConfig 설정
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
updateColumn(index, {
|
||||||
<SelectValue placeholder="저장할 테이블 선택" />
|
targetConfig: {
|
||||||
</SelectTrigger>
|
targetTable: value,
|
||||||
<SelectContent>
|
targetColumn: col.field || "",
|
||||||
{allTables.map((table) => (
|
saveEnabled: true,
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
}
|
||||||
{table.displayName || table.tableName}
|
});
|
||||||
</SelectItem>
|
}
|
||||||
))}
|
}}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<p className="text-xs text-muted-foreground">
|
<SelectValue placeholder="선택 안 함" />
|
||||||
이 컬럼의 값을 저장할 테이블
|
</SelectTrigger>
|
||||||
</p>
|
<SelectContent>
|
||||||
</div>
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
선택 안 하면 이 컬럼은 저장되지 않습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{col.targetConfig?.targetTable && (
|
{col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" && (
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">타겟 컬럼</Label>
|
<Label className="text-xs">타겟 컬럼</Label>
|
||||||
<SourceColumnSelector
|
<SourceColumnSelector
|
||||||
sourceTable={col.targetConfig.targetTable}
|
sourceTable={col.targetConfig.targetTable}
|
||||||
value={col.targetConfig.targetColumn || ""}
|
value={col.targetConfig.targetColumn || col.field || ""}
|
||||||
onChange={(value) => updateColumn(index, {
|
onChange={(value) => updateColumn(index, {
|
||||||
targetConfig: {
|
targetConfig: {
|
||||||
...col.targetConfig,
|
...col.targetConfig,
|
||||||
|
|
@ -1052,37 +1160,10 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
})}
|
})}
|
||||||
showTableName={true}
|
showTableName={true}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
저장할 컬럼명
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
)}
|
||||||
<Label className="text-xs">저장 활성화</Label>
|
|
||||||
<Switch
|
|
||||||
checked={col.targetConfig.saveEnabled ?? true}
|
|
||||||
onCheckedChange={(checked) => updateColumn(index, {
|
|
||||||
targetConfig: {
|
|
||||||
...col.targetConfig,
|
|
||||||
saveEnabled: checked
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
비활성화 시 저장하지 않음 (표시 전용)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{col.targetConfig.targetTable && col.targetConfig.targetColumn && (
|
|
||||||
<div className="p-2 bg-green-50 dark:bg-green-950 rounded text-[10px] font-mono border border-green-200 dark:border-green-800">
|
|
||||||
저장: {col.targetConfig.targetTable}.{col.targetConfig.targetColumn}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 편집 가능 여부 */}
|
{/* 편집 가능 여부 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -1235,11 +1316,13 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(localConfig.columns || []).map((col, colIndex) => (
|
{(localConfig.columns || [])
|
||||||
<SelectItem key={col.field || `empty-${colIndex}`} value={col.field || ""}>
|
.filter((col) => col.field && col.field.trim() !== "")
|
||||||
{col.label} ({col.field || '미설정'})
|
.map((col, colIndex) => (
|
||||||
</SelectItem>
|
<SelectItem key={col.field || `col-${colIndex}`} value={col.field}>
|
||||||
))}
|
{col.label} ({col.field})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
|
@ -1314,15 +1397,285 @@ export function SimpleRepeaterTableConfigPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 설정 */}
|
||||||
|
<div className="space-y-4 border rounded-lg p-4 bg-card">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-1">합계 설정</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
테이블 하단에 합계를 표시합니다
|
||||||
|
</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.summaryConfig?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: checked,
|
||||||
|
fields: localConfig.summaryConfig?.fields || [],
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localConfig.summaryConfig?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">합계 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.summaryConfig?.title || ""}
|
||||||
|
onChange={(e) => updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
title: e.target.value,
|
||||||
|
fields: localConfig.summaryConfig?.fields || [],
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
placeholder="합계"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">표시 위치</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.summaryConfig?.position || "bottom"}
|
||||||
|
onValueChange={(value) => updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
position: value as "bottom" | "bottom-right",
|
||||||
|
fields: localConfig.summaryConfig?.fields || [],
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bottom">하단 전체</SelectItem>
|
||||||
|
<SelectItem value="bottom-right">하단 우측</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">합계 필드</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const fields = localConfig.summaryConfig?.fields || [];
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields: [...fields, { field: "", label: "", type: "sum", format: "number" }],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{localConfig.summaryConfig.fields.map((field, index) => (
|
||||||
|
<div key={`summary-${index}`} className="border rounded-md p-3 space-y-3 bg-background">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">합계 필드 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const fields = [...(localConfig.summaryConfig?.fields || [])];
|
||||||
|
fields.splice(index, 1);
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">필드</Label>
|
||||||
|
<Select
|
||||||
|
value={field.field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const fields = [...(localConfig.summaryConfig?.fields || [])];
|
||||||
|
const selectedCol = localConfig.columns?.find(c => c.field === value);
|
||||||
|
fields[index] = {
|
||||||
|
...fields[index],
|
||||||
|
field: value,
|
||||||
|
label: fields[index].label || selectedCol?.label || value,
|
||||||
|
};
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(localConfig.columns || []).filter(c => c.type === "number").map((col) => (
|
||||||
|
<SelectItem key={col.field} value={col.field}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const fields = [...(localConfig.summaryConfig?.fields || [])];
|
||||||
|
fields[index] = { ...fields[index], label: e.target.value };
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="합계 라벨"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">계산 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={field.type || "sum"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const fields = [...(localConfig.summaryConfig?.fields || [])];
|
||||||
|
fields[index] = { ...fields[index], type: value as SummaryFieldConfig["type"] };
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sum">합계 (SUM)</SelectItem>
|
||||||
|
<SelectItem value="avg">평균 (AVG)</SelectItem>
|
||||||
|
<SelectItem value="count">개수 (COUNT)</SelectItem>
|
||||||
|
<SelectItem value="min">최소값 (MIN)</SelectItem>
|
||||||
|
<SelectItem value="max">최대값 (MAX)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">표시 형식</Label>
|
||||||
|
<Select
|
||||||
|
value={field.format || "number"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const fields = [...(localConfig.summaryConfig?.fields || [])];
|
||||||
|
fields[index] = { ...fields[index], format: value as SummaryFieldConfig["format"] };
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="number">숫자</SelectItem>
|
||||||
|
<SelectItem value="currency">통화 (원)</SelectItem>
|
||||||
|
<SelectItem value="percent">퍼센트 (%)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">강조 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={field.highlight ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const fields = [...(localConfig.summaryConfig?.fields || [])];
|
||||||
|
fields[index] = { ...fields[index], highlight: checked };
|
||||||
|
updateConfig({
|
||||||
|
summaryConfig: {
|
||||||
|
...localConfig.summaryConfig,
|
||||||
|
enabled: true,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 border-2 border-dashed rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
합계 필드를 추가하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-xs font-medium mb-1">사용 예시</p>
|
||||||
|
<div className="space-y-1 text-[10px] text-muted-foreground">
|
||||||
|
<p>• 공급가액 합계: supply_amount 필드의 SUM</p>
|
||||||
|
<p>• 세액 합계: tax_amount 필드의 SUM</p>
|
||||||
|
<p>• 총액: supply_amount + tax_amount (수식 필드)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 사용 안내 */}
|
{/* 사용 안내 */}
|
||||||
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
|
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
|
||||||
<p className="font-medium mb-2">SimpleRepeaterTable 사용법:</p>
|
<p className="font-medium mb-2">SimpleRepeaterTable 사용법:</p>
|
||||||
<ul className="space-y-1 list-disc list-inside">
|
<ul className="space-y-1 list-disc list-inside">
|
||||||
<li>주어진 데이터를 표시하고 편집하는 경량 테이블입니다</li>
|
<li>주어진 데이터를 표시하고 편집하는 경량 테이블입니다</li>
|
||||||
<li>검색/추가 기능은 없으며, 상위 컴포넌트에서 데이터를 전달받습니다</li>
|
<li><strong>행 추가 허용</strong> 옵션으로 사용자가 새 행을 추가할 수 있습니다</li>
|
||||||
<li>주로 EditModal과 함께 사용되며, 선택된 데이터를 일괄 수정할 때 유용합니다</li>
|
<li>주로 EditModal과 함께 사용되며, 선택된 데이터를 일괄 수정할 때 유용합니다</li>
|
||||||
<li>readOnly 옵션으로 전체 테이블을 읽기 전용으로 만들 수 있습니다</li>
|
<li>readOnly 옵션으로 전체 테이블을 읽기 전용으로 만들 수 있습니다</li>
|
||||||
<li>자동 계산 규칙을 통해 수량 * 단가 = 금액 같은 계산을 자동화할 수 있습니다</li>
|
<li>자동 계산 규칙을 통해 수량 * 단가 = 금액 같은 계산을 자동화할 수 있습니다</li>
|
||||||
|
<li><strong>합계 설정</strong>으로 테이블 하단에 합계/평균 등을 표시할 수 있습니다</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ComponentRendererProps } from "@/types/component";
|
||||||
// 컴포넌트 자동 등록
|
// 컴포넌트 자동 등록
|
||||||
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
|
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
|
||||||
|
|
||||||
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
// console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
||||||
|
|
||||||
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
|
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
|
||||||
return <SimpleRepeaterTableComponent {...props} />;
|
return <SimpleRepeaterTableComponent {...props} />;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,15 @@ export const SimpleRepeaterTableDefinition = createComponentDefinition({
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
showRowNumber: true,
|
showRowNumber: true,
|
||||||
allowDelete: true,
|
allowDelete: true,
|
||||||
|
allowAdd: false,
|
||||||
|
addButtonText: "행 추가",
|
||||||
|
addButtonPosition: "bottom",
|
||||||
|
minRows: 0,
|
||||||
|
maxRows: undefined,
|
||||||
|
summaryConfig: {
|
||||||
|
enabled: false,
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
maxHeight: "240px",
|
maxHeight: "240px",
|
||||||
},
|
},
|
||||||
defaultSize: { width: 800, height: 400 },
|
defaultSize: { width: 800, height: 400 },
|
||||||
|
|
@ -51,6 +60,8 @@ export type {
|
||||||
InitialDataConfig,
|
InitialDataConfig,
|
||||||
DataFilterCondition,
|
DataFilterCondition,
|
||||||
SourceJoinCondition,
|
SourceJoinCondition,
|
||||||
|
SummaryConfig,
|
||||||
|
SummaryFieldConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// 컴포넌트 내보내기
|
// 컴포넌트 내보내기
|
||||||
|
|
|
||||||
|
|
@ -1 +1,113 @@
|
||||||
|
/**
|
||||||
|
* SimpleRepeaterTable 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 컬럼 데이터 소스 설정
|
||||||
|
export interface ColumnSourceConfig {
|
||||||
|
type: "direct" | "join" | "manual";
|
||||||
|
sourceTable?: string;
|
||||||
|
sourceColumn?: string;
|
||||||
|
joinTable?: string;
|
||||||
|
joinColumn?: string;
|
||||||
|
joinKey?: string;
|
||||||
|
joinRefKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 데이터 타겟 설정
|
||||||
|
export interface ColumnTargetConfig {
|
||||||
|
targetTable?: string;
|
||||||
|
targetColumn?: string;
|
||||||
|
saveEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 설정
|
||||||
|
export interface SimpleRepeaterColumnConfig {
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
type?: "text" | "number" | "date" | "select";
|
||||||
|
width?: string;
|
||||||
|
editable?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
calculated?: boolean;
|
||||||
|
defaultValue?: any;
|
||||||
|
placeholder?: string;
|
||||||
|
selectOptions?: { value: string; label: string }[];
|
||||||
|
sourceConfig?: ColumnSourceConfig;
|
||||||
|
targetConfig?: ColumnTargetConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계산 규칙
|
||||||
|
export interface CalculationRule {
|
||||||
|
result: string;
|
||||||
|
formula: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 데이터 필터 조건
|
||||||
|
export interface DataFilterCondition {
|
||||||
|
field: string;
|
||||||
|
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||||
|
value?: any;
|
||||||
|
valueFromField?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소스 조인 조건
|
||||||
|
export interface SourceJoinCondition {
|
||||||
|
sourceKey: string;
|
||||||
|
referenceKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 데이터 설정
|
||||||
|
export interface InitialDataConfig {
|
||||||
|
sourceTable: string;
|
||||||
|
filterConditions?: DataFilterCondition[];
|
||||||
|
joinConditions?: SourceJoinCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 합계 필드 설정
|
||||||
|
export interface SummaryFieldConfig {
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
type?: "sum" | "avg" | "count" | "min" | "max";
|
||||||
|
formula?: string; // 다른 합계 필드를 참조하는 계산식 (예: "supply_amount + tax_amount")
|
||||||
|
format?: "number" | "currency" | "percent";
|
||||||
|
decimals?: number;
|
||||||
|
highlight?: boolean; // 강조 표시 (합계 행)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 합계 설정
|
||||||
|
export interface SummaryConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
position?: "bottom" | "bottom-right";
|
||||||
|
title?: string;
|
||||||
|
fields: SummaryFieldConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 Props
|
||||||
|
export interface SimpleRepeaterTableProps {
|
||||||
|
// 기본 설정
|
||||||
|
columns?: SimpleRepeaterColumnConfig[];
|
||||||
|
calculationRules?: CalculationRule[];
|
||||||
|
initialDataConfig?: InitialDataConfig;
|
||||||
|
|
||||||
|
// 표시 설정
|
||||||
|
readOnly?: boolean;
|
||||||
|
showRowNumber?: boolean;
|
||||||
|
allowDelete?: boolean;
|
||||||
|
maxHeight?: string;
|
||||||
|
|
||||||
|
// 행 추가 설정
|
||||||
|
allowAdd?: boolean;
|
||||||
|
addButtonText?: string;
|
||||||
|
addButtonPosition?: "top" | "bottom" | "both";
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
newRowDefaults?: Record<string, any>;
|
||||||
|
|
||||||
|
// 합계 설정
|
||||||
|
summaryConfig?: SummaryConfig;
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
value?: any[];
|
||||||
|
onChange?: (newData: any[]) => void;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ export function useCalculation(calculationRules: CalculationRule[] = []) {
|
||||||
// 결과 필드는 제외
|
// 결과 필드는 제외
|
||||||
if (dep === rule.result) continue;
|
if (dep === rule.result) continue;
|
||||||
|
|
||||||
const value = parseFloat(row[dep]) || 0;
|
// 이전 계산 결과(updatedRow)를 우선 사용, 없으면 원본(row) 사용
|
||||||
|
const value = parseFloat(updatedRow[dep] ?? row[dep]) || 0;
|
||||||
// 정확한 필드명만 대체 (단어 경계 사용)
|
// 정확한 필드명만 대체 (단어 경계 사용)
|
||||||
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -905,11 +905,22 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
|
|
||||||
// 좌측 패널 항목 렌더링
|
// 좌측 패널 항목 렌더링
|
||||||
const renderLeftItem = (item: any, level: number = 0, index: number = 0) => {
|
const renderLeftItem = (item: any, level: number = 0, index: number = 0) => {
|
||||||
const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id";
|
// ID 컬럼 결정: 설정값 > 데이터에 존재하는 일반적인 ID 컬럼 > 폴백
|
||||||
|
const configIdColumn = config.leftPanel?.hierarchyConfig?.idColumn;
|
||||||
|
const idColumn = configIdColumn ||
|
||||||
|
(item["id"] !== undefined ? "id" :
|
||||||
|
item["dept_code"] !== undefined ? "dept_code" :
|
||||||
|
item["code"] !== undefined ? "code" : "id");
|
||||||
const itemId = item[idColumn] ?? `item-${level}-${index}`;
|
const itemId = item[idColumn] ?? `item-${level}-${index}`;
|
||||||
const hasChildren = item.children?.length > 0;
|
const hasChildren = item.children?.length > 0;
|
||||||
const isExpanded = expandedItems.has(String(itemId));
|
const isExpanded = expandedItems.has(String(itemId));
|
||||||
const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn];
|
// 선택 상태 확인: 동일한 객체이거나 idColumn 값이 일치해야 함
|
||||||
|
const isSelected = selectedLeftItem && (
|
||||||
|
selectedLeftItem === item ||
|
||||||
|
(item[idColumn] !== undefined &&
|
||||||
|
selectedLeftItem[idColumn] !== undefined &&
|
||||||
|
selectedLeftItem[idColumn] === item[idColumn])
|
||||||
|
);
|
||||||
|
|
||||||
// displayRow 설정에 따라 컬럼 분류
|
// displayRow 설정에 따라 컬럼 분류
|
||||||
const displayColumns = config.leftPanel?.displayColumns || [];
|
const displayColumns = config.leftPanel?.displayColumns || [];
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,17 @@ interface SingleTableWithStickyProps {
|
||||||
containerWidth?: string; // 컨테이너 너비 설정
|
containerWidth?: string; // 컨테이너 너비 설정
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
// 인라인 편집 관련 props
|
||||||
|
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
|
||||||
|
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
|
||||||
|
editingValue?: string;
|
||||||
|
onEditingValueChange?: (value: string) => void;
|
||||||
|
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
editInputRef?: React.RefObject<HTMLInputElement>;
|
||||||
|
// 검색 하이라이트 관련 props
|
||||||
|
searchHighlights?: Set<string>;
|
||||||
|
currentSearchIndex?: number;
|
||||||
|
searchTerm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
|
|
@ -51,6 +62,17 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
containerWidth,
|
containerWidth,
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
|
// 인라인 편집 관련 props
|
||||||
|
onCellDoubleClick,
|
||||||
|
editingCell,
|
||||||
|
editingValue,
|
||||||
|
onEditingValueChange,
|
||||||
|
onEditKeyDown,
|
||||||
|
editInputRef,
|
||||||
|
// 검색 하이라이트 관련 props
|
||||||
|
searchHighlights,
|
||||||
|
currentSearchIndex = 0,
|
||||||
|
searchTerm = "",
|
||||||
}) => {
|
}) => {
|
||||||
const checkboxConfig = tableConfig?.checkbox || {};
|
const checkboxConfig = tableConfig?.checkbox || {};
|
||||||
const actualColumns = visibleColumns || columns || [];
|
const actualColumns = visibleColumns || columns || [];
|
||||||
|
|
@ -58,14 +80,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm"
|
className="relative flex flex-col bg-background shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex-1 overflow-auto">
|
<div className="relative overflow-x-auto">
|
||||||
<Table
|
<Table
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -75,17 +96,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader
|
<TableHeader
|
||||||
className={
|
className={cn(
|
||||||
tableConfig.stickyHeader
|
"border-b bg-background",
|
||||||
? "sticky top-0 border-b shadow-md"
|
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
|
||||||
: "border-b"
|
)}
|
||||||
}
|
|
||||||
style={{
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
backgroundColor: "hsl(var(--background))",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TableRow className="border-b">
|
<TableRow className="border-b">
|
||||||
{actualColumns.map((column, colIndex) => {
|
{actualColumns.map((column, colIndex) => {
|
||||||
|
|
@ -215,9 +229,65 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
// 현재 셀이 편집 중인지 확인
|
||||||
|
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
|
||||||
|
|
||||||
|
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
|
||||||
|
const cellKey = `${index}-${colIndex}`;
|
||||||
|
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
|
||||||
|
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
|
||||||
|
|
||||||
|
// 인덱스 기반 하이라이트 + 실제 값 검증
|
||||||
|
const isHighlighted = column.columnName !== "__checkbox__" &&
|
||||||
|
hasSearchTerm &&
|
||||||
|
(searchHighlights?.has(cellKey) ?? false);
|
||||||
|
|
||||||
|
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
|
||||||
|
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
|
||||||
|
const isCurrentSearchResult = isHighlighted &&
|
||||||
|
currentSearchIndex >= 0 &&
|
||||||
|
currentSearchIndex < highlightArray.length &&
|
||||||
|
highlightArray[currentSearchIndex] === cellKey;
|
||||||
|
|
||||||
|
// 셀 값에서 검색어 하이라이트 렌더링
|
||||||
|
const renderCellContent = () => {
|
||||||
|
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||||
|
|
||||||
|
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||||
|
return cellValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어 하이라이트 처리
|
||||||
|
const lowerValue = String(cellValue).toLowerCase();
|
||||||
|
const lowerTerm = searchTerm.toLowerCase();
|
||||||
|
const startIndex = lowerValue.indexOf(lowerTerm);
|
||||||
|
|
||||||
|
if (startIndex === -1) return cellValue;
|
||||||
|
|
||||||
|
const before = String(cellValue).slice(0, startIndex);
|
||||||
|
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
||||||
|
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<mark className={cn(
|
||||||
|
"rounded px-0.5",
|
||||||
|
isCurrentSearchResult
|
||||||
|
? "bg-orange-400 text-white font-semibold"
|
||||||
|
: "bg-yellow-200 text-yellow-900"
|
||||||
|
)}>
|
||||||
|
{match}
|
||||||
|
</mark>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={`cell-${column.columnName}`}
|
key={`cell-${column.columnName}`}
|
||||||
|
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
|
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
|
|
@ -226,6 +296,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
|
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
|
||||||
column.fixed === "right" &&
|
column.fixed === "right" &&
|
||||||
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
|
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
|
||||||
|
// 편집 가능 셀 스타일
|
||||||
|
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: getColumnWidth(column),
|
width: getColumnWidth(column),
|
||||||
|
|
@ -239,10 +311,36 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{column.columnName === "__checkbox__"
|
{column.columnName === "__checkbox__" ? (
|
||||||
? renderCheckboxCell(row, index)
|
renderCheckboxCell(row, index)
|
||||||
: formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"}
|
) : isEditing ? (
|
||||||
|
// 인라인 편집 입력 필드
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editingValue ?? ""}
|
||||||
|
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
// blur 시 저장 (Enter와 동일)
|
||||||
|
if (onEditKeyDown) {
|
||||||
|
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
|
||||||
|
onEditKeyDown(fakeEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderCellContent()
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -4674,20 +4674,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* 새로고침 버튼 */}
|
{/* 새로고침 버튼 (하단 페이지네이션) */}
|
||||||
<Button
|
{(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={handleRefresh}
|
size="sm"
|
||||||
disabled={loading}
|
onClick={handleRefresh}
|
||||||
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
disabled={loading}
|
||||||
>
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
||||||
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
>
|
||||||
</Button>
|
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]);
|
}, [tableConfig.pagination, tableConfig.toolbar?.showPaginationRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 렌더링
|
// 렌더링
|
||||||
|
|
@ -4790,57 +4792,67 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
||||||
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
|
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
|
||||||
{/* 편집 모드 토글 */}
|
{/* 편집 모드 토글 */}
|
||||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
{(tableConfig.toolbar?.showEditMode ?? true) && (
|
||||||
<Button
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||||
variant={editMode === "batch" ? "default" : "ghost"}
|
<Button
|
||||||
size="sm"
|
variant={editMode === "batch" ? "default" : "ghost"}
|
||||||
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
|
size="sm"
|
||||||
className="h-7 text-xs"
|
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
|
||||||
title="배치 편집 모드 (Ctrl+B)"
|
className="h-7 text-xs"
|
||||||
>
|
title="배치 편집 모드 (Ctrl+B)"
|
||||||
<Edit className="mr-1 h-3 w-3" />
|
>
|
||||||
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
|
<Edit className="mr-1 h-3 w-3" />
|
||||||
</Button>
|
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 내보내기 버튼들 */}
|
{/* 내보내기 버튼들 */}
|
||||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
{((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && (
|
||||||
<Button
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||||
variant="ghost"
|
{(tableConfig.toolbar?.showExcel ?? true) && (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => exportToExcel(true)}
|
variant="ghost"
|
||||||
className="h-7 text-xs"
|
size="sm"
|
||||||
title="Excel 내보내기"
|
onClick={() => exportToExcel(true)}
|
||||||
>
|
className="h-7 text-xs"
|
||||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
title="Excel 내보내기"
|
||||||
Excel
|
>
|
||||||
</Button>
|
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
||||||
<Button
|
Excel
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => exportToPdf(true)}
|
{(tableConfig.toolbar?.showPdf ?? true) && (
|
||||||
className="h-7 text-xs"
|
<Button
|
||||||
title="PDF 내보내기"
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
onClick={() => exportToPdf(true)}
|
||||||
PDF
|
className="h-7 text-xs"
|
||||||
</Button>
|
title="PDF 내보내기"
|
||||||
</div>
|
>
|
||||||
|
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 복사 버튼 */}
|
{/* 복사 버튼 */}
|
||||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
{(tableConfig.toolbar?.showCopy ?? true) && (
|
||||||
<Button
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={handleCopy}
|
size="sm"
|
||||||
disabled={selectedRows.size === 0 && !focusedCell}
|
onClick={handleCopy}
|
||||||
className="h-7 text-xs"
|
disabled={selectedRows.size === 0 && !focusedCell}
|
||||||
title="복사 (Ctrl+C)"
|
className="h-7 text-xs"
|
||||||
>
|
title="복사 (Ctrl+C)"
|
||||||
<Copy className="mr-1 h-3 w-3" />
|
>
|
||||||
복사
|
<Copy className="mr-1 h-3 w-3" />
|
||||||
</Button>
|
복사
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 선택 정보 */}
|
{/* 선택 정보 */}
|
||||||
{selectedRows.size > 0 && (
|
{selectedRows.size > 0 && (
|
||||||
|
|
@ -4861,124 +4873,130 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🆕 통합 검색 패널 */}
|
{/* 🆕 통합 검색 패널 */}
|
||||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
{(tableConfig.toolbar?.showSearch ?? true) && (
|
||||||
{isSearchPanelOpen ? (
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||||
<div className="flex items-center gap-1">
|
{isSearchPanelOpen ? (
|
||||||
<input
|
<div className="flex items-center gap-1">
|
||||||
type="text"
|
<input
|
||||||
value={globalSearchTerm}
|
type="text"
|
||||||
onChange={(e) => setGlobalSearchTerm(e.target.value)}
|
value={globalSearchTerm}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => setGlobalSearchTerm(e.target.value)}
|
||||||
if (e.key === "Enter") {
|
onKeyDown={(e) => {
|
||||||
executeGlobalSearch(globalSearchTerm);
|
if (e.key === "Enter") {
|
||||||
} else if (e.key === "Escape") {
|
executeGlobalSearch(globalSearchTerm);
|
||||||
clearGlobalSearch();
|
} else if (e.key === "Escape") {
|
||||||
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
|
clearGlobalSearch();
|
||||||
e.preventDefault();
|
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
|
||||||
if (e.shiftKey) {
|
e.preventDefault();
|
||||||
goToPrevSearchResult();
|
if (e.shiftKey) {
|
||||||
} else {
|
goToPrevSearchResult();
|
||||||
goToNextSearchResult();
|
} else {
|
||||||
|
goToNextSearchResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
placeholder="검색어 입력... (Enter)"
|
||||||
placeholder="검색어 입력... (Enter)"
|
className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48"
|
||||||
className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48"
|
autoFocus
|
||||||
autoFocus
|
/>
|
||||||
/>
|
{searchHighlights.size > 0 && (
|
||||||
{searchHighlights.size > 0 && (
|
<span className="text-muted-foreground text-xs">
|
||||||
<span className="text-muted-foreground text-xs">
|
{searchHighlights.size}개
|
||||||
{searchHighlights.size}개
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrevSearchResult}
|
||||||
|
disabled={searchHighlights.size === 0}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="이전 (Shift+F3)"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNextSearchResult}
|
||||||
|
disabled={searchHighlights.size === 0}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="다음 (F3)"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearGlobalSearch}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="닫기 (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSearchPanelOpen(true)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
title="통합 검색 (Ctrl+F)"
|
||||||
|
>
|
||||||
|
<Filter className="mr-1 h-3 w-3" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
|
||||||
|
{(tableConfig.toolbar?.showFilter ?? true) && (
|
||||||
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||||
|
<Button
|
||||||
|
variant={activeFilterCount > 0 ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsFilterBuilderOpen(true)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
title="고급 필터"
|
||||||
|
>
|
||||||
|
<Layers className="mr-1 h-3 w-3" />
|
||||||
|
필터
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
||||||
|
{activeFilterCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={goToPrevSearchResult}
|
onClick={clearFilterBuilder}
|
||||||
disabled={searchHighlights.size === 0}
|
|
||||||
className="h-6 w-6 p-0"
|
className="h-6 w-6 p-0"
|
||||||
title="이전 (Shift+F3)"
|
title="필터 초기화"
|
||||||
>
|
|
||||||
<ChevronLeft className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToNextSearchResult}
|
|
||||||
disabled={searchHighlights.size === 0}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
title="다음 (F3)"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearGlobalSearch}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
title="닫기 (Esc)"
|
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsSearchPanelOpen(true)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
title="통합 검색 (Ctrl+F)"
|
|
||||||
>
|
|
||||||
<Filter className="mr-1 h-3 w-3" />
|
|
||||||
검색
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
|
|
||||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
|
||||||
<Button
|
|
||||||
variant={activeFilterCount > 0 ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsFilterBuilderOpen(true)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
title="고급 필터"
|
|
||||||
>
|
|
||||||
<Layers className="mr-1 h-3 w-3" />
|
|
||||||
필터
|
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
|
||||||
{activeFilterCount}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
{activeFilterCount > 0 && (
|
)}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearFilterBuilder}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
title="필터 초기화"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 새로고침 */}
|
{/* 새로고침 */}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
{(tableConfig.toolbar?.showRefresh ?? true) && (
|
||||||
<Button
|
<div className="ml-auto flex items-center gap-1">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={handleRefresh}
|
size="sm"
|
||||||
disabled={loading}
|
onClick={handleRefresh}
|
||||||
className="h-7 text-xs"
|
disabled={loading}
|
||||||
title="새로고침"
|
className="h-7 text-xs"
|
||||||
>
|
title="새로고침"
|
||||||
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
|
>
|
||||||
새로고침
|
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
|
||||||
</Button>
|
새로고침
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 배치 편집 툴바 */}
|
{/* 🆕 배치 편집 툴바 */}
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,81 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 툴바 버튼 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">툴바 버튼 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">테이블 상단에 표시할 버튼을 선택합니다</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showEditMode"
|
||||||
|
checked={config.toolbar?.showEditMode ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showEditMode" className="text-xs">즉시 저장</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showExcel"
|
||||||
|
checked={config.toolbar?.showExcel ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showExcel" className="text-xs">Excel</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showPdf"
|
||||||
|
checked={config.toolbar?.showPdf ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showPdf" className="text-xs">PDF</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showCopy"
|
||||||
|
checked={config.toolbar?.showCopy ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showCopy" className="text-xs">복사</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showSearch"
|
||||||
|
checked={config.toolbar?.showSearch ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showSearch" className="text-xs">검색</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showFilter"
|
||||||
|
checked={config.toolbar?.showFilter ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showFilter" className="text-xs">필터</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showRefresh"
|
||||||
|
checked={config.toolbar?.showRefresh ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showRefresh" className="text-xs">새로고침 (상단)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showPaginationRefresh"
|
||||||
|
checked={config.toolbar?.showPaginationRefresh ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showPaginationRefresh" className="text-xs">새로고침 (하단)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 체크박스 설정 */}
|
{/* 체크박스 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,20 @@ export interface PaginationConfig {
|
||||||
pageSizeOptions: number[];
|
pageSizeOptions: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 툴바 버튼 표시 설정
|
||||||
|
*/
|
||||||
|
export interface ToolbarConfig {
|
||||||
|
showEditMode?: boolean; // 즉시 저장/배치 모드 버튼
|
||||||
|
showExcel?: boolean; // Excel 내보내기 버튼
|
||||||
|
showPdf?: boolean; // PDF 내보내기 버튼
|
||||||
|
showCopy?: boolean; // 복사 버튼
|
||||||
|
showSearch?: boolean; // 검색 버튼
|
||||||
|
showFilter?: boolean; // 필터 버튼
|
||||||
|
showRefresh?: boolean; // 상단 툴바 새로고침 버튼
|
||||||
|
showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 체크박스 설정
|
* 체크박스 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -259,6 +273,9 @@ export interface TableListConfig extends ComponentConfig {
|
||||||
autoLoad: boolean;
|
autoLoad: boolean;
|
||||||
refreshInterval?: number; // 초 단위
|
refreshInterval?: number; // 초 단위
|
||||||
|
|
||||||
|
// 🆕 툴바 버튼 표시 설정
|
||||||
|
toolbar?: ToolbarConfig;
|
||||||
|
|
||||||
// 🆕 컬럼 값 기반 데이터 필터링
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
dataFilter?: DataFilterConfig;
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { generateNumberingCode } from "@/lib/api/numberingRule";
|
import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
|
@ -81,11 +81,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
const isDisabled = disabled || !parentValue || loading;
|
const isDisabled = disabled || !parentValue || loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
||||||
value={value || ""}
|
|
||||||
onValueChange={onChange}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={fieldId} className="w-full">
|
<SelectTrigger id={fieldId} className="w-full">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -127,11 +123,16 @@ export function UniversalFormModalComponent({
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
initialData,
|
initialData: propInitialData,
|
||||||
|
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
||||||
|
_initialData,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
onChange,
|
onChange,
|
||||||
}: UniversalFormModalComponentProps) {
|
...restProps // 나머지 props는 DOM에 전달하지 않음
|
||||||
|
}: UniversalFormModalComponentProps & { _initialData?: any }) {
|
||||||
|
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
||||||
|
const initialData = propInitialData || _initialData;
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
const config: UniversalFormModalConfig = useMemo(() => {
|
const config: UniversalFormModalConfig = useMemo(() => {
|
||||||
const componentConfig = component?.config || {};
|
const componentConfig = component?.config || {};
|
||||||
|
|
@ -194,10 +195,34 @@ export function UniversalFormModalComponent({
|
||||||
itemId: string;
|
itemId: string;
|
||||||
}>({ open: false, sectionId: "", itemId: "" });
|
}>({ open: false, sectionId: "", itemId: "" });
|
||||||
|
|
||||||
// 초기화
|
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
||||||
|
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
||||||
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
|
// 초기화 - 최초 마운트 시에만 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 이미 초기화되었으면 스킵
|
||||||
|
if (hasInitialized.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
|
||||||
|
if (initialData && Object.keys(initialData).length > 0) {
|
||||||
|
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInitialized.current = true;
|
||||||
initializeForm();
|
initializeForm();
|
||||||
}, [config, initialData]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
|
||||||
|
|
||||||
|
// config 변경 시에만 재초기화 (initialData 변경은 무시)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
||||||
|
|
||||||
|
initializeForm();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
// 필드 레벨 linkedFieldGroup 데이터 로드
|
// 필드 레벨 linkedFieldGroup 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -216,7 +241,6 @@ export function UniversalFormModalComponent({
|
||||||
// 각 테이블 데이터 로드
|
// 각 테이블 데이터 로드
|
||||||
for (const tableName of tablesToLoad) {
|
for (const tableName of tablesToLoad) {
|
||||||
if (!linkedFieldDataCache[tableName]) {
|
if (!linkedFieldDataCache[tableName]) {
|
||||||
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
|
|
||||||
await loadLinkedFieldData(tableName);
|
await loadLinkedFieldData(tableName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +252,9 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 폼 초기화
|
// 폼 초기화
|
||||||
const initializeForm = useCallback(async () => {
|
const initializeForm = useCallback(async () => {
|
||||||
|
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
||||||
|
const effectiveInitialData = capturedInitialData.current || initialData;
|
||||||
|
|
||||||
const newFormData: FormDataState = {};
|
const newFormData: FormDataState = {};
|
||||||
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||||
const newCollapsed = new Set<string>();
|
const newCollapsed = new Set<string>();
|
||||||
|
|
@ -253,11 +280,14 @@ export function UniversalFormModalComponent({
|
||||||
// 기본값 설정
|
// 기본값 설정
|
||||||
let value = field.defaultValue ?? "";
|
let value = field.defaultValue ?? "";
|
||||||
|
|
||||||
// 부모에서 전달받은 값 적용
|
// 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면)
|
||||||
if (field.receiveFromParent && initialData) {
|
if (effectiveInitialData) {
|
||||||
const parentField = field.parentFieldName || field.columnName;
|
const parentField = field.parentFieldName || field.columnName;
|
||||||
if (initialData[parentField] !== undefined) {
|
if (effectiveInitialData[parentField] !== undefined) {
|
||||||
value = initialData[parentField];
|
// receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용
|
||||||
|
if (field.receiveFromParent || value === "" || value === undefined) {
|
||||||
|
value = effectiveInitialData[parentField];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,11 +299,12 @@ export function UniversalFormModalComponent({
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
setRepeatSections(newRepeatSections);
|
setRepeatSections(newRepeatSections);
|
||||||
setCollapsedSections(newCollapsed);
|
setCollapsedSections(newCollapsed);
|
||||||
setOriginalData(initialData || {});
|
setOriginalData(effectiveInitialData || {});
|
||||||
|
|
||||||
// 채번규칙 자동 생성
|
// 채번규칙 자동 생성
|
||||||
await generateNumberingValues(newFormData);
|
await generateNumberingValues(newFormData);
|
||||||
}, [config, initialData]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||||
|
|
||||||
// 반복 섹션 아이템 생성
|
// 반복 섹션 아이템 생성
|
||||||
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
|
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
|
||||||
|
|
@ -423,15 +454,30 @@ export function UniversalFormModalComponent({
|
||||||
if (optionConfig.type === "static") {
|
if (optionConfig.type === "static") {
|
||||||
options = optionConfig.staticOptions || [];
|
options = optionConfig.staticOptions || [];
|
||||||
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
||||||
const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, {
|
// POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
||||||
params: { limit: 1000 },
|
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: { enabled: true, filterColumn: "company_code" },
|
||||||
});
|
});
|
||||||
if (response.data?.success && response.data?.data) {
|
|
||||||
options = response.data.data.map((row: any) => ({
|
// 응답 데이터 파싱
|
||||||
value: String(row[optionConfig.valueColumn || "id"]),
|
let dataArray: any[] = [];
|
||||||
label: String(row[optionConfig.labelColumn || "name"]),
|
if (response.data?.success) {
|
||||||
}));
|
const responseData = response.data?.data;
|
||||||
|
if (responseData?.data && Array.isArray(responseData.data)) {
|
||||||
|
dataArray = responseData.data;
|
||||||
|
} else if (Array.isArray(responseData)) {
|
||||||
|
dataArray = responseData;
|
||||||
|
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
|
||||||
|
dataArray = responseData.rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options = dataArray.map((row: any) => ({
|
||||||
|
value: String(row[optionConfig.valueColumn || "id"]),
|
||||||
|
label: String(row[optionConfig.labelColumn || "name"]),
|
||||||
|
}));
|
||||||
} else if (optionConfig.type === "code" && optionConfig.codeCategory) {
|
} else if (optionConfig.type === "code" && optionConfig.codeCategory) {
|
||||||
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
|
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
|
||||||
if (response.data?.success && response.data?.data) {
|
if (response.data?.success && response.data?.data) {
|
||||||
|
|
@ -471,9 +517,9 @@ export function UniversalFormModalComponent({
|
||||||
size: 1000,
|
size: 1000,
|
||||||
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
|
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
|
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
|
||||||
|
|
||||||
if (response.data?.success) {
|
if (response.data?.success) {
|
||||||
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
|
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
|
||||||
const responseData = response.data?.data;
|
const responseData = response.data?.data;
|
||||||
|
|
@ -534,18 +580,23 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 저장 시점 채번규칙 처리
|
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
for (const field of section.fields) {
|
for (const field of section.fields) {
|
||||||
if (
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
field.numberingRule?.enabled &&
|
// generateOnSave: 저장 시 새로 생성
|
||||||
field.numberingRule?.generateOnSave &&
|
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
|
||||||
field.numberingRule?.ruleId &&
|
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
|
||||||
!dataToSave[field.columnName]
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||||
) {
|
if (response.success && response.data?.generatedCode) {
|
||||||
const response = await generateNumberingCode(field.numberingRule.ruleId);
|
dataToSave[field.columnName] = response.data.generatedCode;
|
||||||
if (response.success && response.data?.generatedCode) {
|
}
|
||||||
dataToSave[field.columnName] = response.data.generatedCode;
|
} else if (field.numberingRule.generateOnOpen && dataToSave[field.columnName]) {
|
||||||
|
// generateOnOpen인 경우, 미리보기 값이 있더라도 실제 순번 할당
|
||||||
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||||
|
if (response.success && response.data?.generatedCode) {
|
||||||
|
dataToSave[field.columnName] = response.data.generatedCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -570,7 +621,6 @@ export function UniversalFormModalComponent({
|
||||||
if (commonFields.length === 0) {
|
if (commonFields.length === 0) {
|
||||||
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
|
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
|
||||||
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
|
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
|
||||||
console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
||||||
|
|
@ -578,22 +628,9 @@ export function UniversalFormModalComponent({
|
||||||
const repeatableSection = config.sections.find((s) => s.repeatable);
|
const repeatableSection = config.sections.find((s) => s.repeatable);
|
||||||
if (repeatableSection) {
|
if (repeatableSection) {
|
||||||
repeatSectionId = repeatableSection.id;
|
repeatSectionId = repeatableSection.id;
|
||||||
console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 디버깅: 설정 확인
|
|
||||||
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
|
|
||||||
commonFields,
|
|
||||||
repeatSectionId,
|
|
||||||
mainSectionFields,
|
|
||||||
typeColumn,
|
|
||||||
mainTypeValue,
|
|
||||||
subTypeValue,
|
|
||||||
repeatSections,
|
|
||||||
formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 반복 섹션 데이터
|
// 반복 섹션 데이터
|
||||||
const repeatItems = repeatSections[repeatSectionId] || [];
|
const repeatItems = repeatSections[repeatSectionId] || [];
|
||||||
|
|
||||||
|
|
@ -616,10 +653,6 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[UniversalFormModal] 공통 데이터:", commonData);
|
|
||||||
console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
|
|
||||||
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
|
|
||||||
|
|
||||||
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
||||||
const mainRow: any = { ...commonData, ...mainSectionData };
|
const mainRow: any = { ...commonData, ...mainSectionData };
|
||||||
if (typeColumn) {
|
if (typeColumn) {
|
||||||
|
|
@ -651,16 +684,20 @@ export function UniversalFormModalComponent({
|
||||||
if (section.repeatable) continue;
|
if (section.repeatable) continue;
|
||||||
|
|
||||||
for (const field of section.fields) {
|
for (const field of section.fields) {
|
||||||
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
const response = await generateNumberingCode(field.numberingRule.ruleId);
|
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
||||||
if (response.success && response.data?.generatedCode) {
|
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
||||||
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
|
if (shouldAllocate) {
|
||||||
if (commonFields.includes(field.columnName)) {
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||||
rowsToSave.forEach((row) => {
|
if (response.success && response.data?.generatedCode) {
|
||||||
row[field.columnName] = response.data?.generatedCode;
|
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
|
||||||
});
|
if (commonFields.includes(field.columnName)) {
|
||||||
} else {
|
rowsToSave.forEach((row) => {
|
||||||
rowsToSave[0][field.columnName] = response.data?.generatedCode;
|
row[field.columnName] = response.data?.generatedCode;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rowsToSave[0][field.columnName] = response.data?.generatedCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -668,16 +705,11 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 행 저장
|
// 모든 행 저장
|
||||||
console.log("[UniversalFormModal] 저장할 행들:", rowsToSave);
|
|
||||||
console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName);
|
|
||||||
|
|
||||||
for (let i = 0; i < rowsToSave.length; i++) {
|
for (let i = 0; i < rowsToSave.length; i++) {
|
||||||
const row = rowsToSave[i];
|
const row = rowsToSave[i];
|
||||||
console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
|
|
||||||
|
|
||||||
// 빈 객체 체크
|
// 빈 객체 체크
|
||||||
if (Object.keys(row).length === 0) {
|
if (Object.keys(row).length === 0) {
|
||||||
console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,87 +719,168 @@ export function UniversalFormModalComponent({
|
||||||
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
|
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
|
|
||||||
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
||||||
|
|
||||||
// 커스텀 API 저장 (사원+부서 통합 저장 등)
|
// 다중 테이블 저장 (범용)
|
||||||
const saveWithCustomApi = useCallback(async () => {
|
const saveWithMultiTable = useCallback(async () => {
|
||||||
const { customApiSave } = config.saveConfig;
|
const { customApiSave } = config.saveConfig;
|
||||||
if (!customApiSave) return;
|
if (!customApiSave?.multiTable) return;
|
||||||
|
|
||||||
console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType);
|
const { multiTable } = customApiSave;
|
||||||
|
|
||||||
const saveUserWithDeptApi = async () => {
|
// 1. 메인 테이블 데이터 구성
|
||||||
const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave;
|
const mainData: Record<string, any> = {};
|
||||||
|
config.sections.forEach((section) => {
|
||||||
// 1. userInfo 데이터 구성
|
if (section.repeatable) return; // 반복 섹션은 제외
|
||||||
const userInfo: Record<string, any> = {};
|
section.fields.forEach((field) => {
|
||||||
|
const value = formData[field.columnName];
|
||||||
// 모든 필드에서 user_info에 해당하는 데이터 추출
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
config.sections.forEach((section) => {
|
mainData[field.columnName] = value;
|
||||||
if (section.repeatable) return; // 반복 섹션은 제외
|
}
|
||||||
|
|
||||||
section.fields.forEach((field) => {
|
|
||||||
const value = formData[field.columnName];
|
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
|
||||||
userInfo[field.columnName] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 2. mainDept 데이터 구성
|
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
||||||
let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined;
|
for (const section of config.sections) {
|
||||||
|
if (section.repeatable) continue;
|
||||||
if (mainDeptFields) {
|
|
||||||
const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"];
|
for (const field of section.fields) {
|
||||||
if (deptCode) {
|
// 채번규칙이 활성화된 필드 처리
|
||||||
mainDept = {
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
dept_code: deptCode,
|
// 신규 생성이거나 값이 없는 경우에만 채번
|
||||||
dept_name: formData[mainDeptFields.deptNameField || "dept_name"],
|
const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
|
||||||
position_name: formData[mainDeptFields.positionNameField || "position_name"],
|
const hasNoValue = !mainData[field.columnName];
|
||||||
};
|
|
||||||
|
if (isNewRecord || hasNoValue) {
|
||||||
|
try {
|
||||||
|
// allocateNumberingCode로 실제 순번 증가
|
||||||
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||||
|
if (response.success && response.data?.generatedCode) {
|
||||||
|
mainData[field.columnName] = response.data.generatedCode;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`채번규칙 할당 실패 (${field.columnName}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 서브 테이블 데이터 구성
|
||||||
|
const subTablesData: Array<{
|
||||||
|
tableName: string;
|
||||||
|
linkColumn: { mainField: string; subColumn: string };
|
||||||
|
items: Record<string, any>[];
|
||||||
|
options?: {
|
||||||
|
saveMainAsFirst?: boolean;
|
||||||
|
mainFieldMappings?: Array<{ formField: string; targetColumn: string }>;
|
||||||
|
mainMarkerColumn?: string;
|
||||||
|
mainMarkerValue?: any;
|
||||||
|
subMarkerValue?: any;
|
||||||
|
deleteExistingBefore?: boolean;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const subTableConfig of multiTable.subTables || []) {
|
||||||
|
if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subItems: Record<string, any>[] = [];
|
||||||
|
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
|
||||||
|
|
||||||
|
// 반복 섹션 데이터를 필드 매핑에 따라 변환
|
||||||
|
for (const item of repeatData) {
|
||||||
|
const mappedItem: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 연결 컬럼 값 설정
|
||||||
|
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
|
||||||
|
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 매핑에 따라 데이터 변환
|
||||||
|
for (const mapping of subTableConfig.fieldMappings || []) {
|
||||||
|
if (mapping.formField && mapping.targetColumn) {
|
||||||
|
mappedItem[mapping.targetColumn] = item[mapping.formField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
|
||||||
|
if (subTableConfig.options?.mainMarkerColumn) {
|
||||||
|
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mappedItem).length > 0) {
|
||||||
|
subItems.push(mappedItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. subDepts 데이터 구성 (반복 섹션에서)
|
// saveMainAsFirst가 활성화된 경우, 메인 데이터를 서브 테이블에 저장하기 위한 매핑 생성
|
||||||
const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = [];
|
let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined;
|
||||||
|
if (subTableConfig.options?.saveMainAsFirst) {
|
||||||
if (subDeptSectionId && repeatSections[subDeptSectionId]) {
|
mainFieldMappings = [];
|
||||||
const subDeptItems = repeatSections[subDeptSectionId];
|
|
||||||
const deptCodeField = subDeptFields?.deptCodeField || "dept_code";
|
|
||||||
const deptNameField = subDeptFields?.deptNameField || "dept_name";
|
|
||||||
const positionNameField = subDeptFields?.positionNameField || "position_name";
|
|
||||||
|
|
||||||
subDeptItems.forEach((item) => {
|
// 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
|
||||||
const deptCode = item[deptCodeField];
|
// 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
|
||||||
if (deptCode) {
|
for (const mapping of subTableConfig.fieldMappings || []) {
|
||||||
subDepts.push({
|
if (mapping.targetColumn) {
|
||||||
dept_code: deptCode,
|
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
|
||||||
dept_name: item[deptNameField],
|
if (mainData[mapping.targetColumn] !== undefined) {
|
||||||
position_name: item[positionNameField],
|
mainFieldMappings.push({
|
||||||
});
|
formField: mapping.targetColumn,
|
||||||
|
targetColumn: mapping.targetColumn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
|
||||||
|
else {
|
||||||
|
config.sections.forEach((section) => {
|
||||||
|
if (section.repeatable) return;
|
||||||
|
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn);
|
||||||
|
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
||||||
|
mainFieldMappings!.push({
|
||||||
|
formField: matchingField.columnName,
|
||||||
|
targetColumn: mapping.targetColumn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 중복 제거
|
||||||
|
mainFieldMappings = mainFieldMappings.filter(
|
||||||
|
(m, idx, arr) => arr.findIndex((x) => x.targetColumn === m.targetColumn) === idx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. API 호출
|
subTablesData.push({
|
||||||
console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts });
|
tableName: subTableConfig.tableName,
|
||||||
|
linkColumn: subTableConfig.linkColumn,
|
||||||
const { saveUserWithDept } = await import("@/lib/api/user");
|
items: subItems,
|
||||||
const response = await saveUserWithDept({
|
options: {
|
||||||
userInfo: userInfo as any,
|
...subTableConfig.options,
|
||||||
mainDept,
|
mainFieldMappings, // 메인 데이터 매핑 추가
|
||||||
subDepts,
|
},
|
||||||
isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.success) {
|
// 3. 범용 다중 테이블 저장 API 호출
|
||||||
throw new Error(response.message || "사원 저장 실패");
|
const response = await apiClient.post("/table-management/multi-table-save", {
|
||||||
}
|
mainTable: multiTable.mainTable,
|
||||||
|
mainData,
|
||||||
|
subTables: subTablesData,
|
||||||
|
isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn],
|
||||||
|
});
|
||||||
|
|
||||||
console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data);
|
if (!response.data?.success) {
|
||||||
};
|
throw new Error(response.data?.message || "다중 테이블 저장 실패");
|
||||||
|
}
|
||||||
|
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
|
||||||
|
|
||||||
|
// 커스텀 API 저장
|
||||||
|
const saveWithCustomApi = useCallback(async () => {
|
||||||
|
const { customApiSave } = config.saveConfig;
|
||||||
|
if (!customApiSave) return;
|
||||||
|
|
||||||
const saveWithGenericCustomApi = async () => {
|
const saveWithGenericCustomApi = async () => {
|
||||||
if (!customApiSave.customEndpoint) {
|
if (!customApiSave.customEndpoint) {
|
||||||
|
|
@ -789,9 +902,10 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = customApiSave.customMethod || "POST";
|
const method = customApiSave.customMethod || "POST";
|
||||||
const response = method === "PUT"
|
const response =
|
||||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
method === "PUT"
|
||||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||||
|
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||||
|
|
||||||
if (!response.data?.success) {
|
if (!response.data?.success) {
|
||||||
throw new Error(response.data?.message || "저장 실패");
|
throw new Error(response.data?.message || "저장 실패");
|
||||||
|
|
@ -799,8 +913,8 @@ export function UniversalFormModalComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (customApiSave.apiType) {
|
switch (customApiSave.apiType) {
|
||||||
case "user-with-dept":
|
case "multi-table":
|
||||||
await saveUserWithDeptApi();
|
await saveWithMultiTable();
|
||||||
break;
|
break;
|
||||||
case "custom":
|
case "custom":
|
||||||
await saveWithGenericCustomApi();
|
await saveWithGenericCustomApi();
|
||||||
|
|
@ -808,7 +922,7 @@ export function UniversalFormModalComponent({
|
||||||
default:
|
default:
|
||||||
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
||||||
}
|
}
|
||||||
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
|
}, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
|
||||||
|
|
||||||
// 저장 처리
|
// 저장 처리
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
|
@ -869,7 +983,16 @@ export function UniversalFormModalComponent({
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]);
|
}, [
|
||||||
|
config,
|
||||||
|
formData,
|
||||||
|
repeatSections,
|
||||||
|
onSave,
|
||||||
|
validateRequiredFields,
|
||||||
|
saveSingleRow,
|
||||||
|
saveMultipleRows,
|
||||||
|
saveWithCustomApi,
|
||||||
|
]);
|
||||||
|
|
||||||
// 폼 초기화
|
// 폼 초기화
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
|
|
@ -878,12 +1001,14 @@ export function UniversalFormModalComponent({
|
||||||
}, [initializeForm]);
|
}, [initializeForm]);
|
||||||
|
|
||||||
// 필드 요소 렌더링 (입력 컴포넌트만)
|
// 필드 요소 렌더링 (입력 컴포넌트만)
|
||||||
|
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
||||||
const renderFieldElement = (
|
const renderFieldElement = (
|
||||||
field: FormFieldConfig,
|
field: FormFieldConfig,
|
||||||
value: any,
|
value: any,
|
||||||
onChangeHandler: (value: any) => void,
|
onChangeHandler: (value: any) => void,
|
||||||
fieldKey: string,
|
fieldKey: string,
|
||||||
isDisabled: boolean,
|
isDisabled: boolean,
|
||||||
|
repeatContext?: { sectionId: string; itemId: string },
|
||||||
) => {
|
) => {
|
||||||
return (() => {
|
return (() => {
|
||||||
switch (field.fieldType) {
|
switch (field.fieldType) {
|
||||||
|
|
@ -920,7 +1045,7 @@ export function UniversalFormModalComponent({
|
||||||
if (field.cascading?.enabled) {
|
if (field.cascading?.enabled) {
|
||||||
const cascadingConfig = field.cascading;
|
const cascadingConfig = field.cascading;
|
||||||
const parentValue = formData[cascadingConfig.parentField];
|
const parentValue = formData[cascadingConfig.parentField];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CascadingSelectField
|
<CascadingSelectField
|
||||||
fieldId={fieldKey}
|
fieldId={fieldKey}
|
||||||
|
|
@ -933,10 +1058,15 @@ export function UniversalFormModalComponent({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다중 컬럼 저장이 활성화된 경우
|
// 다중 컬럼 저장이 활성화된 경우
|
||||||
const lfgMappings = field.linkedFieldGroup?.mappings;
|
const lfgMappings = field.linkedFieldGroup?.mappings;
|
||||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
if (
|
||||||
|
field.linkedFieldGroup?.enabled &&
|
||||||
|
field.linkedFieldGroup?.sourceTable &&
|
||||||
|
lfgMappings &&
|
||||||
|
lfgMappings.length > 0
|
||||||
|
) {
|
||||||
const lfg = field.linkedFieldGroup;
|
const lfg = field.linkedFieldGroup;
|
||||||
const sourceTableName = lfg.sourceTable as string;
|
const sourceTableName = lfg.sourceTable as string;
|
||||||
const cachedData = linkedFieldDataCache[sourceTableName];
|
const cachedData = linkedFieldDataCache[sourceTableName];
|
||||||
|
|
@ -980,11 +1110,24 @@ export function UniversalFormModalComponent({
|
||||||
lfg.mappings.forEach((mapping) => {
|
lfg.mappings.forEach((mapping) => {
|
||||||
if (mapping.sourceColumn && mapping.targetColumn) {
|
if (mapping.sourceColumn && mapping.targetColumn) {
|
||||||
const mappedValue = selectedRow[mapping.sourceColumn];
|
const mappedValue = selectedRow[mapping.sourceColumn];
|
||||||
// formData에 직접 저장
|
|
||||||
setFormData((prev) => ({
|
// 반복 섹션인 경우 repeatSections에 저장, 아니면 formData에 저장
|
||||||
...prev,
|
if (repeatContext) {
|
||||||
[mapping.targetColumn]: mappedValue,
|
setRepeatSections((prev) => {
|
||||||
}));
|
const items = prev[repeatContext.sectionId] || [];
|
||||||
|
const newItems = items.map((item) =>
|
||||||
|
item._id === repeatContext.itemId
|
||||||
|
? { ...item, [mapping.targetColumn]: mappedValue }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
return { ...prev, [repeatContext.sectionId]: newItems };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[mapping.targetColumn]: mappedValue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -997,10 +1140,7 @@ export function UniversalFormModalComponent({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sourceData.length > 0 ? (
|
{sourceData.length > 0 ? (
|
||||||
sourceData.map((row, index) => (
|
sourceData.map((row, index) => (
|
||||||
<SelectItem
|
<SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
|
||||||
key={`${row[valueColumn] || index}_${index}`}
|
|
||||||
value={String(row[valueColumn] || "")}
|
|
||||||
>
|
|
||||||
{getDisplayText(row)}
|
{getDisplayText(row)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
|
|
@ -1127,12 +1267,14 @@ export function UniversalFormModalComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 렌더링 (섹션 열 수 적용)
|
// 필드 렌더링 (섹션 열 수 적용)
|
||||||
|
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
||||||
const renderFieldWithColumns = (
|
const renderFieldWithColumns = (
|
||||||
field: FormFieldConfig,
|
field: FormFieldConfig,
|
||||||
value: any,
|
value: any,
|
||||||
onChangeHandler: (value: any) => void,
|
onChangeHandler: (value: any) => void,
|
||||||
fieldKey: string,
|
fieldKey: string,
|
||||||
sectionColumns: number = 2,
|
sectionColumns: number = 2,
|
||||||
|
repeatContext?: { sectionId: string; itemId: string },
|
||||||
) => {
|
) => {
|
||||||
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
|
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
|
||||||
const defaultSpan = getDefaultGridSpan(sectionColumns);
|
const defaultSpan = getDefaultGridSpan(sectionColumns);
|
||||||
|
|
@ -1146,7 +1288,7 @@ export function UniversalFormModalComponent({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled);
|
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled, repeatContext);
|
||||||
|
|
||||||
if (field.fieldType === "checkbox") {
|
if (field.fieldType === "checkbox") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1286,6 +1428,7 @@ export function UniversalFormModalComponent({
|
||||||
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
|
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
|
||||||
`${section.id}-${item._id}-${field.id}`,
|
`${section.id}-${item._id}-${field.id}`,
|
||||||
sectionColumns,
|
sectionColumns,
|
||||||
|
{ sectionId: section.id, itemId: item._id }, // 반복 섹션 컨텍스트 전달
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -123,6 +123,7 @@ export interface FormFieldConfig {
|
||||||
// 연동 필드 매핑 설정
|
// 연동 필드 매핑 설정
|
||||||
export interface LinkedFieldMapping {
|
export interface LinkedFieldMapping {
|
||||||
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
|
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
|
||||||
|
targetTable?: string; // 저장할 테이블 (선택, 없으면 자동 결정)
|
||||||
targetColumn: string; // 저장할 컬럼 (예: "position_code")
|
targetColumn: string; // 저장할 컬럼 (예: "position_code")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,42 +210,92 @@ export interface SaveConfig {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서브 테이블 필드 매핑
|
||||||
|
* 폼 필드(columnName)를 서브 테이블의 컬럼에 매핑합니다.
|
||||||
|
*/
|
||||||
|
export interface SubTableFieldMapping {
|
||||||
|
formField: string; // 폼 필드의 columnName
|
||||||
|
targetColumn: string; // 서브 테이블의 컬럼명
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서브 테이블 저장 설정
|
||||||
|
* 반복 섹션의 데이터를 별도 테이블에 저장하는 설정입니다.
|
||||||
|
*/
|
||||||
|
export interface SubTableSaveConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
tableName: string; // 서브 테이블명 (예: user_dept, order_items)
|
||||||
|
repeatSectionId: string; // 연결할 반복 섹션 ID
|
||||||
|
|
||||||
|
// 연결 설정 (메인 테이블과 서브 테이블 연결)
|
||||||
|
linkColumn: {
|
||||||
|
mainField: string; // 메인 테이블의 연결 필드 (예: user_id)
|
||||||
|
subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 매핑 (반복 섹션 필드 → 서브 테이블 컬럼)
|
||||||
|
fieldMappings: SubTableFieldMapping[];
|
||||||
|
|
||||||
|
// 추가 옵션
|
||||||
|
options?: {
|
||||||
|
// 메인 데이터도 서브 테이블에 저장 (1:N에서 메인도 저장할 때)
|
||||||
|
saveMainAsFirst?: boolean;
|
||||||
|
mainFieldMappings?: SubTableFieldMapping[]; // 메인 데이터용 필드 매핑
|
||||||
|
mainMarkerColumn?: string; // 메인 여부 표시 컬럼 (예: is_primary)
|
||||||
|
mainMarkerValue?: any; // 메인일 때 값 (예: true)
|
||||||
|
subMarkerValue?: any; // 서브일 때 값 (예: false)
|
||||||
|
|
||||||
|
// 저장 전 기존 데이터 삭제
|
||||||
|
deleteExistingBefore?: boolean;
|
||||||
|
deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 테이블 저장 설정 (범용)
|
||||||
|
*
|
||||||
|
* 메인 테이블 + 서브 테이블(들)에 트랜잭션으로 저장합니다.
|
||||||
|
*
|
||||||
|
* ## 사용 예시
|
||||||
|
*
|
||||||
|
* ### 사원 + 부서 (user_info + user_dept)
|
||||||
|
* - 메인 테이블: user_info (사원 정보)
|
||||||
|
* - 서브 테이블: user_dept (부서 관계, 메인 부서 + 겸직 부서)
|
||||||
|
*
|
||||||
|
* ### 주문 + 주문상세 (orders + order_items)
|
||||||
|
* - 메인 테이블: orders (주문 정보)
|
||||||
|
* - 서브 테이블: order_items (주문 상품 목록)
|
||||||
|
*/
|
||||||
|
export interface MultiTableSaveConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
// 메인 테이블 설정
|
||||||
|
mainTable: {
|
||||||
|
tableName: string; // 메인 테이블명
|
||||||
|
primaryKeyColumn: string; // PK 컬럼명
|
||||||
|
};
|
||||||
|
|
||||||
|
// 서브 테이블 설정 (여러 개 가능)
|
||||||
|
subTables: SubTableSaveConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커스텀 API 저장 설정
|
* 커스텀 API 저장 설정
|
||||||
*
|
*
|
||||||
* 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다.
|
* 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다.
|
||||||
* 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다.
|
* 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다.
|
||||||
*
|
|
||||||
* ## 지원하는 API 타입
|
|
||||||
* - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept)
|
|
||||||
*
|
|
||||||
* ## 데이터 매핑 설정
|
|
||||||
* - `userInfoFields`: user_info 테이블에 저장할 필드 매핑
|
|
||||||
* - `mainDeptFields`: 메인 부서 정보 필드 매핑
|
|
||||||
* - `subDeptSectionId`: 겸직 부서 반복 섹션 ID
|
|
||||||
*/
|
*/
|
||||||
export interface CustomApiSaveConfig {
|
export interface CustomApiSaveConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입
|
apiType: "multi-table" | "custom"; // API 타입
|
||||||
|
|
||||||
// user-with-dept 전용 설정
|
// 다중 테이블 저장 설정 (범용)
|
||||||
userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName)
|
multiTable?: MultiTableSaveConfig;
|
||||||
mainDeptFields?: {
|
|
||||||
deptCodeField?: string; // 메인 부서코드 필드명
|
|
||||||
deptNameField?: string; // 메인 부서명 필드명
|
|
||||||
positionNameField?: string; // 메인 직급 필드명
|
|
||||||
};
|
|
||||||
subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID
|
|
||||||
subDeptFields?: {
|
|
||||||
deptCodeField?: string; // 겸직 부서코드 필드명
|
|
||||||
deptNameField?: string; // 겸직 부서명 필드명
|
|
||||||
positionNameField?: string; // 겸직 직급 필드명
|
|
||||||
};
|
|
||||||
|
|
||||||
// 커스텀 API 전용 설정
|
// 커스텀 API 전용 설정
|
||||||
customEndpoint?: string; // 커스텀 API 엔드포인트
|
customEndpoint?: string; // 커스텀 API 엔드포인트
|
||||||
customMethod?: "POST" | "PUT"; // HTTP 메서드
|
customMethod?: "POST" | "PUT"; // HTTP 메서드
|
||||||
customDataTransform?: string; // 데이터 변환 함수명 (추후 확장)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모달 설정
|
// 모달 설정
|
||||||
|
|
|
||||||
|
|
@ -3836,6 +3836,7 @@ export class ButtonActionExecutor {
|
||||||
const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context);
|
const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context);
|
||||||
|
|
||||||
if (keyValue) {
|
if (keyValue) {
|
||||||
|
// 상태 업데이트
|
||||||
await apiClient.put("/dynamic-form/update-field", {
|
await apiClient.put("/dynamic-form/update-field", {
|
||||||
tableName: statusTableName,
|
tableName: statusTableName,
|
||||||
keyField: keyField,
|
keyField: keyField,
|
||||||
|
|
@ -3844,6 +3845,37 @@ export class ButtonActionExecutor {
|
||||||
updateValue: config.trackingStatusOnStart,
|
updateValue: config.trackingStatusOnStart,
|
||||||
});
|
});
|
||||||
console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart);
|
console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart);
|
||||||
|
|
||||||
|
// 🆕 출발지/도착지도 vehicles 테이블에 저장
|
||||||
|
if (departure) {
|
||||||
|
try {
|
||||||
|
await apiClient.put("/dynamic-form/update-field", {
|
||||||
|
tableName: statusTableName,
|
||||||
|
keyField: keyField,
|
||||||
|
keyValue: keyValue,
|
||||||
|
updateField: "departure",
|
||||||
|
updateValue: departure,
|
||||||
|
});
|
||||||
|
console.log("✅ 출발지 저장 완료:", departure);
|
||||||
|
} catch {
|
||||||
|
// 컬럼이 없으면 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrival) {
|
||||||
|
try {
|
||||||
|
await apiClient.put("/dynamic-form/update-field", {
|
||||||
|
tableName: statusTableName,
|
||||||
|
keyField: keyField,
|
||||||
|
keyValue: keyValue,
|
||||||
|
updateField: "arrival",
|
||||||
|
updateValue: arrival,
|
||||||
|
});
|
||||||
|
console.log("✅ 도착지 저장 완료:", arrival);
|
||||||
|
} catch {
|
||||||
|
// 컬럼이 없으면 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (statusError) {
|
} catch (statusError) {
|
||||||
console.warn("⚠️ 상태 변경 실패:", statusError);
|
console.warn("⚠️ 상태 변경 실패:", statusError);
|
||||||
|
|
@ -4050,6 +4082,23 @@ export class ButtonActionExecutor {
|
||||||
updateValue: effectiveConfig.trackingStatusOnStop,
|
updateValue: effectiveConfig.trackingStatusOnStop,
|
||||||
});
|
});
|
||||||
console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop);
|
console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop);
|
||||||
|
|
||||||
|
// 🆕 운행 종료 시 vehicles 테이블의 출발지/도착지/위도/경도를 null로 초기화
|
||||||
|
const fieldsToReset = ["departure", "arrival", "latitude", "longitude"];
|
||||||
|
for (const field of fieldsToReset) {
|
||||||
|
try {
|
||||||
|
await apiClient.put("/dynamic-form/update-field", {
|
||||||
|
tableName: statusTableName,
|
||||||
|
keyField: keyField,
|
||||||
|
keyValue: keyValue,
|
||||||
|
updateField: field,
|
||||||
|
updateValue: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 컬럼이 없으면 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("✅ 출발지/도착지/위도/경도 초기화 완료");
|
||||||
}
|
}
|
||||||
} catch (statusError) {
|
} catch (statusError) {
|
||||||
console.warn("⚠️ 상태 변경 실패:", statusError);
|
console.warn("⚠️ 상태 변경 실패:", statusError);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue