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> => {
|
||||
try {
|
||||
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] 요청:", {
|
||||
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({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
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({
|
||||
success: false,
|
||||
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-batch", flowController.moveBatchData);
|
||||
|
||||
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
|
||||
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
|
||||
|
||||
// ==================== 오딧 로그 ====================
|
||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
getLogData,
|
||||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
|||
*/
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -65,12 +65,18 @@ export class BatchSchedulerService {
|
|||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||
);
|
||||
|
||||
const task = cron.schedule(config.cron_schedule, async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
const task = cron.schedule(
|
||||
config.cron_schedule,
|
||||
async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
},
|
||||
{
|
||||
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
|
||||
}
|
||||
);
|
||||
|
||||
this.scheduledTasks.set(config.id, task);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ export class FlowDataMoveService {
|
|||
// 내부 DB 처리 (기존 로직)
|
||||
return await db.transaction(async (client) => {
|
||||
try {
|
||||
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
||||
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||
userId || "system",
|
||||
]);
|
||||
|
||||
// 1. 단계 정보 조회
|
||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||
const toStep = await this.flowStepService.findById(toStepId);
|
||||
|
|
@ -684,6 +689,14 @@ export class FlowDataMoveService {
|
|||
dbConnectionId,
|
||||
async (externalClient, dbType) => {
|
||||
try {
|
||||
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
||||
if (dbType.toLowerCase() === "postgresql") {
|
||||
await externalClient.query(
|
||||
"SELECT set_config('app.user_id', $1, true)",
|
||||
[userId || "system"]
|
||||
);
|
||||
}
|
||||
|
||||
// 1. 단계 정보 조회 (내부 DB에서)
|
||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||
const toStep = await this.flowStepService.findById(toStepId);
|
||||
|
|
|
|||
|
|
@ -263,4 +263,139 @@ export class FlowExecutionService {
|
|||
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 {
|
||||
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) {
|
||||
await this.executeLevel(level, nodes, edges, context, client);
|
||||
|
|
|
|||
|
|
@ -897,13 +897,13 @@ class NumberingRuleService {
|
|||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
|
@ -957,13 +957,13 @@ class NumberingRuleService {
|
|||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (자동 증가 숫자)
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import {
|
||||
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
||||
} from "lucide-react";
|
||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||
|
||||
|
|
@ -9,208 +7,206 @@ import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
|||
*/
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 pt-12 pb-16 space-y-16">
|
||||
|
||||
{/* 주요 관리 기능 */}
|
||||
<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-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 className="bg-background min-h-screen">
|
||||
<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="mb-8 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">주요 관리 기능</h2>
|
||||
<p className="text-muted-foreground">시스템의 핵심 관리 기능들을 제공합니다</p>
|
||||
</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="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
||||
<Shield className="h-6 w-6 text-success" />
|
||||
{/* <div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<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>
|
||||
<h3 className="font-semibold text-foreground">권한 관리</h3>
|
||||
<p className="text-sm text-muted-foreground">메뉴 및 기능 권한 설정</p>
|
||||
|
||||
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||
<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 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 className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<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 className="mx-auto max-w-7xl space-y-10">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">표준 관리</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="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 className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-warning/10">
|
||||
<BarChart3 className="h-6 w-6 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">통계 및 리포트</h3>
|
||||
<p className="text-sm text-muted-foreground">시스템 사용 현황 분석</p>
|
||||
</div>
|
||||
{/* 빠른 액세스 */}
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">빠른 액세스</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="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>
|
||||
|
||||
<Link href="/admin/screenMng" 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">
|
||||
<Palette 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 className="mx-auto max-w-7xl space-y-6">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-foreground mb-2 text-2xl font-bold">전역 파일 관리</h2>
|
||||
<p className="text-muted-foreground">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
||||
</div>
|
||||
</Link>
|
||||
<GlobalFileViewer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,12 +276,12 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
||||
</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">
|
||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-shrink-0 border-t pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
취소
|
||||
|
|
|
|||
|
|
@ -379,6 +379,47 @@ export interface ListWidgetConfig {
|
|||
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
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";
|
||||
|
||||
import React from "react";
|
||||
import { ListWidgetConfig, QueryResult } from "../types";
|
||||
import React, { useState } from "react";
|
||||
import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
||||
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 {
|
||||
queryResult: QueryResult | null;
|
||||
|
|
@ -16,8 +23,91 @@ interface 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 (
|
||||
<div className="space-y-3">
|
||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||
|
|
@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
|||
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import { Truck, Clock, MapPin, Package, Info } from "lucide-react";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
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 || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
|
|
@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
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(() => {
|
||||
const loadData = async () => {
|
||||
|
|
@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
</TableRow>
|
||||
) : (
|
||||
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
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
|
|
@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
}}
|
||||
>
|
||||
{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">
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
|
|
@ -345,6 +577,49 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -526,7 +526,8 @@ function MaterialBox({
|
|||
case "location-temp":
|
||||
case "location-dest":
|
||||
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
|
||||
// 자재가 없으면 0, 있으면 해당 개수 표시 (기본값 5 제거)
|
||||
const locPlateCount = placement.material_count ?? placement.quantity ?? 0;
|
||||
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
||||
const locPlateThickness = 0.15; // 각 철판 두께
|
||||
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
||||
|
|
@ -538,8 +539,32 @@ function MaterialBox({
|
|||
|
||||
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 xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||
|
|
@ -570,7 +595,7 @@ function MaterialBox({
|
|||
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||
{placement.name && (
|
||||
<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]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||
color="#374151"
|
||||
|
|
|
|||
|
|
@ -115,24 +115,33 @@ export function TableHistoryModal({
|
|||
const getOperationBadge = (type: string) => {
|
||||
switch (type) {
|
||||
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":
|
||||
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":
|
||||
return <Badge className="bg-red-100 text-xs text-red-800">삭제</Badge>;
|
||||
return <span className="text-sm font-medium text-red-600">삭제</span>;
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{type}
|
||||
</Badge>
|
||||
);
|
||||
return <span className="text-sm font-medium text-gray-600">{type}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
// DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환
|
||||
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 });
|
||||
} catch {
|
||||
return dateString;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
"use client";
|
||||
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
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 { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
|
|
@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
|
|
@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
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)
|
||||
const visibleColumnConfigs = useMemo(() => {
|
||||
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
|
||||
|
|
@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
)}
|
||||
<TableBody>
|
||||
{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) => (
|
||||
<TableCell key={field} className="whitespace-nowrap">
|
||||
{String(row[field] ?? "")}
|
||||
|
|
@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
return (
|
||||
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
||||
{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) => (
|
||||
<div key={field} className="mb-2">
|
||||
<span className="font-semibold">{getLabel(field)}: </span>
|
||||
|
|
@ -489,6 +721,49 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
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";
|
||||
|
||||
// Popup 말풍선 꼬리 제거 스타일
|
||||
|
|
@ -101,6 +103,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
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 방지)
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
|
@ -182,6 +194,151 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
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 () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
|
|
@ -254,6 +411,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
setMarkers(markersWithHeading);
|
||||
setPolygons(allPolygons);
|
||||
setLastRefreshTime(new Date());
|
||||
|
||||
// 마커 로드 후 운행/공차 정보 미리 일괄 조회
|
||||
preloadTripInfo(markersWithHeading);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
|
|
@ -1130,14 +1290,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
loadMultipleDataSources();
|
||||
// Popup이 열려있으면 자동 새로고침 건너뛰기
|
||||
if (!isPopupOpen) {
|
||||
loadMultipleDataSources();
|
||||
}
|
||||
}, refreshInterval * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
||||
}, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]);
|
||||
|
||||
// 타일맵 URL (VWorld 한국 지도)
|
||||
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>
|
||||
</div>
|
||||
<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 && (
|
||||
<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,
|
||||
weight: 2,
|
||||
}}
|
||||
eventHandlers={{
|
||||
popupopen: () => setIsPopupOpen(true),
|
||||
popupclose: () => setIsPopupOpen(false),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<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 markerType = sourceDataSource?.markerType || "circle";
|
||||
|
|
@ -1602,7 +1783,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}
|
||||
|
||||
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}>
|
||||
<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]">
|
||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||
|
|
@ -1771,7 +2115,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
{/* 하단 정보 */}
|
||||
{(markers.length > 0 || polygons.length > 0) && (
|
||||
<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 && " · "}
|
||||
{polygons.length > 0 && `영역 ${polygons.length}개`}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
|
@ -130,11 +130,11 @@ export function FlowDataListModal({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
{stepName}
|
||||
<Badge variant="secondary">{data.length}건</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</DialogDescription>
|
||||
</ResizableDialogTitle>
|
||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</ResizableDialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
<Select
|
||||
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}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -196,10 +196,31 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
try {
|
||||
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로 필터링)
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -62,7 +62,7 @@ import {
|
|||
CostType,
|
||||
costTypeLabels,
|
||||
} from "@/lib/api/taxInvoice";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { uploadFiles } from "@/lib/api/file";
|
||||
|
||||
interface TaxInvoiceFormProps {
|
||||
open: boolean;
|
||||
|
|
@ -223,36 +223,35 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor
|
|||
});
|
||||
};
|
||||
|
||||
// 파일 업로드
|
||||
// 파일 업로드 (화면 관리 파일 업로드 컴포넌트와 동일한 방식 사용)
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
|
||||
formDataUpload.append("category", "tax-invoice");
|
||||
// 화면 관리 파일 업로드 컴포넌트와 동일한 uploadFiles 함수 사용
|
||||
const response = await uploadFiles({
|
||||
files: files,
|
||||
tableName: "tax_invoice",
|
||||
fieldName: "attachments",
|
||||
recordId: invoice?.id,
|
||||
docType: "tax-invoice",
|
||||
docTypeName: "세금계산서",
|
||||
});
|
||||
|
||||
const response = await apiClient.post("/files/upload", formDataUpload, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.files?.length > 0) {
|
||||
const uploadedFile = response.data.files[0];
|
||||
const newAttachment: TaxInvoiceAttachment = {
|
||||
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file_name: uploadedFile.realFileName || file.name,
|
||||
file_path: uploadedFile.filePath,
|
||||
file_size: uploadedFile.fileSize || file.size,
|
||||
file_type: file.type,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
uploaded_by: "",
|
||||
};
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
toast.success(`'${file.name}' 업로드 완료`);
|
||||
}
|
||||
if (response.success && response.files?.length > 0) {
|
||||
const newAttachments: TaxInvoiceAttachment[] = response.files.map((uploadedFile) => ({
|
||||
id: uploadedFile.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file_name: uploadedFile.name,
|
||||
file_path: uploadedFile.serverPath || "",
|
||||
file_size: uploadedFile.size,
|
||||
file_type: uploadedFile.type,
|
||||
uploaded_at: uploadedFile.uploadedAt || new Date().toISOString(),
|
||||
uploaded_by: "",
|
||||
}));
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
toast.success(`${response.files.length}개 파일 업로드 완료`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
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와 명확히 구분
|
||||
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
|
||||
// 🆕 그룹 데이터 전달 (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";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2, X } from "lucide-react";
|
||||
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
|
||||
import { Trash2, Loader2, X, Plus } from "lucide-react";
|
||||
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { useCalculation } from "./useCalculation";
|
||||
|
|
@ -21,6 +21,7 @@ export interface SimpleRepeaterTableComponentProps extends ComponentRendererProp
|
|||
readOnly?: boolean;
|
||||
showRowNumber?: boolean;
|
||||
allowDelete?: boolean;
|
||||
allowAdd?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
|
|
@ -44,10 +45,31 @@ export function SimpleRepeaterTableComponent({
|
|||
readOnly: propReadOnly,
|
||||
showRowNumber: propShowRowNumber,
|
||||
allowDelete: propAllowDelete,
|
||||
allowAdd: propAllowAdd,
|
||||
maxHeight: propMaxHeight,
|
||||
|
||||
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
||||
_initialData,
|
||||
_originalData,
|
||||
_groupedData,
|
||||
// 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음)
|
||||
initialData: legacyInitialData,
|
||||
originalData: legacyOriginalData,
|
||||
groupedData: legacyGroupedData,
|
||||
|
||||
...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 우선순위로 병합
|
||||
const componentConfig = {
|
||||
...config,
|
||||
|
|
@ -60,6 +82,13 @@ export function SimpleRepeaterTableComponent({
|
|||
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
||||
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? 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";
|
||||
|
||||
// value는 formData[columnName] 우선, 없으면 prop 사용
|
||||
|
|
@ -256,7 +285,7 @@ export function SimpleRepeaterTableComponent({
|
|||
useEffect(() => {
|
||||
const handleSaveRequest = async (event: Event) => {
|
||||
if (value.length === 0) {
|
||||
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
||||
// console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +326,7 @@ export function SimpleRepeaterTableComponent({
|
|||
});
|
||||
});
|
||||
|
||||
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
||||
// console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
||||
|
||||
// CustomEvent의 detail에 테이블별 데이터 추가
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
|
|
@ -310,10 +339,10 @@ export function SimpleRepeaterTableComponent({
|
|||
}));
|
||||
});
|
||||
|
||||
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
||||
tables: Object.keys(dataByTable),
|
||||
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
||||
});
|
||||
// console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
||||
// tables: Object.keys(dataByTable),
|
||||
// totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
||||
// });
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
|
|
@ -345,10 +374,137 @@ export function SimpleRepeaterTableComponent({
|
|||
};
|
||||
|
||||
const handleRowDelete = (rowIndex: number) => {
|
||||
// 최소 행 수 체크
|
||||
if (value.length <= minRows) {
|
||||
return;
|
||||
}
|
||||
const newData = value.filter((_, i) => i !== rowIndex);
|
||||
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 = (
|
||||
row: any,
|
||||
column: SimpleRepeaterColumnConfig,
|
||||
|
|
@ -457,8 +613,18 @@ export function SimpleRepeaterTableComponent({
|
|||
);
|
||||
}
|
||||
|
||||
// 테이블 컬럼 수 계산
|
||||
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
|
||||
|
||||
return (
|
||||
<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
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
|
|
@ -492,10 +658,17 @@ export function SimpleRepeaterTableComponent({
|
|||
{value.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
|
||||
colSpan={totalColumns}
|
||||
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>
|
||||
</tr>
|
||||
) : (
|
||||
|
|
@ -517,7 +690,8 @@ export function SimpleRepeaterTableComponent({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
|
|
@ -529,6 +703,58 @@ export function SimpleRepeaterTableComponent({
|
|||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
ColumnTargetConfig,
|
||||
InitialDataConfig,
|
||||
DataFilterCondition,
|
||||
SummaryConfig,
|
||||
SummaryFieldConfig,
|
||||
} from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -482,6 +484,81 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
</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.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">
|
||||
<Label className="text-xs sm:text-sm">최대 높이</Label>
|
||||
<Input
|
||||
|
|
@ -508,21 +585,29 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">소스 테이블</Label>
|
||||
<Select
|
||||
value={localConfig.initialDataConfig?.sourceTable || ""}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
initialDataConfig: {
|
||||
...localConfig.initialDataConfig,
|
||||
sourceTable: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={localConfig.initialDataConfig?.sourceTable || "__none__"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__none__") {
|
||||
// 선택 안 함: initialDataConfig 초기화
|
||||
updateConfig({
|
||||
initialDataConfig: undefined,
|
||||
});
|
||||
} else {
|
||||
updateConfig({
|
||||
initialDataConfig: {
|
||||
...localConfig.initialDataConfig,
|
||||
sourceTable: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-10 text-sm w-full">
|
||||
<SelectValue placeholder={isLoadingTables ? "로딩 중..." : "데이터를 가져올 테이블 선택"} />
|
||||
<SelectValue placeholder={isLoadingTables ? "로딩 중..." : "선택 안 함 (빈 테이블로 시작)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안 함 (빈 테이블로 시작)</SelectItem>
|
||||
{allTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
|
|
@ -531,7 +616,7 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
초기 데이터를 조회할 테이블 (예: sales_order_mng)
|
||||
선택 안 하면 빈 테이블로 시작합니다 (새 데이터 입력용)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -1002,48 +1087,71 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
)}
|
||||
</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-green-500"></div>
|
||||
<Label className="text-xs font-semibold text-green-600">데이터 타겟 설정 (어디에 저장?)</Label>
|
||||
{/* 🆕 데이터 타겟 설정 - 부모-자식 모드면 숨김 */}
|
||||
{localConfig.parentChildConfig?.enabled ? (
|
||||
// 부모-자식 모드: 간단한 안내만 표시
|
||||
<div className="border-t pt-4">
|
||||
<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 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">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Select
|
||||
value={col.targetConfig?.targetTable || ""}
|
||||
onValueChange={(value) => updateColumn(index, {
|
||||
targetConfig: {
|
||||
...col.targetConfig,
|
||||
targetTable: value,
|
||||
saveEnabled: true,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="저장할 테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Select
|
||||
value={col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" ? col.targetConfig.targetTable : "__none__"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__none__") {
|
||||
// 선택 안 함: targetConfig 초기화
|
||||
updateColumn(index, {
|
||||
targetConfig: undefined
|
||||
});
|
||||
} else {
|
||||
// 테이블 선택: targetConfig 설정
|
||||
updateColumn(index, {
|
||||
targetConfig: {
|
||||
targetTable: value,
|
||||
targetColumn: col.field || "",
|
||||
saveEnabled: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택 안 함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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">
|
||||
<Label className="text-xs">타겟 컬럼</Label>
|
||||
<SourceColumnSelector
|
||||
sourceTable={col.targetConfig.targetTable}
|
||||
value={col.targetConfig.targetColumn || ""}
|
||||
value={col.targetConfig.targetColumn || col.field || ""}
|
||||
onChange={(value) => updateColumn(index, {
|
||||
targetConfig: {
|
||||
...col.targetConfig,
|
||||
|
|
@ -1052,37 +1160,10 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
})}
|
||||
showTableName={true}
|
||||
/>
|
||||
<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">저장 활성화</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>
|
||||
)}
|
||||
|
||||
{/* 편집 가능 여부 */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1235,11 +1316,13 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(localConfig.columns || []).map((col, colIndex) => (
|
||||
<SelectItem key={col.field || `empty-${colIndex}`} value={col.field || ""}>
|
||||
{col.label} ({col.field || '미설정'})
|
||||
</SelectItem>
|
||||
))}
|
||||
{(localConfig.columns || [])
|
||||
.filter((col) => col.field && col.field.trim() !== "")
|
||||
.map((col, colIndex) => (
|
||||
<SelectItem key={col.field || `col-${colIndex}`} value={col.field}>
|
||||
{col.label} ({col.field})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
|
|
@ -1314,15 +1397,285 @@ export function SimpleRepeaterTableConfigPanel({
|
|||
)}
|
||||
</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">
|
||||
<p className="font-medium mb-2">SimpleRepeaterTable 사용법:</p>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>주어진 데이터를 표시하고 편집하는 경량 테이블입니다</li>
|
||||
<li>검색/추가 기능은 없으며, 상위 컴포넌트에서 데이터를 전달받습니다</li>
|
||||
<li><strong>행 추가 허용</strong> 옵션으로 사용자가 새 행을 추가할 수 있습니다</li>
|
||||
<li>주로 EditModal과 함께 사용되며, 선택된 데이터를 일괄 수정할 때 유용합니다</li>
|
||||
<li>readOnly 옵션으로 전체 테이블을 읽기 전용으로 만들 수 있습니다</li>
|
||||
<li>자동 계산 규칙을 통해 수량 * 단가 = 금액 같은 계산을 자동화할 수 있습니다</li>
|
||||
<li><strong>합계 설정</strong>으로 테이블 하단에 합계/평균 등을 표시할 수 있습니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { ComponentRendererProps } from "@/types/component";
|
|||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
|
||||
|
||||
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
||||
// console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
||||
|
||||
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
|
||||
return <SimpleRepeaterTableComponent {...props} />;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,15 @@ export const SimpleRepeaterTableDefinition = createComponentDefinition({
|
|||
readOnly: false,
|
||||
showRowNumber: true,
|
||||
allowDelete: true,
|
||||
allowAdd: false,
|
||||
addButtonText: "행 추가",
|
||||
addButtonPosition: "bottom",
|
||||
minRows: 0,
|
||||
maxRows: undefined,
|
||||
summaryConfig: {
|
||||
enabled: false,
|
||||
fields: [],
|
||||
},
|
||||
maxHeight: "240px",
|
||||
},
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
|
|
@ -51,6 +60,8 @@ export type {
|
|||
InitialDataConfig,
|
||||
DataFilterCondition,
|
||||
SourceJoinCondition,
|
||||
SummaryConfig,
|
||||
SummaryFieldConfig,
|
||||
} 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;
|
||||
|
||||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -905,11 +905,22 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
|
||||
// 좌측 패널 항목 렌더링
|
||||
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 hasChildren = item.children?.length > 0;
|
||||
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 설정에 따라 컬럼 분류
|
||||
const displayColumns = config.leftPanel?.displayColumns || [];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ interface SingleTableWithStickyProps {
|
|||
containerWidth?: string; // 컨테이너 너비 설정
|
||||
loading?: boolean;
|
||||
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> = ({
|
||||
|
|
@ -51,6 +62,17 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
containerWidth,
|
||||
loading = false,
|
||||
error = null,
|
||||
// 인라인 편집 관련 props
|
||||
onCellDoubleClick,
|
||||
editingCell,
|
||||
editingValue,
|
||||
onEditingValueChange,
|
||||
onEditKeyDown,
|
||||
editInputRef,
|
||||
// 검색 하이라이트 관련 props
|
||||
searchHighlights,
|
||||
currentSearchIndex = 0,
|
||||
searchTerm = "",
|
||||
}) => {
|
||||
const checkboxConfig = tableConfig?.checkbox || {};
|
||||
const actualColumns = visibleColumns || columns || [];
|
||||
|
|
@ -58,14 +80,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm"
|
||||
className="relative flex flex-col bg-background shadow-sm"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex-1 overflow-auto">
|
||||
<div className="relative overflow-x-auto">
|
||||
<Table
|
||||
className="w-full"
|
||||
style={{
|
||||
|
|
@ -75,17 +96,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
}}
|
||||
>
|
||||
<TableHeader
|
||||
className={
|
||||
tableConfig.stickyHeader
|
||||
? "sticky top-0 border-b shadow-md"
|
||||
: "border-b"
|
||||
}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
className={cn(
|
||||
"border-b bg-background",
|
||||
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
|
||||
)}
|
||||
>
|
||||
<TableRow className="border-b">
|
||||
{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)
|
||||
: 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 (
|
||||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
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",
|
||||
`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",
|
||||
column.fixed === "right" &&
|
||||
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
|
||||
// 편집 가능 셀 스타일
|
||||
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
|
|
@ -239,10 +311,36 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(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__"
|
||||
? renderCheckboxCell(row, index)
|
||||
: formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"}
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxCell(row, index)
|
||||
) : 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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -4674,20 +4674,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 새로고침 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
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>
|
||||
{/* 새로고침 버튼 (하단 페이지네이션) */}
|
||||
{(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
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>
|
||||
)}
|
||||
</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 스타일 기능 툴바 */}
|
||||
<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">
|
||||
<Button
|
||||
variant={editMode === "batch" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
|
||||
className="h-7 text-xs"
|
||||
title="배치 편집 모드 (Ctrl+B)"
|
||||
>
|
||||
<Edit className="mr-1 h-3 w-3" />
|
||||
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
{(tableConfig.toolbar?.showEditMode ?? true) && (
|
||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||
<Button
|
||||
variant={editMode === "batch" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
|
||||
className="h-7 text-xs"
|
||||
title="배치 편집 모드 (Ctrl+B)"
|
||||
>
|
||||
<Edit className="mr-1 h-3 w-3" />
|
||||
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내보내기 버튼들 */}
|
||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => exportToExcel(true)}
|
||||
className="h-7 text-xs"
|
||||
title="Excel 내보내기"
|
||||
>
|
||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
||||
Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => exportToPdf(true)}
|
||||
className="h-7 text-xs"
|
||||
title="PDF 내보내기"
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
{((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && (
|
||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||
{(tableConfig.toolbar?.showExcel ?? true) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => exportToExcel(true)}
|
||||
className="h-7 text-xs"
|
||||
title="Excel 내보내기"
|
||||
>
|
||||
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
||||
Excel
|
||||
</Button>
|
||||
)}
|
||||
{(tableConfig.toolbar?.showPdf ?? true) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => exportToPdf(true)}
|
||||
className="h-7 text-xs"
|
||||
title="PDF 내보내기"
|
||||
>
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
disabled={selectedRows.size === 0 && !focusedCell}
|
||||
className="h-7 text-xs"
|
||||
title="복사 (Ctrl+C)"
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
</div>
|
||||
{(tableConfig.toolbar?.showCopy ?? true) && (
|
||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
disabled={selectedRows.size === 0 && !focusedCell}
|
||||
className="h-7 text-xs"
|
||||
title="복사 (Ctrl+C)"
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택 정보 */}
|
||||
{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">
|
||||
{isSearchPanelOpen ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={globalSearchTerm}
|
||||
onChange={(e) => setGlobalSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
executeGlobalSearch(globalSearchTerm);
|
||||
} else if (e.key === "Escape") {
|
||||
clearGlobalSearch();
|
||||
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
goToPrevSearchResult();
|
||||
} else {
|
||||
goToNextSearchResult();
|
||||
{(tableConfig.toolbar?.showSearch ?? true) && (
|
||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||
{isSearchPanelOpen ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={globalSearchTerm}
|
||||
onChange={(e) => setGlobalSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
executeGlobalSearch(globalSearchTerm);
|
||||
} else if (e.key === "Escape") {
|
||||
clearGlobalSearch();
|
||||
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
goToPrevSearchResult();
|
||||
} else {
|
||||
goToNextSearchResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
{searchHighlights.size > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{searchHighlights.size}개
|
||||
}}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
{searchHighlights.size > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{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>
|
||||
)}
|
||||
</Button>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToPrevSearchResult}
|
||||
disabled={searchHighlights.size === 0}
|
||||
onClick={clearFilterBuilder}
|
||||
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)"
|
||||
title="필터 초기화"
|
||||
>
|
||||
<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 (고급 필터) 버튼 */}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 새로고침 */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
{(tableConfig.toolbar?.showRefresh ?? true) && (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 🆕 배치 편집 툴바 */}
|
||||
|
|
|
|||
|
|
@ -765,6 +765,81 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -164,6 +164,20 @@ export interface PaginationConfig {
|
|||
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;
|
||||
refreshInterval?: number; // 초 단위
|
||||
|
||||
// 🆕 툴바 버튼 표시 설정
|
||||
toolbar?: ToolbarConfig;
|
||||
|
||||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
dataFilter?: DataFilterConfig;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide
|
|||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
|
|
@ -81,11 +81,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
const isDisabled = disabled || !parentValue || loading;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={onChange}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
||||
<SelectTrigger id={fieldId} className="w-full">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -127,11 +123,16 @@ export function UniversalFormModalComponent({
|
|||
isSelected = false,
|
||||
className,
|
||||
style,
|
||||
initialData,
|
||||
initialData: propInitialData,
|
||||
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
|
||||
_initialData,
|
||||
onSave,
|
||||
onCancel,
|
||||
onChange,
|
||||
}: UniversalFormModalComponentProps) {
|
||||
...restProps // 나머지 props는 DOM에 전달하지 않음
|
||||
}: UniversalFormModalComponentProps & { _initialData?: any }) {
|
||||
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
||||
const initialData = propInitialData || _initialData;
|
||||
// 설정 병합
|
||||
const config: UniversalFormModalConfig = useMemo(() => {
|
||||
const componentConfig = component?.config || {};
|
||||
|
|
@ -194,10 +195,34 @@ export function UniversalFormModalComponent({
|
|||
itemId: string;
|
||||
}>({ open: false, sectionId: "", itemId: "" });
|
||||
|
||||
// 초기화
|
||||
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
|
||||
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// 초기화 - 최초 마운트 시에만 실행
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 스킵
|
||||
if (hasInitialized.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
|
||||
if (initialData && Object.keys(initialData).length > 0) {
|
||||
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
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 데이터 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -216,7 +241,6 @@ export function UniversalFormModalComponent({
|
|||
// 각 테이블 데이터 로드
|
||||
for (const tableName of tablesToLoad) {
|
||||
if (!linkedFieldDataCache[tableName]) {
|
||||
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
|
||||
await loadLinkedFieldData(tableName);
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +252,9 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 폼 초기화
|
||||
const initializeForm = useCallback(async () => {
|
||||
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
||||
const effectiveInitialData = capturedInitialData.current || initialData;
|
||||
|
||||
const newFormData: FormDataState = {};
|
||||
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||
const newCollapsed = new Set<string>();
|
||||
|
|
@ -253,11 +280,14 @@ export function UniversalFormModalComponent({
|
|||
// 기본값 설정
|
||||
let value = field.defaultValue ?? "";
|
||||
|
||||
// 부모에서 전달받은 값 적용
|
||||
if (field.receiveFromParent && initialData) {
|
||||
// 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면)
|
||||
if (effectiveInitialData) {
|
||||
const parentField = field.parentFieldName || field.columnName;
|
||||
if (initialData[parentField] !== undefined) {
|
||||
value = initialData[parentField];
|
||||
if (effectiveInitialData[parentField] !== undefined) {
|
||||
// receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용
|
||||
if (field.receiveFromParent || value === "" || value === undefined) {
|
||||
value = effectiveInitialData[parentField];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,11 +299,12 @@ export function UniversalFormModalComponent({
|
|||
setFormData(newFormData);
|
||||
setRepeatSections(newRepeatSections);
|
||||
setCollapsedSections(newCollapsed);
|
||||
setOriginalData(initialData || {});
|
||||
setOriginalData(effectiveInitialData || {});
|
||||
|
||||
// 채번규칙 자동 생성
|
||||
await generateNumberingValues(newFormData);
|
||||
}, [config, initialData]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||
|
||||
// 반복 섹션 아이템 생성
|
||||
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
|
||||
|
|
@ -423,15 +454,30 @@ export function UniversalFormModalComponent({
|
|||
if (optionConfig.type === "static") {
|
||||
options = optionConfig.staticOptions || [];
|
||||
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
||||
const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, {
|
||||
params: { limit: 1000 },
|
||||
// POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
||||
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"]),
|
||||
label: String(row[optionConfig.labelColumn || "name"]),
|
||||
}));
|
||||
|
||||
// 응답 데이터 파싱
|
||||
let dataArray: any[] = [];
|
||||
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) {
|
||||
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
|
|
@ -471,9 +517,9 @@ export function UniversalFormModalComponent({
|
|||
size: 1000,
|
||||
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
|
||||
});
|
||||
|
||||
|
||||
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
|
||||
|
||||
|
||||
if (response.data?.success) {
|
||||
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
|
||||
const responseData = response.data?.data;
|
||||
|
|
@ -534,18 +580,23 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
});
|
||||
|
||||
// 저장 시점 채번규칙 처리
|
||||
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
|
||||
for (const section of config.sections) {
|
||||
for (const field of section.fields) {
|
||||
if (
|
||||
field.numberingRule?.enabled &&
|
||||
field.numberingRule?.generateOnSave &&
|
||||
field.numberingRule?.ruleId &&
|
||||
!dataToSave[field.columnName]
|
||||
) {
|
||||
const response = await generateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
dataToSave[field.columnName] = response.data.generatedCode;
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// generateOnSave: 저장 시 새로 생성
|
||||
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
|
||||
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
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) {
|
||||
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
|
||||
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
|
||||
console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields);
|
||||
}
|
||||
|
||||
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
||||
|
|
@ -578,22 +628,9 @@ export function UniversalFormModalComponent({
|
|||
const repeatableSection = config.sections.find((s) => s.repeatable);
|
||||
if (repeatableSection) {
|
||||
repeatSectionId = repeatableSection.id;
|
||||
console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// 디버깅: 설정 확인
|
||||
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
|
||||
commonFields,
|
||||
repeatSectionId,
|
||||
mainSectionFields,
|
||||
typeColumn,
|
||||
mainTypeValue,
|
||||
subTypeValue,
|
||||
repeatSections,
|
||||
formData,
|
||||
});
|
||||
|
||||
// 반복 섹션 데이터
|
||||
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 };
|
||||
if (typeColumn) {
|
||||
|
|
@ -651,16 +684,20 @@ export function UniversalFormModalComponent({
|
|||
if (section.repeatable) continue;
|
||||
|
||||
for (const field of section.fields) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
||||
const response = await generateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
|
||||
if (commonFields.includes(field.columnName)) {
|
||||
rowsToSave.forEach((row) => {
|
||||
row[field.columnName] = response.data?.generatedCode;
|
||||
});
|
||||
} else {
|
||||
rowsToSave[0][field.columnName] = response.data?.generatedCode;
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
||||
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
||||
if (shouldAllocate) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
|
||||
if (commonFields.includes(field.columnName)) {
|
||||
rowsToSave.forEach((row) => {
|
||||
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++) {
|
||||
const row = rowsToSave[i];
|
||||
console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
|
||||
|
||||
// 빈 객체 체크
|
||||
if (Object.keys(row).length === 0) {
|
||||
console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -687,87 +719,168 @@ export function UniversalFormModalComponent({
|
|||
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
||||
|
||||
// 커스텀 API 저장 (사원+부서 통합 저장 등)
|
||||
const saveWithCustomApi = useCallback(async () => {
|
||||
// 다중 테이블 저장 (범용)
|
||||
const saveWithMultiTable = useCallback(async () => {
|
||||
const { customApiSave } = config.saveConfig;
|
||||
if (!customApiSave) return;
|
||||
if (!customApiSave?.multiTable) return;
|
||||
|
||||
console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType);
|
||||
const { multiTable } = customApiSave;
|
||||
|
||||
const saveUserWithDeptApi = async () => {
|
||||
const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave;
|
||||
|
||||
// 1. userInfo 데이터 구성
|
||||
const userInfo: Record<string, any> = {};
|
||||
|
||||
// 모든 필드에서 user_info에 해당하는 데이터 추출
|
||||
config.sections.forEach((section) => {
|
||||
if (section.repeatable) return; // 반복 섹션은 제외
|
||||
|
||||
section.fields.forEach((field) => {
|
||||
const value = formData[field.columnName];
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
userInfo[field.columnName] = value;
|
||||
}
|
||||
});
|
||||
// 1. 메인 테이블 데이터 구성
|
||||
const mainData: Record<string, any> = {};
|
||||
config.sections.forEach((section) => {
|
||||
if (section.repeatable) return; // 반복 섹션은 제외
|
||||
section.fields.forEach((field) => {
|
||||
const value = formData[field.columnName];
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
mainData[field.columnName] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2. mainDept 데이터 구성
|
||||
let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined;
|
||||
|
||||
if (mainDeptFields) {
|
||||
const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"];
|
||||
if (deptCode) {
|
||||
mainDept = {
|
||||
dept_code: deptCode,
|
||||
dept_name: formData[mainDeptFields.deptNameField || "dept_name"],
|
||||
position_name: formData[mainDeptFields.positionNameField || "position_name"],
|
||||
};
|
||||
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
||||
for (const section of config.sections) {
|
||||
if (section.repeatable) continue;
|
||||
|
||||
for (const field of section.fields) {
|
||||
// 채번규칙이 활성화된 필드 처리
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// 신규 생성이거나 값이 없는 경우에만 채번
|
||||
const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
|
||||
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 데이터 구성 (반복 섹션에서)
|
||||
const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = [];
|
||||
|
||||
if (subDeptSectionId && repeatSections[subDeptSectionId]) {
|
||||
const subDeptItems = repeatSections[subDeptSectionId];
|
||||
const deptCodeField = subDeptFields?.deptCodeField || "dept_code";
|
||||
const deptNameField = subDeptFields?.deptNameField || "dept_name";
|
||||
const positionNameField = subDeptFields?.positionNameField || "position_name";
|
||||
// saveMainAsFirst가 활성화된 경우, 메인 데이터를 서브 테이블에 저장하기 위한 매핑 생성
|
||||
let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined;
|
||||
if (subTableConfig.options?.saveMainAsFirst) {
|
||||
mainFieldMappings = [];
|
||||
|
||||
subDeptItems.forEach((item) => {
|
||||
const deptCode = item[deptCodeField];
|
||||
if (deptCode) {
|
||||
subDepts.push({
|
||||
dept_code: deptCode,
|
||||
dept_name: item[deptNameField],
|
||||
position_name: item[positionNameField],
|
||||
});
|
||||
// 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
|
||||
// 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
|
||||
for (const mapping of subTableConfig.fieldMappings || []) {
|
||||
if (mapping.targetColumn) {
|
||||
// 메인 데이터에서 동일한 컬럼명이 있으면 매핑
|
||||
if (mainData[mapping.targetColumn] !== undefined) {
|
||||
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 호출
|
||||
console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts });
|
||||
|
||||
const { saveUserWithDept } = await import("@/lib/api/user");
|
||||
const response = await saveUserWithDept({
|
||||
userInfo: userInfo as any,
|
||||
mainDept,
|
||||
subDepts,
|
||||
isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드
|
||||
subTablesData.push({
|
||||
tableName: subTableConfig.tableName,
|
||||
linkColumn: subTableConfig.linkColumn,
|
||||
items: subItems,
|
||||
options: {
|
||||
...subTableConfig.options,
|
||||
mainFieldMappings, // 메인 데이터 매핑 추가
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || "사원 저장 실패");
|
||||
}
|
||||
// 3. 범용 다중 테이블 저장 API 호출
|
||||
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 () => {
|
||||
if (!customApiSave.customEndpoint) {
|
||||
|
|
@ -789,9 +902,10 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
const method = customApiSave.customMethod || "POST";
|
||||
const response = method === "PUT"
|
||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||
const response =
|
||||
method === "PUT"
|
||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
|
|
@ -799,8 +913,8 @@ export function UniversalFormModalComponent({
|
|||
};
|
||||
|
||||
switch (customApiSave.apiType) {
|
||||
case "user-with-dept":
|
||||
await saveUserWithDeptApi();
|
||||
case "multi-table":
|
||||
await saveWithMultiTable();
|
||||
break;
|
||||
case "custom":
|
||||
await saveWithGenericCustomApi();
|
||||
|
|
@ -808,7 +922,7 @@ export function UniversalFormModalComponent({
|
|||
default:
|
||||
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
||||
}
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
|
||||
}, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(async () => {
|
||||
|
|
@ -869,7 +983,16 @@ export function UniversalFormModalComponent({
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]);
|
||||
}, [
|
||||
config,
|
||||
formData,
|
||||
repeatSections,
|
||||
onSave,
|
||||
validateRequiredFields,
|
||||
saveSingleRow,
|
||||
saveMultipleRows,
|
||||
saveWithCustomApi,
|
||||
]);
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
|
|
@ -878,12 +1001,14 @@ export function UniversalFormModalComponent({
|
|||
}, [initializeForm]);
|
||||
|
||||
// 필드 요소 렌더링 (입력 컴포넌트만)
|
||||
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
||||
const renderFieldElement = (
|
||||
field: FormFieldConfig,
|
||||
value: any,
|
||||
onChangeHandler: (value: any) => void,
|
||||
fieldKey: string,
|
||||
isDisabled: boolean,
|
||||
repeatContext?: { sectionId: string; itemId: string },
|
||||
) => {
|
||||
return (() => {
|
||||
switch (field.fieldType) {
|
||||
|
|
@ -920,7 +1045,7 @@ export function UniversalFormModalComponent({
|
|||
if (field.cascading?.enabled) {
|
||||
const cascadingConfig = field.cascading;
|
||||
const parentValue = formData[cascadingConfig.parentField];
|
||||
|
||||
|
||||
return (
|
||||
<CascadingSelectField
|
||||
fieldId={fieldKey}
|
||||
|
|
@ -933,10 +1058,15 @@ export function UniversalFormModalComponent({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 다중 컬럼 저장이 활성화된 경우
|
||||
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 sourceTableName = lfg.sourceTable as string;
|
||||
const cachedData = linkedFieldDataCache[sourceTableName];
|
||||
|
|
@ -980,11 +1110,24 @@ export function UniversalFormModalComponent({
|
|||
lfg.mappings.forEach((mapping) => {
|
||||
if (mapping.sourceColumn && mapping.targetColumn) {
|
||||
const mappedValue = selectedRow[mapping.sourceColumn];
|
||||
// formData에 직접 저장
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[mapping.targetColumn]: mappedValue,
|
||||
}));
|
||||
|
||||
// 반복 섹션인 경우 repeatSections에 저장, 아니면 formData에 저장
|
||||
if (repeatContext) {
|
||||
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>
|
||||
{sourceData.length > 0 ? (
|
||||
sourceData.map((row, index) => (
|
||||
<SelectItem
|
||||
key={`${row[valueColumn] || index}_${index}`}
|
||||
value={String(row[valueColumn] || "")}
|
||||
>
|
||||
<SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
|
||||
{getDisplayText(row)}
|
||||
</SelectItem>
|
||||
))
|
||||
|
|
@ -1127,12 +1267,14 @@ export function UniversalFormModalComponent({
|
|||
};
|
||||
|
||||
// 필드 렌더링 (섹션 열 수 적용)
|
||||
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
||||
const renderFieldWithColumns = (
|
||||
field: FormFieldConfig,
|
||||
value: any,
|
||||
onChangeHandler: (value: any) => void,
|
||||
fieldKey: string,
|
||||
sectionColumns: number = 2,
|
||||
repeatContext?: { sectionId: string; itemId: string },
|
||||
) => {
|
||||
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
|
||||
const defaultSpan = getDefaultGridSpan(sectionColumns);
|
||||
|
|
@ -1146,7 +1288,7 @@ export function UniversalFormModalComponent({
|
|||
return null;
|
||||
}
|
||||
|
||||
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled);
|
||||
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled, repeatContext);
|
||||
|
||||
if (field.fieldType === "checkbox") {
|
||||
return (
|
||||
|
|
@ -1286,6 +1428,7 @@ export function UniversalFormModalComponent({
|
|||
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
|
||||
`${section.id}-${item._id}-${field.id}`,
|
||||
sectionColumns,
|
||||
{ sectionId: section.id, itemId: item._id }, // 반복 섹션 컨텍스트 전달
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -123,6 +123,7 @@ export interface FormFieldConfig {
|
|||
// 연동 필드 매핑 설정
|
||||
export interface LinkedFieldMapping {
|
||||
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
|
||||
targetTable?: string; // 저장할 테이블 (선택, 없으면 자동 결정)
|
||||
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 타입
|
||||
* - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept)
|
||||
*
|
||||
* ## 데이터 매핑 설정
|
||||
* - `userInfoFields`: user_info 테이블에 저장할 필드 매핑
|
||||
* - `mainDeptFields`: 메인 부서 정보 필드 매핑
|
||||
* - `subDeptSectionId`: 겸직 부서 반복 섹션 ID
|
||||
*/
|
||||
export interface CustomApiSaveConfig {
|
||||
enabled: boolean;
|
||||
apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입
|
||||
apiType: "multi-table" | "custom"; // API 타입
|
||||
|
||||
// user-with-dept 전용 설정
|
||||
userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName)
|
||||
mainDeptFields?: {
|
||||
deptCodeField?: string; // 메인 부서코드 필드명
|
||||
deptNameField?: string; // 메인 부서명 필드명
|
||||
positionNameField?: string; // 메인 직급 필드명
|
||||
};
|
||||
subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID
|
||||
subDeptFields?: {
|
||||
deptCodeField?: string; // 겸직 부서코드 필드명
|
||||
deptNameField?: string; // 겸직 부서명 필드명
|
||||
positionNameField?: string; // 겸직 직급 필드명
|
||||
};
|
||||
// 다중 테이블 저장 설정 (범용)
|
||||
multiTable?: MultiTableSaveConfig;
|
||||
|
||||
// 커스텀 API 전용 설정
|
||||
customEndpoint?: string; // 커스텀 API 엔드포인트
|
||||
customMethod?: "POST" | "PUT"; // HTTP 메서드
|
||||
customDataTransform?: string; // 데이터 변환 함수명 (추후 확장)
|
||||
}
|
||||
|
||||
// 모달 설정
|
||||
|
|
|
|||
|
|
@ -3836,6 +3836,7 @@ export class ButtonActionExecutor {
|
|||
const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context);
|
||||
|
||||
if (keyValue) {
|
||||
// 상태 업데이트
|
||||
await apiClient.put("/dynamic-form/update-field", {
|
||||
tableName: statusTableName,
|
||||
keyField: keyField,
|
||||
|
|
@ -3844,6 +3845,37 @@ export class ButtonActionExecutor {
|
|||
updateValue: 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) {
|
||||
console.warn("⚠️ 상태 변경 실패:", statusError);
|
||||
|
|
@ -4050,6 +4082,23 @@ export class ButtonActionExecutor {
|
|||
updateValue: 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) {
|
||||
console.warn("⚠️ 상태 변경 실패:", statusError);
|
||||
|
|
|
|||
Loading…
Reference in New Issue