Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-12-11 10:43:09 +09:00
commit 5bbbd37553
42 changed files with 5977 additions and 1202 deletions

View File

@ -427,7 +427,8 @@ export const updateFieldValue = async (
): Promise<Response | void> => { ): Promise<Response | void> => {
try { try {
const { companyCode, userId } = req.user as any; const { companyCode, userId } = req.user as any;
const { tableName, keyField, keyValue, updateField, updateValue } = req.body; const { tableName, keyField, keyValue, updateField, updateValue } =
req.body;
console.log("🔄 [updateFieldValue] 요청:", { console.log("🔄 [updateFieldValue] 요청:", {
tableName, tableName,
@ -440,16 +441,27 @@ export const updateFieldValue = async (
}); });
// 필수 필드 검증 // 필수 필드 검증
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { if (
!tableName ||
!keyField ||
keyValue === undefined ||
!updateField ||
updateValue === undefined
) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", message:
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
}); });
} }
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증 // SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { if (
!validNamePattern.test(tableName) ||
!validNamePattern.test(keyField) ||
!validNamePattern.test(updateField)
) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "유효하지 않은 테이블명 또는 컬럼명입니다.", message: "유효하지 않은 테이블명 또는 컬럼명입니다.",

View File

@ -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",
});
}
};
} }

View File

@ -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();
}
}

View File

@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
router.post("/move", flowController.moveData); router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData); router.post("/move-batch", flowController.moveBatchData);
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
// ==================== 오딧 로그 ==================== // ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
router.get("/audit/:flowId", flowController.getFlowAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs);

View File

@ -24,6 +24,7 @@ import {
getLogData, getLogData,
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/ */
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
// ========================================
// 범용 다중 테이블 저장 API
// ========================================
/**
* ( + )
* POST /api/table-management/multi-table-save
*
* () .
* +, + 1:N .
*/
router.post("/multi-table-save", multiTableSave);
export default router; export default router;

View File

@ -65,12 +65,18 @@ export class BatchSchedulerService {
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` `배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
); );
const task = cron.schedule(config.cron_schedule, async () => { const task = cron.schedule(
logger.info( config.cron_schedule,
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` async () => {
); logger.info(
await this.executeBatchConfig(config); `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
}); );
await this.executeBatchConfig(config);
},
{
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
}
);
this.scheduledTasks.set(config.id, task); this.scheduledTasks.set(config.id, task);
} catch (error) { } catch (error) {

View File

@ -72,6 +72,11 @@ export class FlowDataMoveService {
// 내부 DB 처리 (기존 로직) // 내부 DB 처리 (기존 로직)
return await db.transaction(async (client) => { return await db.transaction(async (client) => {
try { try {
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId || "system",
]);
// 1. 단계 정보 조회 // 1. 단계 정보 조회
const fromStep = await this.flowStepService.findById(fromStepId); const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId); const toStep = await this.flowStepService.findById(toStepId);
@ -684,6 +689,14 @@ export class FlowDataMoveService {
dbConnectionId, dbConnectionId,
async (externalClient, dbType) => { async (externalClient, dbType) => {
try { try {
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
if (dbType.toLowerCase() === "postgresql") {
await externalClient.query(
"SELECT set_config('app.user_id', $1, true)",
[userId || "system"]
);
}
// 1. 단계 정보 조회 (내부 DB에서) // 1. 단계 정보 조회 (내부 DB에서)
const fromStep = await this.flowStepService.findById(fromStepId); const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId); const toStep = await this.flowStepService.findById(toStepId);

View File

@ -263,4 +263,139 @@ export class FlowExecutionService {
tableName: result[0].table_name, tableName: result[0].table_name,
}; };
} }
/**
* ( )
* .
*/
async updateStepData(
flowId: number,
stepId: number,
recordId: string,
updateData: Record<string, any>,
userId: string,
companyCode?: string
): Promise<{ success: boolean }> {
try {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 스텝 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
// 3. 테이블명 결정
const tableName = step.tableName || flowDef.tableName;
if (!tableName) {
throw new Error("Table name not found");
}
// 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id";
console.log(
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
);
// 5. SET 절 생성
const updateColumns = Object.keys(updateData);
if (updateColumns.length === 0) {
throw new Error("No columns to update");
}
// 6. 외부 DB vs 내부 DB 구분
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 업데이트
console.log(
"✅ [updateStepData] Using EXTERNAL DB:",
flowDef.dbConnectionId
);
// 외부 DB 연결 정보 조회
const connectionResult = await db.query(
"SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId]
);
if (connectionResult.length === 0) {
throw new Error(
`External DB connection not found: ${flowDef.dbConnectionId}`
);
}
const connection = connectionResult[0];
const dbType = connection.db_type?.toLowerCase();
// DB 타입에 따른 placeholder 및 쿼리 생성
let setClause: string;
let params: any[];
if (dbType === "mysql" || dbType === "mariadb") {
// MySQL/MariaDB: ? placeholder
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
params = [...Object.values(updateData), recordId];
} else if (dbType === "mssql") {
// MSSQL: @p1, @p2 placeholder
setClause = updateColumns
.map((col, idx) => `[${col}] = @p${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId];
} else {
// PostgreSQL: $1, $2 placeholder
setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId];
}
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
} else {
// 내부 DB 업데이트
console.log("✅ [updateStepData] Using INTERNAL DB");
const setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const params = [...Object.values(updateData), recordId];
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
// (트리거에서 changed_by를 기록하기 위함)
await db.transaction(async (client) => {
// 안전한 파라미터 바인딩 방식 사용
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId,
]);
await client.query(updateQuery, params);
});
}
console.log(
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
{
updatedFields: updateColumns,
userId,
}
);
return { success: true };
} catch (error: any) {
console.error("❌ [updateStepData] Error:", error);
throw error;
}
}
} }

View File

@ -191,6 +191,12 @@ export class NodeFlowExecutionService {
try { try {
result = await transaction(async (client) => { result = await transaction(async (client) => {
// 🔥 사용자 ID 세션 변수 설정 (트리거용)
const userId = context.buttonContext?.userId || "system";
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId,
]);
// 트랜잭션 내에서 레벨별 실행 // 트랜잭션 내에서 레벨별 실행
for (const level of levels) { for (const level of levels) {
await this.executeLevel(level, nodes, edges, context, client); await this.executeLevel(level, nodes, edges, context, client);

View File

@ -897,13 +897,13 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (현재 순번으로 미리보기, 증가 안 함) // 순번 (현재 순번으로 미리보기, 증가 안 함)
const length = autoConfig.sequenceLength || 4; const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0"); return String(rule.currentSequence || 1).padStart(length, "0");
} }
case "number": { case "number": {
// 숫자 (고정 자릿수) // 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4; const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1; const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0"); return String(value).padStart(length, "0");
} }
@ -957,13 +957,13 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
// 순번 (자동 증가 숫자) // 순번 (자동 증가 숫자)
const length = autoConfig.sequenceLength || 4; const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0"); return String(rule.currentSequence || 1).padStart(length, "0");
} }
case "number": { case "number": {
// 숫자 (고정 자릿수) // 숫자 (고정 자릿수)
const length = autoConfig.numberLength || 4; const length = autoConfig.numberLength || 3;
const value = autoConfig.numberValue || 1; const value = autoConfig.numberValue || 1;
return String(value).padStart(length, "0"); return String(value).padStart(length, "0");
} }

View File

@ -1,6 +1,4 @@
import { import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer"; import { GlobalFileViewer } from "@/components/GlobalFileViewer";
@ -9,208 +7,206 @@ import { GlobalFileViewer } from "@/components/GlobalFileViewer";
*/ */
export default function AdminPage() { export default function AdminPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none px-4 pt-12 pb-16 space-y-16"> <div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
{/* 주요 관리 기능 */}
{/* 주요 관리 기능 */} <div className="mx-auto max-w-7xl space-y-10">
<div className="mx-auto max-w-7xl space-y-10"> <div className="mb-8 text-center">
<div className="text-center mb-8"> <h2 className="text-foreground mb-2 text-2xl font-bold"> </h2>
<h2 className="text-2xl font-bold text-foreground mb-2"> </h2> <p className="text-muted-foreground"> </p>
<p className="text-muted-foreground"> </p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link href="/admin/userMng" className="block">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Users className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div> </div>
</Link> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link href="/admin/userMng" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Users className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
<div className="rounded-lg border bg-card p-6 shadow-sm"> {/* <div className="bg-card rounded-lg border p-6 shadow-sm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10"> <div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Shield className="h-6 w-6 text-success" /> <Shield className="text-success h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div> </div>
<div>
<h3 className="font-semibold text-foreground"> </h3> <div className="bg-card rounded-lg border p-6 shadow-sm">
<p className="text-sm text-muted-foreground"> </p> <div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Settings className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div> </div>
<div className="bg-card rounded-lg border p-6 shadow-sm">
<div className="flex items-center gap-4">
<div className="bg-warning/10 flex h-12 w-12 items-center justify-center rounded-lg">
<BarChart3 className="text-warning h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div> */}
<Link href="/admin/screenMng" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Palette className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"></h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<div className="rounded-lg border bg-card p-6 shadow-sm"> {/* 표준 관리 섹션 */}
<div className="flex items-center gap-4"> <div className="mx-auto max-w-7xl space-y-10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-8 text-center">
<Settings className="h-6 w-6 text-primary" /> <h2 className="text-foreground mb-2 text-2xl font-bold"> </h2>
</div> <p className="text-muted-foreground"> </p>
<div> </div>
<h3 className="font-semibold text-foreground"> </h3> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<p className="text-sm text-muted-foreground"> </p> {/* <Link href="/admin/standards" className="block h-full">
</div> <div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Database className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/templates" className="block h-full">
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Layout className="text-success h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold">릿 </h3>
<p className="text-muted-foreground text-sm"> 릿 </p>
</div>
</div>
</div>
</Link> */}
<Link href="/admin/tableMng" className="block h-full">
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Database className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
{/* <Link href="/admin/components" className="block h-full">
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Package className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link> */}
</div> </div>
</div> </div>
<div className="rounded-lg border bg-card p-6 shadow-sm"> {/* 빠른 액세스 */}
<div className="flex items-center gap-4"> <div className="mx-auto max-w-7xl space-y-10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-warning/10"> <div className="mb-8 text-center">
<BarChart3 className="h-6 w-6 text-warning" /> <h2 className="text-foreground mb-2 text-2xl font-bold"> </h2>
</div> <p className="text-muted-foreground"> </p>
<div> </div>
<h3 className="font-semibold text-foreground"> </h3> <div className="grid gap-6 md:grid-cols-3">
<p className="text-sm text-muted-foreground"> </p> <Link href="/admin/menu" className="block">
</div> <div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Layout className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/external-connections" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Database className="text-success h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/commonCode" className="block">
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
<div className="flex items-center gap-4">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Settings className="text-primary h-6 w-6" />
</div>
<div>
<h3 className="text-foreground font-semibold"> </h3>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<Link href="/admin/screenMng" className="block"> {/* 전역 파일 관리 */}
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted"> <div className="mx-auto max-w-7xl space-y-6">
<div className="flex items-center gap-4"> <div className="mb-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <h2 className="text-foreground mb-2 text-2xl font-bold"> </h2>
<Palette className="h-6 w-6 text-primary" /> <p className="text-muted-foreground"> </p>
</div>
<div>
<h3 className="font-semibold text-foreground"></h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div> </div>
</Link> <GlobalFileViewer />
</div> </div>
</div> </div>
{/* 표준 관리 섹션 */}
<div className="mx-auto max-w-7xl space-y-10">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2"> </h2>
<p className="text-muted-foreground"> </p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Link href="/admin/standards" className="block h-full">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Database className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/templates" className="block h-full">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
<Layout className="h-6 w-6 text-success" />
</div>
<div>
<h3 className="font-semibold text-foreground">릿 </h3>
<p className="text-sm text-muted-foreground"> 릿 </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/tableMng" className="block h-full">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Database className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/components" className="block h-full">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div>
</Link>
</div>
</div>
{/* 빠른 액세스 */}
<div className="mx-auto max-w-7xl space-y-10">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2"> </h2>
<p className="text-muted-foreground"> </p>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Link href="/admin/menu" className="block">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Layout className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/external-connections" className="block">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
<Database className="h-6 w-6 text-success" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div>
</Link>
<Link href="/admin/commonCode" className="block">
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Settings className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-foreground"> </h3>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</div>
</Link>
</div>
</div>
{/* 전역 파일 관리 */}
<div className="mx-auto max-w-7xl space-y-6">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-foreground mb-2"> </h2>
<p className="text-muted-foreground"> </p>
</div>
<GlobalFileViewer />
</div>
</div>
</div> </div>
); );
} }

View File

@ -276,12 +276,12 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden"> <DialogContent className="flex max-h-[90vh] max-w-3xl flex-col">
<DialogHeader> <DialogHeader className="flex-shrink-0">
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle> <DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-4"> <div className="flex-1 space-y-6 overflow-y-auto py-4 pr-2">
{/* 기본 정보 */} {/* 기본 정보 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className="flex-shrink-0 border-t pt-4">
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />

View File

@ -379,6 +379,47 @@ export interface ListWidgetConfig {
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용) stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
compactMode: boolean; // 압축 모드 (기본: false) compactMode: boolean; // 압축 모드 (기본: false)
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3) cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
// 행 클릭 팝업 설정
rowDetailPopup?: RowDetailPopupConfig;
}
// 행 상세 팝업 설정
export interface RowDetailPopupConfig {
enabled: boolean; // 팝업 활성화 여부
title?: string; // 팝업 제목 (기본: "상세 정보")
// 추가 데이터 조회 설정
additionalQuery?: {
enabled: boolean;
tableName: string; // 조회할 테이블명 (예: vehicles)
matchColumn: string; // 매칭할 컬럼 (예: id)
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
displayColumns?: DisplayColumnConfig[];
};
}
// 표시 컬럼 설정
export interface DisplayColumnConfig {
column: string; // DB 컬럼명
label: string; // 표시 라벨 (사용자 정의)
// 필드 그룹 설정
fieldGroups?: FieldGroup[];
}
// 필드 그룹 (팝업 내 섹션)
export interface FieldGroup {
id: string;
title: string; // 그룹 제목 (예: "운행 정보")
icon?: string; // 아이콘 (예: "truck", "clock")
color?: "blue" | "orange" | "green" | "red" | "purple" | "gray";
fields: FieldConfig[];
}
// 필드 설정
export interface FieldConfig {
column: string; // DB 컬럼명
label: string; // 표시 라벨
format?: "text" | "number" | "date" | "datetime" | "currency" | "boolean" | "distance" | "duration";
} }
// 리스트 컬럼 // 리스트 컬럼

View File

@ -1,10 +1,17 @@
"use client"; "use client";
import React from "react"; import React, { useState } from "react";
import { ListWidgetConfig, QueryResult } from "../types"; import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor"; import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions"; import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
import { Plus, Trash2, ChevronDown, ChevronUp, X, Check } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface ListWidgetSectionProps { interface ListWidgetSectionProps {
queryResult: QueryResult | null; queryResult: QueryResult | null;
@ -16,8 +23,91 @@ interface ListWidgetSectionProps {
* *
* - * -
* - * -
* -
*/ */
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) { export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
// 팝업 설정 초기화
const popupConfig = config.rowDetailPopup || {
enabled: false,
title: "상세 정보",
additionalQuery: { enabled: false, tableName: "", matchColumn: "" },
fieldGroups: [],
};
// 팝업 설정 업데이트 헬퍼
const updatePopupConfig = (updates: Partial<typeof popupConfig>) => {
onConfigChange({
rowDetailPopup: { ...popupConfig, ...updates },
});
};
// 필드 그룹 추가
const addFieldGroup = () => {
const newGroup: FieldGroup = {
id: `group-${Date.now()}`,
title: "새 그룹",
icon: "info",
color: "gray",
fields: [],
};
updatePopupConfig({
fieldGroups: [...(popupConfig.fieldGroups || []), newGroup],
});
};
// 필드 그룹 삭제
const removeFieldGroup = (groupId: string) => {
updatePopupConfig({
fieldGroups: (popupConfig.fieldGroups || []).filter((g) => g.id !== groupId),
});
};
// 필드 그룹 업데이트
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
updatePopupConfig({
fieldGroups: (popupConfig.fieldGroups || []).map((g) => (g.id === groupId ? { ...g, ...updates } : g)),
});
};
// 필드 추가
const addField = (groupId: string) => {
const newField: FieldConfig = {
column: "",
label: "",
format: "text",
};
updatePopupConfig({
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
g.id === groupId ? { ...g, fields: [...g.fields, newField] } : g,
),
});
};
// 필드 삭제
const removeField = (groupId: string, fieldIndex: number) => {
updatePopupConfig({
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
g.id === groupId ? { ...g, fields: g.fields.filter((_, i) => i !== fieldIndex) } : g,
),
});
};
// 필드 업데이트
const updateField = (groupId: string, fieldIndex: number, updates: Partial<FieldConfig>) => {
updatePopupConfig({
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
g.id === groupId ? { ...g, fields: g.fields.map((f, i) => (i === fieldIndex ? { ...f, ...updates } : f)) } : g,
),
});
};
// 그룹 확장/축소 토글
const toggleGroupExpand = (groupId: string) => {
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} {/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
<ListTableOptions config={config} onConfigChange={onConfigChange} /> <ListTableOptions config={config} onConfigChange={onConfigChange} />
</div> </div>
)} )}
{/* 행 클릭 팝업 설정 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Switch
checked={popupConfig.enabled}
onCheckedChange={(enabled) => updatePopupConfig({ enabled })}
aria-label="행 클릭 팝업 활성화"
/>
</div>
{popupConfig.enabled && (
<div className="space-y-3">
{/* 팝업 제목 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={popupConfig.title || ""}
onChange={(e) => updatePopupConfig({ title: e.target.value })}
placeholder="상세 정보"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 추가 데이터 조회 설정 */}
<div className="space-y-2 rounded border p-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={popupConfig.additionalQuery?.enabled || false}
onCheckedChange={(enabled) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
})
}
aria-label="추가 데이터 조회 활성화"
/>
</div>
{popupConfig.additionalQuery?.enabled && (
<div className="space-y-2">
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
<div>
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
<span className="truncate">
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
: "전체 표시 (클릭하여 선택)"}
</span>
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{queryResult?.columns.map((col) => {
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
{/* 선택된 컬럼 라벨 편집 */}
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<div className="space-y-1.5">
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return (
<div key={column} className="flex items-center gap-2">
<span className="text-muted-foreground w-24 truncate text-xs" title={column}>
{column}
</span>
<Input
value={label}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column, label: e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="표시 라벨"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
(c) => (typeof c === 'object' ? c.column : c) !== column
);
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* 필드 그룹 설정 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> ()</Label>
<Button variant="outline" size="sm" onClick={addFieldGroup} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-xs"> .</p>
{/* 필드 그룹 목록 */}
{(popupConfig.fieldGroups || []).map((group) => (
<div key={group.id} className="rounded border p-2">
{/* 그룹 헤더 */}
<div className="flex items-center justify-between">
<button
onClick={() => toggleGroupExpand(group.id)}
className="flex flex-1 items-center gap-2 text-left"
>
{expandedGroups[group.id] ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<span className="text-xs font-medium">{group.title || "새 그룹"}</span>
<span className="text-muted-foreground text-xs">({group.fields.length} )</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => removeFieldGroup(group.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 그룹 상세 (확장 시) */}
{expandedGroups[group.id] && (
<div className="mt-2 space-y-2 border-t pt-2">
{/* 그룹 제목 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
value={group.title}
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={group.color || "gray"}
onValueChange={(value) =>
updateFieldGroup(group.id, {
color: value as "blue" | "orange" | "green" | "red" | "purple" | "gray",
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gray"></SelectItem>
<SelectItem value="blue"></SelectItem>
<SelectItem value="orange"></SelectItem>
<SelectItem value="green"></SelectItem>
<SelectItem value="red"></SelectItem>
<SelectItem value="purple"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 아이콘 */}
<div>
<Label className="text-xs"></Label>
<Select
value={group.icon || "info"}
onValueChange={(value) => updateFieldGroup(group.id, { icon: value })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info"></SelectItem>
<SelectItem value="truck"></SelectItem>
<SelectItem value="clock"></SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="package"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 필드 목록 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Button
variant="outline"
size="sm"
onClick={() => addField(group.id)}
className="h-6 gap-1 text-xs"
>
<Plus className="h-3 w-3" />
</Button>
</div>
{group.fields.map((field, fieldIndex) => (
<div key={fieldIndex} className="flex items-center gap-1 rounded bg-muted/50 p-1">
<Input
value={field.column}
onChange={(e) => updateField(group.id, fieldIndex, { column: e.target.value })}
placeholder="컬럼명"
className="h-6 flex-1 text-xs"
/>
<Input
value={field.label}
onChange={(e) => updateField(group.id, fieldIndex, { label: e.target.value })}
placeholder="라벨"
className="h-6 flex-1 text-xs"
/>
<Select
value={field.format || "text"}
onValueChange={(value) =>
updateField(group.id, fieldIndex, {
format: value as FieldConfig["format"],
})
}
>
<SelectTrigger className="h-6 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="datetime"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="distance"></SelectItem>
<SelectItem value="duration"></SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={() => removeField(group.id, fieldIndex)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -1,11 +1,20 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types"; import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getApiUrl } from "@/lib/utils/apiUrl"; import { getApiUrl } from "@/lib/utils/apiUrl";
import { Truck, Clock, MapPin, Package, Info } from "lucide-react";
interface ListWidgetProps { interface ListWidgetProps {
element: DashboardElement; element: DashboardElement;
@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
// 행 상세 팝업 상태
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | null>(null);
const config = element.listConfig || { const config = element.listConfig || {
columnMode: "auto", columnMode: "auto",
viewMode: "table", viewMode: "table",
@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
cardColumns: 3, cardColumns: 3,
}; };
// 행 클릭 핸들러 - 팝업 열기
const handleRowClick = useCallback(
async (row: Record<string, any>) => {
// 팝업이 비활성화되어 있으면 무시
if (!config.rowDetailPopup?.enabled) return;
setDetailPopupData(row);
setDetailPopupOpen(true);
setAdditionalDetailData(null);
setDetailPopupLoading(false);
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (error) {
console.error("추가 데이터 로드 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
},
[config.rowDetailPopup],
);
// 값 포맷팅 함수
const formatValue = (value: any, format?: string): string => {
if (value === null || value === undefined) return "-";
switch (format) {
case "date":
return new Date(value).toLocaleDateString("ko-KR");
case "datetime":
return new Date(value).toLocaleString("ko-KR");
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":
return `${Number(value).toLocaleString("ko-KR")}`;
case "boolean":
return value ? "예" : "아니오";
case "distance":
return typeof value === "number" ? `${value.toFixed(1)} km` : String(value);
case "duration":
return typeof value === "number" ? `${value}` : String(value);
default:
return String(value);
}
};
// 아이콘 렌더링
const renderIcon = (icon?: string, color?: string) => {
const colorClass =
color === "blue"
? "text-blue-600"
: color === "orange"
? "text-orange-600"
: color === "green"
? "text-green-600"
: color === "red"
? "text-red-600"
: color === "purple"
? "text-purple-600"
: "text-gray-600";
switch (icon) {
case "truck":
return <Truck className={`h-4 w-4 ${colorClass}`} />;
case "clock":
return <Clock className={`h-4 w-4 ${colorClass}`} />;
case "map":
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
case "package":
return <Package className={`h-4 w-4 ${colorClass}`} />;
default:
return <Info className={`h-4 w-4 ${colorClass}`} />;
}
};
// 필드 그룹 렌더링
const renderFieldGroup = (group: FieldGroup, data: Record<string, any>) => {
const colorClass =
group.color === "blue"
? "text-blue-600"
: group.color === "orange"
? "text-orange-600"
: group.color === "green"
? "text-green-600"
: group.color === "red"
? "text-red-600"
: group.color === "purple"
? "text-purple-600"
: "text-gray-600";
return (
<div key={group.id} className="rounded-lg border p-4">
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
{renderIcon(group.icon, group.color)}
{group.title}
</div>
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
{group.fields.map((field) => (
<div key={field.column} className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
{field.label}
</span>
<span className="font-medium break-words">{formatValue(data[field.column], field.format)}</span>
</div>
))}
</div>
</div>
);
};
// 기본 필드 그룹 생성 (설정이 없을 경우)
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return { column, label };
})
.filter((item) => item.column in row);
} else {
// 전체 컬럼
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
}
groups.push({
id: "basic",
title: "기본 정보",
icon: "info",
color: "gray",
fields: basicFields.map((item) => ({
column: item.column,
label: item.label,
format: "text",
})),
});
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
if (additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({
id: "trip",
title: "운행 정보",
icon: "truck",
color: "blue",
fields: [
{ column: "last_trip_start", label: "시작", format: "datetime" },
{ column: "last_trip_end", label: "종료", format: "datetime" },
{ column: "last_trip_distance", label: "거리", format: "distance" },
{ column: "last_trip_time", label: "시간", format: "duration" },
{ column: "departure", label: "출발지", format: "text" },
{ column: "arrival", label: "도착지", format: "text" },
],
});
}
// 공차 정보
if (additional.last_empty_start) {
groups.push({
id: "empty",
title: "공차 정보",
icon: "package",
color: "orange",
fields: [
{ column: "last_empty_start", label: "시작", format: "datetime" },
{ column: "last_empty_end", label: "종료", format: "datetime" },
{ column: "last_empty_distance", label: "거리", format: "distance" },
{ column: "last_empty_time", label: "시간", format: "duration" },
],
});
}
}
return groups;
};
// 데이터 로드 // 데이터 로드
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
</TableRow> </TableRow>
) : ( ) : (
paginatedRows.map((row, idx) => ( paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows ? undefined : ""}> <TableRow
key={idx}
className={`${config.stripedRows ? "" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-muted/50" : ""}`}
onClick={() => handleRowClick(row)}
>
{displayColumns {displayColumns
.filter((col) => col.visible) .filter((col) => col.visible)
.map((col) => ( .map((col) => (
@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
}} }}
> >
{paginatedRows.map((row, idx) => ( {paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4 transition-shadow hover:shadow-md"> <Card
key={idx}
className={`p-4 transition-shadow hover:shadow-md ${config.rowDetailPopup?.enabled ? "cursor-pointer" : ""}`}
onClick={() => handleRowClick(row)}
>
<div className="space-y-2"> <div className="space-y-2">
{displayColumns {displayColumns
.filter((col) => col.visible) .filter((col) => col.visible)
@ -345,6 +577,49 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
</div> </div>
</div> </div>
)} )}
{/* 행 상세 팝업 */}
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
<DialogHeader>
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
<DialogDescription>
{detailPopupLoading
? "추가 정보를 로딩 중입니다..."
: detailPopupData
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
: "선택된 항목의 상세 정보입니다."}
</DialogDescription>
</DialogHeader>
{detailPopupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : (
<div className="space-y-4">
{detailPopupData && (
<>
{/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */}
{config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0
? // 설정된 필드 그룹 렌더링
config.rowDetailPopup.fieldGroups.map((group) =>
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
)
: // 기본 필드 그룹 렌더링
getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) =>
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
)}
</>
)}
</div>
)}
<DialogFooter>
<Button onClick={() => setDetailPopupOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -526,7 +526,8 @@ function MaterialBox({
case "location-temp": case "location-temp":
case "location-dest": case "location-dest":
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태 // 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 // 자재가 없으면 0, 있으면 해당 개수 표시 (기본값 5 제거)
const locPlateCount = placement.material_count ?? placement.quantity ?? 0;
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링 const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
const locPlateThickness = 0.15; // 각 철판 두께 const locPlateThickness = 0.15; // 각 철판 두께
const locPlateGap = 0.03; // 철판 사이 미세한 간격 const locPlateGap = 0.03; // 철판 사이 미세한 간격
@ -538,8 +539,32 @@ function MaterialBox({
return ( return (
<> <>
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */} {/* 자재가 없을 때: 흰색 실선 테두리 바닥판 */}
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => { {locPlateCount === 0 && (
<>
{/* 얇은 흰색 바닥판 */}
<Box
args={[boxWidth, 0.05, boxDepth]}
position={[0, locYOffset + 0.025, 0]}
>
<meshStandardMaterial
color="#f5f5f5"
roughness={0.6}
metalness={0.1}
emissive={isSelected ? "#e5e5e5" : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
/>
</Box>
{/* 흰색 실선 테두리 */}
<lineSegments position={[0, locYOffset + 0.06, 0]}>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, 0.05, boxDepth)]} />
<lineBasicMaterial color="#9ca3af" linewidth={2} />
</lineSegments>
</>
)}
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 */}
{locPlateCount > 0 && Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap); const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
// 약간의 랜덤 오프셋으로 자연스러움 추가 // 약간의 랜덤 오프셋으로 자연스러움 추가
const xOffset = (Math.sin(idx * 0.5) * 0.02); const xOffset = (Math.sin(idx * 0.5) * 0.02);
@ -570,7 +595,7 @@ function MaterialBox({
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */} {/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
{placement.name && ( {placement.name && (
<Text <Text
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]} position={[0, locYOffset + (locPlateCount > 0 ? locVisibleStackHeight : 0.1) + 0.3, boxDepth * 0.3]}
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.18} fontSize={Math.min(boxWidth, boxDepth) * 0.18}
color="#374151" color="#374151"

View File

@ -115,24 +115,33 @@ export function TableHistoryModal({
const getOperationBadge = (type: string) => { const getOperationBadge = (type: string) => {
switch (type) { switch (type) {
case "INSERT": case "INSERT":
return <Badge className="bg-green-100 text-xs text-green-800"></Badge>; return <span className="text-sm font-medium text-green-600"></span>;
case "UPDATE": case "UPDATE":
return <Badge className="bg-blue-100 text-xs text-blue-800"></Badge>; return <span className="text-sm font-medium text-blue-600"></span>;
case "DELETE": case "DELETE":
return <Badge className="bg-red-100 text-xs text-red-800"></Badge>; return <span className="text-sm font-medium text-red-600"></span>;
default: default:
return ( return <span className="text-sm font-medium text-gray-600">{type}</span>;
<Badge variant="secondary" className="text-xs">
{type}
</Badge>
);
} }
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
try { try {
// DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환
const date = new Date(dateString); const date = new Date(dateString);
// 🚨 타임존 보정 로직
// 실 서비스 DB는 UTC로 저장되는데, 프론트엔드에서 이를 KST로 인식하지 못하고
// UTC 시간 그대로(예: 02:55)를 한국 시간 02:55로 보여주는 문제가 있음 (9시간 느림).
// 반면 로컬 DB는 이미 KST로 저장되어 있어서 변환하면 안 됨.
// 따라서 로컬 환경이 아닐 때만 강제로 9시간을 더해줌.
const isLocal =
typeof window !== "undefined" &&
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
if (!isLocal) {
date.setHours(date.getHours() + 9);
}
return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
} catch { } catch {
return dateString; return dateString;

View File

@ -1,11 +1,19 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { DashboardElement, ChartDataSource, FieldGroup } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Loader2, RefreshCw } from "lucide-react"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react";
import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { applyColumnMapping } from "@/lib/utils/columnMapping";
import { getApiUrl } from "@/lib/utils/apiUrl"; import { getApiUrl } from "@/lib/utils/apiUrl";
@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null); const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
// 행 상세 팝업 상태
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | null>(null);
// // console.log("🧪 ListTestWidget 렌더링!", element); // // console.log("🧪 ListTestWidget 렌더링!", element);
const dataSources = useMemo(() => { const dataSources = useMemo(() => {
@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
cardColumns: 3, cardColumns: 3,
}; };
// 행 클릭 핸들러 - 팝업 열기
const handleRowClick = useCallback(
async (row: Record<string, any>) => {
// 팝업이 비활성화되어 있으면 무시
if (!config.rowDetailPopup?.enabled) return;
setDetailPopupData(row);
setDetailPopupOpen(true);
setAdditionalDetailData(null);
setDetailPopupLoading(false);
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (err) {
console.error("추가 데이터 로드 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
},
[config.rowDetailPopup],
);
// 값 포맷팅 함수
const formatValue = (value: any, format?: string): string => {
if (value === null || value === undefined) return "-";
switch (format) {
case "date":
return new Date(value).toLocaleDateString("ko-KR");
case "datetime":
return new Date(value).toLocaleString("ko-KR");
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":
return `${Number(value).toLocaleString("ko-KR")}`;
case "boolean":
return value ? "예" : "아니오";
case "distance":
return typeof value === "number" ? `${value.toFixed(1)} km` : String(value);
case "duration":
return typeof value === "number" ? `${value}` : String(value);
default:
return String(value);
}
};
// 아이콘 렌더링
const renderIcon = (icon?: string, color?: string) => {
const colorClass =
color === "blue"
? "text-blue-600"
: color === "orange"
? "text-orange-600"
: color === "green"
? "text-green-600"
: color === "red"
? "text-red-600"
: color === "purple"
? "text-purple-600"
: "text-gray-600";
switch (icon) {
case "truck":
return <Truck className={`h-4 w-4 ${colorClass}`} />;
case "clock":
return <Clock className={`h-4 w-4 ${colorClass}`} />;
case "map":
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
case "package":
return <Package className={`h-4 w-4 ${colorClass}`} />;
default:
return <Info className={`h-4 w-4 ${colorClass}`} />;
}
};
// 필드 그룹 렌더링
const renderFieldGroup = (group: FieldGroup, groupData: Record<string, any>) => {
const colorClass =
group.color === "blue"
? "text-blue-600"
: group.color === "orange"
? "text-orange-600"
: group.color === "green"
? "text-green-600"
: group.color === "red"
? "text-red-600"
: group.color === "purple"
? "text-purple-600"
: "text-gray-600";
return (
<div key={group.id} className="rounded-lg border p-4">
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
{renderIcon(group.icon, group.color)}
{group.title}
</div>
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
{group.fields.map((field) => (
<div key={field.column} className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
{field.label}
</span>
<span className="font-medium break-words">{formatValue(groupData[field.column], field.format)}</span>
</div>
))}
</div>
</div>
);
};
// 기본 필드 그룹 생성 (설정이 없을 경우)
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return { column, label };
})
.filter((item) => allKeys.includes(item.column));
} else {
// 전체 컬럼
basicFields = allKeys.map((key) => ({ column: key, label: key }));
}
groups.push({
id: "basic",
title: "기본 정보",
icon: "info",
color: "gray",
fields: basicFields.map((item) => ({
column: item.column,
label: item.label,
format: "text" as const,
})),
});
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
if (additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({
id: "trip",
title: "운행 정보",
icon: "truck",
color: "blue",
fields: [
{ column: "last_trip_start", label: "시작", format: "datetime" as const },
{ column: "last_trip_end", label: "종료", format: "datetime" as const },
{ column: "last_trip_distance", label: "거리", format: "distance" as const },
{ column: "last_trip_time", label: "시간", format: "duration" as const },
{ column: "departure", label: "출발지", format: "text" as const },
{ column: "arrival", label: "도착지", format: "text" as const },
],
});
}
// 공차 정보
if (additional.last_empty_start) {
groups.push({
id: "empty",
title: "공차 정보",
icon: "package",
color: "orange",
fields: [
{ column: "last_empty_start", label: "시작", format: "datetime" as const },
{ column: "last_empty_end", label: "종료", format: "datetime" as const },
{ column: "last_empty_distance", label: "거리", format: "distance" as const },
{ column: "last_empty_time", label: "시간", format: "duration" as const },
],
});
}
}
return groups;
};
// visible 컬럼 설정 객체 배열 (field + label) // visible 컬럼 설정 객체 배열 (field + label)
const visibleColumnConfigs = useMemo(() => { const visibleColumnConfigs = useMemo(() => {
if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") { if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") {
@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
)} )}
<TableBody> <TableBody>
{paginatedRows.map((row, idx) => ( {paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}> <TableRow
key={idx}
className={`${config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-accent" : ""}`}
onClick={() => handleRowClick(row)}
>
{displayColumns.map((field) => ( {displayColumns.map((field) => (
<TableCell key={field} className="whitespace-nowrap"> <TableCell key={field} className="whitespace-nowrap">
{String(row[field] ?? "")} {String(row[field] ?? "")}
@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
return ( return (
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}> <div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
{paginatedRows.map((row, idx) => ( {paginatedRows.map((row, idx) => (
<Card key={idx} className="p-4"> <Card
key={idx}
className={`p-4 ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-shadow hover:shadow-md" : ""}`}
onClick={() => handleRowClick(row)}
>
{displayColumns.map((field) => ( {displayColumns.map((field) => (
<div key={field} className="mb-2"> <div key={field} className="mb-2">
<span className="font-semibold">{getLabel(field)}: </span> <span className="font-semibold">{getLabel(field)}: </span>
@ -489,6 +721,49 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
</div> </div>
</div> </div>
)} )}
{/* 행 상세 팝업 */}
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
<DialogHeader>
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
<DialogDescription>
{detailPopupLoading
? "추가 정보를 로딩 중입니다..."
: detailPopupData
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
: "선택된 항목의 상세 정보입니다."}
</DialogDescription>
</DialogHeader>
{detailPopupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : (
<div className="space-y-4">
{detailPopupData && (
<>
{/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */}
{config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0
? // 설정된 필드 그룹 렌더링
config.rowDetailPopup.fieldGroups.map((group) =>
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
)
: // 기본 필드 그룹 렌더링
getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) =>
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
)}
</>
)}
</div>
)}
<DialogFooter>
<Button onClick={() => setDetailPopupOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { applyColumnMapping } from "@/lib/utils/columnMapping";
import { getApiUrl } from "@/lib/utils/apiUrl"; import { getApiUrl } from "@/lib/utils/apiUrl";
import { regionOptions, filterVehiclesByRegion } from "@/lib/constants/regionBounds";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
// Popup 말풍선 꼬리 제거 스타일 // Popup 말풍선 꼬리 제거 스타일
@ -101,6 +103,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [routeLoading, setRouteLoading] = useState(false); const [routeLoading, setRouteLoading] = useState(false);
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
// 공차/운행 정보 상태
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});
const [tripInfoLoading, setTripInfoLoading] = useState<string | null>(null);
// Popup 열림 상태 (자동 새로고침 일시 중지용)
const [isPopupOpen, setIsPopupOpen] = useState(false);
// 지역 필터 상태
const [selectedRegion, setSelectedRegion] = useState<string>("all");
// dataSources를 useMemo로 추출 (circular reference 방지) // dataSources를 useMemo로 추출 (circular reference 방지)
const dataSources = useMemo(() => { const dataSources = useMemo(() => {
return element?.dataSources || element?.chartConfig?.dataSources; return element?.dataSources || element?.chartConfig?.dataSources;
@ -182,6 +194,151 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setRoutePoints([]); setRoutePoints([]);
}, []); }, []);
// 공차/운행 정보 로드 함수
const loadTripInfo = useCallback(async (identifier: string) => {
if (!identifier || tripInfo[identifier]) {
return; // 이미 로드됨
}
setTripInfoLoading(identifier);
try {
// user_id 또는 vehicle_number로 조회
const query = `SELECT
id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id = '${identifier}'
OR vehicle_number = '${identifier}'
LIMIT 1`;
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
},
body: JSON.stringify({ query }),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
setTripInfo((prev) => ({
...prev,
[identifier]: result.data.rows[0],
}));
} else {
// 데이터가 없는 경우에도 "로드 완료" 상태로 표시 (빈 객체 저장)
setTripInfo((prev) => ({
...prev,
[identifier]: { _noData: true },
}));
}
} else {
// API 실패 시에도 "로드 완료" 상태로 표시
setTripInfo((prev) => ({
...prev,
[identifier]: { _noData: true },
}));
}
} catch (err) {
console.error("공차/운행 정보 로드 실패:", err);
// 에러 시에도 "로드 완료" 상태로 표시
setTripInfo((prev) => ({
...prev,
[identifier]: { _noData: true },
}));
}
setTripInfoLoading(null);
}, [tripInfo]);
// 마커 로드 시 운행/공차 정보 미리 일괄 조회
const preloadTripInfo = useCallback(async (loadedMarkers: MarkerData[]) => {
if (!loadedMarkers || loadedMarkers.length === 0) return;
// 마커에서 identifier 추출 (user_id 또는 vehicle_number)
const identifiers: string[] = [];
loadedMarkers.forEach((marker) => {
try {
const parsed = JSON.parse(marker.description || "{}");
const identifier = parsed.user_id || parsed.vehicle_number || parsed.id;
if (identifier && !tripInfo[identifier]) {
identifiers.push(identifier);
}
} catch {
// 파싱 실패 시 무시
}
});
if (identifiers.length === 0) return;
try {
// 모든 마커의 운행/공차 정보를 한 번에 조회
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT
id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
OR vehicle_number IN (${identifiers.map(id => `'${id}'`).join(", ")})`;
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
},
body: JSON.stringify({ query }),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
const newTripInfo: Record<string, any> = {};
// 조회된 데이터를 identifier별로 매핑
result.data.rows.forEach((row: any) => {
const hasData = row.last_trip_start || row.last_trip_end ||
row.last_trip_distance || row.last_trip_time ||
row.last_empty_start || row.last_empty_end ||
row.last_empty_distance || row.last_empty_time;
if (row.user_id) {
newTripInfo[row.user_id] = hasData ? row : { _noData: true };
}
if (row.vehicle_number) {
newTripInfo[row.vehicle_number] = hasData ? row : { _noData: true };
}
});
// 조회되지 않은 identifier는 _noData로 표시
identifiers.forEach((id) => {
if (!newTripInfo[id]) {
newTripInfo[id] = { _noData: true };
}
});
setTripInfo((prev) => ({ ...prev, ...newTripInfo }));
} else {
// 결과가 없으면 모든 identifier를 _noData로 표시
const noDataInfo: Record<string, any> = {};
identifiers.forEach((id) => {
noDataInfo[id] = { _noData: true };
});
setTripInfo((prev) => ({ ...prev, ...noDataInfo }));
}
}
} catch (err) {
console.error("운행/공차 정보 미리 로드 실패:", err);
}
}, [tripInfo]);
// 다중 데이터 소스 로딩 // 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => { const loadMultipleDataSources = useCallback(async () => {
if (!dataSources || dataSources.length === 0) { if (!dataSources || dataSources.length === 0) {
@ -254,6 +411,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setMarkers(markersWithHeading); setMarkers(markersWithHeading);
setPolygons(allPolygons); setPolygons(allPolygons);
setLastRefreshTime(new Date()); setLastRefreshTime(new Date());
// 마커 로드 후 운행/공차 정보 미리 일괄 조회
preloadTripInfo(markersWithHeading);
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally { } finally {
@ -1130,14 +1290,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
} }
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
loadMultipleDataSources(); // Popup이 열려있으면 자동 새로고침 건너뛰기
if (!isPopupOpen) {
loadMultipleDataSources();
}
}, refreshInterval * 1000); }, refreshInterval * 1000);
return () => { return () => {
clearInterval(intervalId); clearInterval(intervalId);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSources, element?.chartConfig?.refreshInterval]); }, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]);
// 타일맵 URL (VWorld 한국 지도) // 타일맵 URL (VWorld 한국 지도)
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
@ -1165,6 +1328,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 지역 필터 */}
<Select value={selectedRegion} onValueChange={setSelectedRegion}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder="지역 선택" />
</SelectTrigger>
<SelectContent>
{regionOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 이동경로 날짜 선택 */} {/* 이동경로 날짜 선택 */}
{selectedUserId && ( {selectedUserId && (
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1"> <div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
@ -1371,6 +1548,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
fillOpacity: 0.3, fillOpacity: 0.3,
weight: 2, weight: 2,
}} }}
eventHandlers={{
popupopen: () => setIsPopupOpen(true),
popupclose: () => setIsPopupOpen(false),
}}
> >
<Popup> <Popup>
<div className="min-w-[200px]"> <div className="min-w-[200px]">
@ -1442,8 +1623,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
); );
})} })}
{/* 마커 렌더링 */} {/* 마커 렌더링 (지역 필터 적용) */}
{markers.map((marker) => { {filterVehiclesByRegion(markers, selectedRegion).map((marker) => {
// 마커의 소스에 해당하는 데이터 소스 찾기 // 마커의 소스에 해당하는 데이터 소스 찾기
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0]; const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
const markerType = sourceDataSource?.markerType || "circle"; const markerType = sourceDataSource?.markerType || "circle";
@ -1602,7 +1783,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
} }
return ( return (
<Marker key={marker.id} position={[marker.lat, marker.lng]} icon={markerIcon}> <Marker
key={marker.id}
position={[marker.lat, marker.lng]}
icon={markerIcon}
eventHandlers={{
popupopen: () => setIsPopupOpen(true),
popupclose: () => setIsPopupOpen(false),
}}
>
<Popup maxWidth={350}> <Popup maxWidth={350}>
<div className="max-w-[350px] min-w-[250px]" dir="ltr"> <div className="max-w-[350px] min-w-[250px]" dir="ltr">
{/* 데이터 소스명만 표시 */} {/* 데이터 소스명만 표시 */}
@ -1713,6 +1902,161 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
} }
})()} })()}
{/* 공차/운행 정보 (동적 로딩) */}
{(() => {
try {
const parsed = JSON.parse(marker.description || "{}");
// 식별자 찾기 (user_id 또는 vehicle_number)
const identifier = parsed.user_id || parsed.userId || parsed.vehicle_number ||
parsed.vehicleNumber || parsed.plate_no || parsed.plateNo ||
parsed.car_number || parsed.carNumber || marker.name;
if (!identifier) return null;
// 동적으로 로드된 정보 또는 marker.description에서 가져온 정보 사용
const info = tripInfo[identifier] || parsed;
// 공차 정보가 있는지 확인
const hasEmptyTripInfo = info.last_empty_start || info.last_empty_end ||
info.last_empty_distance || info.last_empty_time;
// 운행 정보가 있는지 확인
const hasTripInfo = info.last_trip_start || info.last_trip_end ||
info.last_trip_distance || info.last_trip_time;
// 날짜/시간 포맷팅 함수
const formatDateTime = (dateStr: string) => {
if (!dateStr) return "-";
try {
const date = new Date(dateStr);
return date.toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
// 거리 포맷팅 (km)
const formatDistance = (dist: number | string) => {
if (dist === null || dist === undefined) return "-";
const num = typeof dist === "string" ? parseFloat(dist) : dist;
if (isNaN(num)) return "-";
return `${num.toFixed(1)} km`;
};
// 시간 포맷팅 (분)
const formatTime = (minutes: number | string) => {
if (minutes === null || minutes === undefined) return "-";
const num = typeof minutes === "string" ? parseInt(minutes) : minutes;
if (isNaN(num)) return "-";
if (num < 60) return `${num}`;
const hours = Math.floor(num / 60);
const mins = num % 60;
return mins > 0 ? `${hours}시간 ${mins}` : `${hours}시간`;
};
// 이미 로드했는데 데이터가 없는 경우 (버튼 숨김)
const loadedInfo = tripInfo[identifier];
if (loadedInfo && loadedInfo._noData) {
return null; // 데이터 없음 - 버튼도 정보도 표시 안 함
}
// 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시
if (!hasEmptyTripInfo && !hasTripInfo && !tripInfo[identifier]) {
return (
<div className="border-t pt-2 mt-2">
<button
onClick={() => loadTripInfo(identifier)}
disabled={tripInfoLoading === identifier}
className="w-full rounded bg-gray-100 px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-200 disabled:opacity-50"
>
{tripInfoLoading === identifier ? "로딩 중..." : "📊 운행/공차 정보 보기"}
</button>
</div>
);
}
// 데이터가 없으면 표시 안 함
if (!hasEmptyTripInfo && !hasTripInfo) return null;
return (
<div className="border-t pt-2 mt-2">
{/* 운행 정보 */}
{hasTripInfo && (
<div className="mb-2">
<div className="text-xs font-semibold text-blue-600 mb-1">🚛 </div>
<div className="bg-blue-50 rounded p-2 space-y-1">
{(info.last_trip_start || info.last_trip_end) && (
<div className="text-[10px] text-gray-600">
<span className="font-medium">:</span>{" "}
{formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)}
</div>
)}
<div className="flex gap-3 text-[10px]">
{info.last_trip_distance !== undefined && info.last_trip_distance !== null && (
<span>
<span className="font-medium text-gray-600">:</span>{" "}
<span className="text-blue-700 font-semibold">{formatDistance(info.last_trip_distance)}</span>
</span>
)}
{info.last_trip_time !== undefined && info.last_trip_time !== null && (
<span>
<span className="font-medium text-gray-600">:</span>{" "}
<span className="text-blue-700 font-semibold">{formatTime(info.last_trip_time)}</span>
</span>
)}
</div>
{/* 출발지/도착지 */}
{(info.departure || info.arrival) && (
<div className="text-[10px] text-gray-600 pt-1 border-t border-blue-100">
{info.departure && <span>: {info.departure}</span>}
{info.departure && info.arrival && " → "}
{info.arrival && <span>: {info.arrival}</span>}
</div>
)}
</div>
</div>
)}
{/* 공차 정보 */}
{hasEmptyTripInfo && (
<div>
<div className="text-xs font-semibold text-orange-600 mb-1">📦 </div>
<div className="bg-orange-50 rounded p-2 space-y-1">
{(info.last_empty_start || info.last_empty_end) && (
<div className="text-[10px] text-gray-600">
<span className="font-medium">:</span>{" "}
{formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)}
</div>
)}
<div className="flex gap-3 text-[10px]">
{info.last_empty_distance !== undefined && info.last_empty_distance !== null && (
<span>
<span className="font-medium text-gray-600">:</span>{" "}
<span className="text-orange-700 font-semibold">{formatDistance(info.last_empty_distance)}</span>
</span>
)}
{info.last_empty_time !== undefined && info.last_empty_time !== null && (
<span>
<span className="font-medium text-gray-600">:</span>{" "}
<span className="text-orange-700 font-semibold">{formatTime(info.last_empty_time)}</span>
</span>
)}
</div>
</div>
</div>
)}
</div>
);
} catch {
return null;
}
})()}
{/* 좌표 */} {/* 좌표 */}
<div className="text-muted-foreground border-t pt-2 text-[10px]"> <div className="text-muted-foreground border-t pt-2 text-[10px]">
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
@ -1771,7 +2115,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{/* 하단 정보 */} {/* 하단 정보 */}
{(markers.length > 0 || polygons.length > 0) && ( {(markers.length > 0 || polygons.length > 0) && (
<div className="text-muted-foreground border-t p-2 text-xs"> <div className="text-muted-foreground border-t p-2 text-xs">
{markers.length > 0 && `마커 ${markers.length}`} {markers.length > 0 && (
<>
{filterVehiclesByRegion(markers, selectedRegion).length}
{selectedRegion !== "all" && ` (전체 ${markers.length}개)`}
</>
)}
{markers.length > 0 && polygons.length > 0 && " · "} {markers.length > 0 && polygons.length > 0 && " · "}
{polygons.length > 0 && `영역 ${polygons.length}`} {polygons.length > 0 && `영역 ${polygons.length}`}
</div> </div>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
@ -130,11 +130,11 @@ export function FlowDataListModal({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden"> <DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <ResizableDialogTitle className="flex items-center gap-2">
{stepName} {stepName}
<Badge variant="secondary">{data.length}</Badge> <Badge variant="secondary">{data.length}</Badge>
</DialogTitle> </ResizableDialogTitle>
<DialogDescription> </DialogDescription> <DialogDescription> </ResizableDialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">

View File

@ -48,7 +48,20 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
<Label className="text-xs font-medium sm:text-sm"> </Label> <Label className="text-xs font-medium sm:text-sm"> </Label>
<Select <Select
value={part.partType} value={part.partType}
onValueChange={(value) => onUpdate({ partType: value as CodePartType })} onValueChange={(value) => {
const newPartType = value as CodePartType;
// 타입 변경 시 해당 타입의 기본 autoConfig 설정
const defaultAutoConfig: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" },
};
onUpdate({
partType: newPartType,
autoConfig: defaultAutoConfig[newPartType] || {}
});
}}
disabled={isPreview} disabled={isPreview}
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">

View File

@ -196,10 +196,31 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
try { try {
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId); const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
// 파트별 기본 autoConfig 정의
const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "" },
};
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
const partsWithDefaults = currentRule.parts.map((part) => {
if (part.generationMethod === "auto") {
const defaults = defaultAutoConfigs[part.partType] || {};
return {
...part,
autoConfig: { ...defaults, ...part.autoConfig },
};
}
return part;
});
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정 // 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링) // 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
const ruleToSave = { const ruleToSave = {
...currentRule, ...currentRule,
parts: partsWithDefaults,
scopeType: "menu" as const, // 메뉴 기반 채번규칙 scopeType: "menu" as const, // 메뉴 기반 채번규칙
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준) menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)

File diff suppressed because it is too large Load Diff

View File

@ -62,7 +62,7 @@ import {
CostType, CostType,
costTypeLabels, costTypeLabels,
} from "@/lib/api/taxInvoice"; } from "@/lib/api/taxInvoice";
import { apiClient } from "@/lib/api/client"; import { uploadFiles } from "@/lib/api/file";
interface TaxInvoiceFormProps { interface TaxInvoiceFormProps {
open: boolean; open: boolean;
@ -223,36 +223,35 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor
}); });
}; };
// 파일 업로드 // 파일 업로드 (화면 관리 파일 업로드 컴포넌트와 동일한 방식 사용)
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
setUploading(true); setUploading(true);
try { try {
for (const file of Array.from(files)) { // 화면 관리 파일 업로드 컴포넌트와 동일한 uploadFiles 함수 사용
const formDataUpload = new FormData(); const response = await uploadFiles({
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files" files: files,
formDataUpload.append("category", "tax-invoice"); tableName: "tax_invoice",
fieldName: "attachments",
recordId: invoice?.id,
docType: "tax-invoice",
docTypeName: "세금계산서",
});
const response = await apiClient.post("/files/upload", formDataUpload, { if (response.success && response.files?.length > 0) {
headers: { "Content-Type": "multipart/form-data" }, const newAttachments: TaxInvoiceAttachment[] = response.files.map((uploadedFile) => ({
}); id: uploadedFile.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file_name: uploadedFile.name,
if (response.data.success && response.data.files?.length > 0) { file_path: uploadedFile.serverPath || "",
const uploadedFile = response.data.files[0]; file_size: uploadedFile.size,
const newAttachment: TaxInvoiceAttachment = { file_type: uploadedFile.type,
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, uploaded_at: uploadedFile.uploadedAt || new Date().toISOString(),
file_name: uploadedFile.realFileName || file.name, uploaded_by: "",
file_path: uploadedFile.filePath, }));
file_size: uploadedFile.fileSize || file.size, setAttachments((prev) => [...prev, ...newAttachments]);
file_type: file.type, toast.success(`${response.files.length}개 파일 업로드 완료`);
uploaded_at: new Date().toISOString(),
uploaded_by: "",
};
setAttachments((prev) => [...prev, newAttachment]);
toast.success(`'${file.name}' 업로드 완료`);
}
} }
} catch (error: any) { } catch (error: any) {
toast.error("파일 업로드 실패", { description: error.message }); toast.error("파일 업로드 실패", { description: error.message });

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -447,7 +447,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분 // 디자인 모드 플래그 전달 - isPreview와 명확히 구분
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
groupedData: props.groupedData, // Note: 이 props들은 DOM 요소에 전달되면 안 됨
// 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함
_groupedData: props.groupedData,
// 🆕 UniversalFormModal용 initialData 전달
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
_initialData: originalData || formData,
_originalData: originalData,
}; };
// 렌더러가 클래스인지 함수인지 확인 // 렌더러가 클래스인지 함수인지 확인

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useMemo } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Trash2, Loader2, X } from "lucide-react"; import { Trash2, Loader2, X, Plus } from "lucide-react";
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types"; import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component"; import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation"; import { useCalculation } from "./useCalculation";
@ -21,6 +21,7 @@ export interface SimpleRepeaterTableComponentProps extends ComponentRendererProp
readOnly?: boolean; readOnly?: boolean;
showRowNumber?: boolean; showRowNumber?: boolean;
allowDelete?: boolean; allowDelete?: boolean;
allowAdd?: boolean;
maxHeight?: string; maxHeight?: string;
} }
@ -44,10 +45,31 @@ export function SimpleRepeaterTableComponent({
readOnly: propReadOnly, readOnly: propReadOnly,
showRowNumber: propShowRowNumber, showRowNumber: propShowRowNumber,
allowDelete: propAllowDelete, allowDelete: propAllowDelete,
allowAdd: propAllowAdd,
maxHeight: propMaxHeight, maxHeight: propMaxHeight,
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
_initialData,
_originalData,
_groupedData,
// 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음)
initialData: legacyInitialData,
originalData: legacyOriginalData,
groupedData: legacyGroupedData,
...props ...props
}: SimpleRepeaterTableComponentProps) { }: SimpleRepeaterTableComponentProps & {
_initialData?: any;
_originalData?: any;
_groupedData?: any;
initialData?: any;
originalData?: any;
groupedData?: any;
}) {
// 실제 사용할 데이터 (새 props 우선, 레거시 fallback)
const effectiveInitialData = _initialData || legacyInitialData;
const effectiveOriginalData = _originalData || legacyOriginalData;
const effectiveGroupedData = _groupedData || legacyGroupedData;
// config 또는 component.config 또는 개별 prop 우선순위로 병합 // config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = { const componentConfig = {
...config, ...config,
@ -60,6 +82,13 @@ export function SimpleRepeaterTableComponent({
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false; const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true; const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true; const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
const allowAdd = componentConfig?.allowAdd ?? propAllowAdd ?? false;
const addButtonText = componentConfig?.addButtonText || "행 추가";
const addButtonPosition = componentConfig?.addButtonPosition || "bottom";
const minRows = componentConfig?.minRows ?? 0;
const maxRows = componentConfig?.maxRows ?? Infinity;
const newRowDefaults = componentConfig?.newRowDefaults || {};
const summaryConfig = componentConfig?.summaryConfig;
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px"; const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
// value는 formData[columnName] 우선, 없으면 prop 사용 // value는 formData[columnName] 우선, 없으면 prop 사용
@ -256,7 +285,7 @@ export function SimpleRepeaterTableComponent({
useEffect(() => { useEffect(() => {
const handleSaveRequest = async (event: Event) => { const handleSaveRequest = async (event: Event) => {
if (value.length === 0) { if (value.length === 0) {
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음"); // console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
return; return;
} }
@ -297,7 +326,7 @@ export function SimpleRepeaterTableComponent({
}); });
}); });
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable); // console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
// CustomEvent의 detail에 테이블별 데이터 추가 // CustomEvent의 detail에 테이블별 데이터 추가
if (event instanceof CustomEvent && event.detail) { if (event instanceof CustomEvent && event.detail) {
@ -310,10 +339,10 @@ export function SimpleRepeaterTableComponent({
})); }));
}); });
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", { // console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
tables: Object.keys(dataByTable), // tables: Object.keys(dataByTable),
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0), // totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
}); // });
} }
// 기존 onFormDataChange도 호출 (호환성) // 기존 onFormDataChange도 호출 (호환성)
@ -345,10 +374,137 @@ export function SimpleRepeaterTableComponent({
}; };
const handleRowDelete = (rowIndex: number) => { const handleRowDelete = (rowIndex: number) => {
// 최소 행 수 체크
if (value.length <= minRows) {
return;
}
const newData = value.filter((_, i) => i !== rowIndex); const newData = value.filter((_, i) => i !== rowIndex);
handleChange(newData); handleChange(newData);
}; };
// 행 추가 함수
const handleAddRow = () => {
// 최대 행 수 체크
if (value.length >= maxRows) {
return;
}
// 새 행 생성 (기본값 적용)
const newRow: Record<string, any> = { ...newRowDefaults };
// 각 컬럼의 기본값 설정
columns.forEach((col) => {
if (newRow[col.field] === undefined) {
if (col.defaultValue !== undefined) {
newRow[col.field] = col.defaultValue;
} else if (col.type === "number") {
newRow[col.field] = 0;
} else if (col.type === "date") {
newRow[col.field] = new Date().toISOString().split("T")[0];
} else {
newRow[col.field] = "";
}
}
});
// 계산 필드 적용
const calculatedRow = calculateRow(newRow);
const newData = [...value, calculatedRow];
handleChange(newData);
};
// 합계 계산
const summaryValues = useMemo(() => {
if (!summaryConfig?.enabled || !summaryConfig.fields || value.length === 0) {
return null;
}
const result: Record<string, number> = {};
// 먼저 기본 집계 함수 계산
summaryConfig.fields.forEach((field) => {
if (field.formula) return; // 수식 필드는 나중에 처리
const values = value.map((row) => {
const val = row[field.field];
return typeof val === "number" ? val : parseFloat(val) || 0;
});
switch (field.type || "sum") {
case "sum":
result[field.field] = values.reduce((a, b) => a + b, 0);
break;
case "avg":
result[field.field] = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case "count":
result[field.field] = values.length;
break;
case "min":
result[field.field] = Math.min(...values);
break;
case "max":
result[field.field] = Math.max(...values);
break;
default:
result[field.field] = values.reduce((a, b) => a + b, 0);
}
});
// 수식 필드 계산 (다른 합계 필드 참조)
summaryConfig.fields.forEach((field) => {
if (!field.formula) return;
let formula = field.formula;
// 다른 필드 참조 치환
Object.keys(result).forEach((key) => {
formula = formula.replace(new RegExp(`\\b${key}\\b`, "g"), result[key].toString());
});
try {
result[field.field] = new Function(`return ${formula}`)();
} catch {
result[field.field] = 0;
}
});
return result;
}, [value, summaryConfig]);
// 합계 값 포맷팅
const formatSummaryValue = (field: SummaryFieldConfig, value: number): string => {
const decimals = field.decimals ?? 0;
const formatted = value.toFixed(decimals);
switch (field.format) {
case "currency":
return Number(formatted).toLocaleString() + "원";
case "percent":
return formatted + "%";
default:
return Number(formatted).toLocaleString();
}
};
// 행 추가 버튼 컴포넌트
const AddRowButton = () => {
if (!allowAdd || readOnly || value.length >= maxRows) return null;
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-8 text-xs"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{addButtonText}
</Button>
);
};
const renderCell = ( const renderCell = (
row: any, row: any,
column: SimpleRepeaterColumnConfig, column: SimpleRepeaterColumnConfig,
@ -457,8 +613,18 @@ export function SimpleRepeaterTableComponent({
); );
} }
// 테이블 컬럼 수 계산
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
return ( return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}> <div className={cn("border rounded-md overflow-hidden bg-background", className)}>
{/* 상단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "bottom" && (
<div className="p-2 border-b bg-muted/50">
<AddRowButton />
</div>
)}
<div <div
className="overflow-x-auto overflow-y-auto" className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }} style={{ maxHeight }}
@ -492,10 +658,17 @@ export function SimpleRepeaterTableComponent({
{value.length === 0 ? ( {value.length === 0 ? (
<tr> <tr>
<td <td
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)} colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground" className="px-4 py-8 text-center text-muted-foreground"
> >
{allowAdd ? (
<div className="flex flex-col items-center gap-2">
<span> </span>
<AddRowButton />
</div>
) : (
"표시할 데이터가 없습니다"
)}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -517,7 +690,8 @@ export function SimpleRepeaterTableComponent({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRowDelete(rowIndex)} onClick={() => handleRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive" disabled={value.length <= minRows}
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -529,6 +703,58 @@ export function SimpleRepeaterTableComponent({
</tbody> </tbody>
</table> </table>
</div> </div>
{/* 합계 표시 */}
{summaryConfig?.enabled && summaryValues && (
<div className={cn(
"border-t bg-muted/30 p-3",
summaryConfig.position === "bottom-right" && "flex justify-end"
)}>
<div className={cn(
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
)}>
{summaryConfig.title && (
<div className="text-xs font-medium text-muted-foreground mb-2">
{summaryConfig.title}
</div>
)}
<div className={cn(
"grid gap-2",
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
)}>
{summaryConfig.fields.map((field) => (
<div
key={field.field}
className={cn(
"flex justify-between items-center px-3 py-1.5 rounded",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background"
)}
>
<span className="text-xs text-muted-foreground">{field.label}</span>
<span className={cn(
"text-sm font-medium",
field.highlight && "text-primary"
)}>
{formatSummaryValue(field, summaryValues[field.field] || 0)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* 하단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "top" && value.length > 0 && (
<div className="p-2 border-t bg-muted/50 flex justify-between items-center">
<AddRowButton />
{maxRows !== Infinity && (
<span className="text-xs text-muted-foreground">
{value.length} / {maxRows}
</span>
)}
</div>
)}
</div> </div>
); );
} }

View File

@ -15,6 +15,8 @@ import {
ColumnTargetConfig, ColumnTargetConfig,
InitialDataConfig, InitialDataConfig,
DataFilterCondition, DataFilterCondition,
SummaryConfig,
SummaryFieldConfig,
} from "./types"; } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -482,6 +484,81 @@ export function SimpleRepeaterTableConfigPanel({
</p> </p>
</div> </div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.allowAdd ?? false}
onCheckedChange={(checked) => updateConfig({ allowAdd: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
{localConfig.allowAdd && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.addButtonText || "행 추가"}
onChange={(e) => updateConfig({ addButtonText: e.target.value })}
placeholder="행 추가"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.addButtonPosition || "bottom"}
onValueChange={(value) => updateConfig({ addButtonPosition: value as "top" | "bottom" | "both" })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="both"> + </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={0}
value={localConfig.minRows ?? 0}
onChange={(e) => updateConfig({ minRows: parseInt(e.target.value) || 0 })}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
0
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={1}
value={localConfig.maxRows ?? ""}
onChange={(e) => updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="무제한"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label> <Label className="text-xs sm:text-sm"> </Label>
<Input <Input
@ -508,21 +585,29 @@ export function SimpleRepeaterTableConfigPanel({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label> <Label className="text-xs sm:text-sm"> </Label>
<Select <Select
value={localConfig.initialDataConfig?.sourceTable || ""} value={localConfig.initialDataConfig?.sourceTable || "__none__"}
onValueChange={(value) => onValueChange={(value) => {
updateConfig({ if (value === "__none__") {
initialDataConfig: { // 선택 안 함: initialDataConfig 초기화
...localConfig.initialDataConfig, updateConfig({
sourceTable: value, initialDataConfig: undefined,
}, });
}) } else {
} updateConfig({
initialDataConfig: {
...localConfig.initialDataConfig,
sourceTable: value,
},
});
}
}}
disabled={isLoadingTables} disabled={isLoadingTables}
> >
<SelectTrigger className="h-10 text-sm w-full"> <SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder={isLoadingTables ? "로딩 중..." : "데이터를 가져올 테이블 선택"} /> <SelectValue placeholder={isLoadingTables ? "로딩 중..." : "선택 안 함 (빈 테이블로 시작)"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{allTables.map((table) => ( {allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}> <SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName} {table.displayName || table.tableName}
@ -531,7 +616,7 @@ export function SimpleRepeaterTableConfigPanel({
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
(: sales_order_mng) ( )
</p> </p>
</div> </div>
@ -1002,48 +1087,71 @@ export function SimpleRepeaterTableConfigPanel({
)} )}
</div> </div>
{/* 🆕 데이터 타겟 설정 (어디에 저장할지) */} {/* 🆕 데이터 타겟 설정 - 부모-자식 모드면 숨김 */}
<div className="space-y-3 border-t pt-4"> {localConfig.parentChildConfig?.enabled ? (
<div className="flex items-center gap-2"> // 부모-자식 모드: 간단한 안내만 표시
<div className="h-1 w-1 rounded-full bg-green-500"></div> <div className="border-t pt-4">
<Label className="text-xs font-semibold text-green-600"> ( ?)</Label> <div className="p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
<p className="text-xs text-muted-foreground">
<strong className="text-green-700 dark:text-green-400">- </strong>
<br />
<code className="bg-green-100 dark:bg-green-900 px-1 rounded">{localConfig.parentChildConfig.childTable || "자식 테이블"}.{col.field || "필드명"}</code>
</p>
</div>
</div> </div>
) : (
// 일반 모드: 타겟 설정 (선택사항)
<div className="space-y-3 border-t pt-4">
<div className="flex items-center gap-2">
<div className="h-1 w-1 rounded-full bg-gray-400"></div>
<Label className="text-xs font-semibold text-muted-foreground"> ()</Label>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Select <Select
value={col.targetConfig?.targetTable || ""} value={col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" ? col.targetConfig.targetTable : "__none__"}
onValueChange={(value) => updateColumn(index, { onValueChange={(value) => {
targetConfig: { if (value === "__none__") {
...col.targetConfig, // 선택 안 함: targetConfig 초기화
targetTable: value, updateColumn(index, {
saveEnabled: true, targetConfig: undefined
} });
})} } else {
> // 테이블 선택: targetConfig 설정
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> updateColumn(index, {
<SelectValue placeholder="저장할 테이블 선택" /> targetConfig: {
</SelectTrigger> targetTable: value,
<SelectContent> targetColumn: col.field || "",
{allTables.map((table) => ( saveEnabled: true,
<SelectItem key={table.tableName} value={table.tableName}> }
{table.displayName || table.tableName} });
</SelectItem> }
))} }}
</SelectContent> >
</Select> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<p className="text-xs text-muted-foreground"> <SelectValue placeholder="선택 안 함" />
</SelectTrigger>
</p> <SelectContent>
</div> <SelectItem value="__none__"> </SelectItem>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
{col.targetConfig?.targetTable && ( {col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" && (
<>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<SourceColumnSelector <SourceColumnSelector
sourceTable={col.targetConfig.targetTable} sourceTable={col.targetConfig.targetTable}
value={col.targetConfig.targetColumn || ""} value={col.targetConfig.targetColumn || col.field || ""}
onChange={(value) => updateColumn(index, { onChange={(value) => updateColumn(index, {
targetConfig: { targetConfig: {
...col.targetConfig, ...col.targetConfig,
@ -1052,37 +1160,10 @@ export function SimpleRepeaterTableConfigPanel({
})} })}
showTableName={true} showTableName={true}
/> />
<p className="text-xs text-muted-foreground">
</p>
</div> </div>
)}
<div className="space-y-2"> </div>
<div className="flex items-center justify-between"> )}
<Label className="text-xs"> </Label>
<Switch
checked={col.targetConfig.saveEnabled ?? true}
onCheckedChange={(checked) => updateColumn(index, {
targetConfig: {
...col.targetConfig,
saveEnabled: checked
}
})}
/>
</div>
<p className="text-xs text-muted-foreground">
( )
</p>
</div>
{col.targetConfig.targetTable && col.targetConfig.targetColumn && (
<div className="p-2 bg-green-50 dark:bg-green-950 rounded text-[10px] font-mono border border-green-200 dark:border-green-800">
: {col.targetConfig.targetTable}.{col.targetConfig.targetColumn}
</div>
)}
</>
)}
</div>
{/* 편집 가능 여부 */} {/* 편집 가능 여부 */}
<div className="space-y-2"> <div className="space-y-2">
@ -1235,11 +1316,13 @@ export function SimpleRepeaterTableConfigPanel({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(localConfig.columns || []).map((col, colIndex) => ( {(localConfig.columns || [])
<SelectItem key={col.field || `empty-${colIndex}`} value={col.field || ""}> .filter((col) => col.field && col.field.trim() !== "")
{col.label} ({col.field || '미설정'}) .map((col, colIndex) => (
</SelectItem> <SelectItem key={col.field || `col-${colIndex}`} value={col.field}>
))} {col.label} ({col.field})
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
@ -1314,15 +1397,285 @@ export function SimpleRepeaterTableConfigPanel({
)} )}
</div> </div>
{/* 합계 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.summaryConfig?.enabled ?? false}
onCheckedChange={(checked) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: checked,
fields: localConfig.summaryConfig?.fields || [],
}
})}
/>
</div>
</div>
{localConfig.summaryConfig?.enabled && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.summaryConfig?.title || ""}
onChange={(e) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
title: e.target.value,
fields: localConfig.summaryConfig?.fields || [],
}
})}
placeholder="합계"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.summaryConfig?.position || "bottom"}
onValueChange={(value) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
position: value as "bottom" | "bottom-right",
fields: localConfig.summaryConfig?.fields || [],
}
})}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom"> </SelectItem>
<SelectItem value="bottom-right"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const fields = localConfig.summaryConfig?.fields || [];
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields: [...fields, { field: "", label: "", type: "sum", format: "number" }],
}
});
}}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? (
<div className="space-y-3">
{localConfig.summaryConfig.fields.map((field, index) => (
<div key={`summary-${index}`} className="border rounded-md p-3 space-y-3 bg-background">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields.splice(index, 1);
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={field.field}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
const selectedCol = localConfig.columns?.find(c => c.field === value);
fields[index] = {
...fields[index],
field: value,
label: fields[index].label || selectedCol?.label || value,
};
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).filter(c => c.type === "number").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={field.label}
onChange={(e) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], label: e.target.value };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
placeholder="합계 라벨"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={field.type || "sum"}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], type: value as SummaryFieldConfig["type"] };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={field.format || "number"}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], format: value as SummaryFieldConfig["format"] };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"> ()</SelectItem>
<SelectItem value="percent"> (%)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={field.highlight ?? false}
onCheckedChange={(checked) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], highlight: checked };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
/>
</div>
</div>
))}
</div>
) : (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium mb-1"> </p>
<div className="space-y-1 text-[10px] text-muted-foreground">
<p> 합계: supply_amount SUM</p>
<p> 합계: tax_amount SUM</p>
<p> 총액: supply_amount + tax_amount ( )</p>
</div>
</div>
</>
)}
</div>
{/* 사용 안내 */} {/* 사용 안내 */}
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground"> <div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2">SimpleRepeaterTable :</p> <p className="font-medium mb-2">SimpleRepeaterTable :</p>
<ul className="space-y-1 list-disc list-inside"> <ul className="space-y-1 list-disc list-inside">
<li> </li> <li> </li>
<li>/ , </li> <li><strong> </strong> </li>
<li> EditModal과 , </li> <li> EditModal과 , </li>
<li>readOnly </li> <li>readOnly </li>
<li> * = </li> <li> * = </li>
<li><strong> </strong> / </li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -9,7 +9,7 @@ import { ComponentRendererProps } from "@/types/component";
// 컴포넌트 자동 등록 // 컴포넌트 자동 등록
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition); ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료"); // console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) { export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
return <SimpleRepeaterTableComponent {...props} />; return <SimpleRepeaterTableComponent {...props} />;

View File

@ -31,6 +31,15 @@ export const SimpleRepeaterTableDefinition = createComponentDefinition({
readOnly: false, readOnly: false,
showRowNumber: true, showRowNumber: true,
allowDelete: true, allowDelete: true,
allowAdd: false,
addButtonText: "행 추가",
addButtonPosition: "bottom",
minRows: 0,
maxRows: undefined,
summaryConfig: {
enabled: false,
fields: [],
},
maxHeight: "240px", maxHeight: "240px",
}, },
defaultSize: { width: 800, height: 400 }, defaultSize: { width: 800, height: 400 },
@ -51,6 +60,8 @@ export type {
InitialDataConfig, InitialDataConfig,
DataFilterCondition, DataFilterCondition,
SourceJoinCondition, SourceJoinCondition,
SummaryConfig,
SummaryFieldConfig,
} from "./types"; } from "./types";
// 컴포넌트 내보내기 // 컴포넌트 내보내기

View File

@ -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;
}

View File

@ -30,7 +30,8 @@ export function useCalculation(calculationRules: CalculationRule[] = []) {
// 결과 필드는 제외 // 결과 필드는 제외
if (dep === rule.result) continue; if (dep === rule.result) continue;
const value = parseFloat(row[dep]) || 0; // 이전 계산 결과(updatedRow)를 우선 사용, 없으면 원본(row) 사용
const value = parseFloat(updatedRow[dep] ?? row[dep]) || 0;
// 정확한 필드명만 대체 (단어 경계 사용) // 정확한 필드명만 대체 (단어 경계 사용)
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString()); formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
} }

View File

@ -905,11 +905,22 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 좌측 패널 항목 렌더링 // 좌측 패널 항목 렌더링
const renderLeftItem = (item: any, level: number = 0, index: number = 0) => { const renderLeftItem = (item: any, level: number = 0, index: number = 0) => {
const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; // ID 컬럼 결정: 설정값 > 데이터에 존재하는 일반적인 ID 컬럼 > 폴백
const configIdColumn = config.leftPanel?.hierarchyConfig?.idColumn;
const idColumn = configIdColumn ||
(item["id"] !== undefined ? "id" :
item["dept_code"] !== undefined ? "dept_code" :
item["code"] !== undefined ? "code" : "id");
const itemId = item[idColumn] ?? `item-${level}-${index}`; const itemId = item[idColumn] ?? `item-${level}-${index}`;
const hasChildren = item.children?.length > 0; const hasChildren = item.children?.length > 0;
const isExpanded = expandedItems.has(String(itemId)); const isExpanded = expandedItems.has(String(itemId));
const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; // 선택 상태 확인: 동일한 객체이거나 idColumn 값이 일치해야 함
const isSelected = selectedLeftItem && (
selectedLeftItem === item ||
(item[idColumn] !== undefined &&
selectedLeftItem[idColumn] !== undefined &&
selectedLeftItem[idColumn] === item[idColumn])
);
// displayRow 설정에 따라 컬럼 분류 // displayRow 설정에 따라 컬럼 분류
const displayColumns = config.leftPanel?.displayColumns || []; const displayColumns = config.leftPanel?.displayColumns || [];

View File

@ -28,6 +28,17 @@ interface SingleTableWithStickyProps {
containerWidth?: string; // 컨테이너 너비 설정 containerWidth?: string; // 컨테이너 너비 설정
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
// 인라인 편집 관련 props
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
} }
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
@ -51,6 +62,17 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
containerWidth, containerWidth,
loading = false, loading = false,
error = null, error = null,
// 인라인 편집 관련 props
onCellDoubleClick,
editingCell,
editingValue,
onEditingValueChange,
onEditKeyDown,
editInputRef,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
}) => { }) => {
const checkboxConfig = tableConfig?.checkbox || {}; const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || []; const actualColumns = visibleColumns || columns || [];
@ -58,14 +80,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
return ( return (
<div <div
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm" className="relative flex flex-col bg-background shadow-sm"
style={{ style={{
width: "100%", width: "100%",
height: "100%",
boxSizing: "border-box", boxSizing: "border-box",
}} }}
> >
<div className="relative flex-1 overflow-auto"> <div className="relative overflow-x-auto">
<Table <Table
className="w-full" className="w-full"
style={{ style={{
@ -75,17 +96,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
}} }}
> >
<TableHeader <TableHeader
className={ className={cn(
tableConfig.stickyHeader "border-b bg-background",
? "sticky top-0 border-b shadow-md" tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
: "border-b" )}
}
style={{
position: "sticky",
top: 0,
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
> >
<TableRow className="border-b"> <TableRow className="border-b">
{actualColumns.map((column, colIndex) => { {actualColumns.map((column, colIndex) => {
@ -215,9 +229,65 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0; : 0;
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted = column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult = isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 text-white font-semibold"
: "bg-yellow-200 text-yellow-900"
)}>
{match}
</mark>
{after}
</>
);
};
return ( return (
<TableCell <TableCell
key={`cell-${column.columnName}`} key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn( className={cn(
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm", "h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`, `text-${column.align}`,
@ -226,6 +296,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm", "sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
column.fixed === "right" && column.fixed === "right" &&
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm", "sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)} )}
style={{ style={{
width: getColumnWidth(column), width: getColumnWidth(column),
@ -239,10 +311,36 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
}} }}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
> >
{column.columnName === "__checkbox__" {column.columnName === "__checkbox__" ? (
? renderCheckboxCell(row, index) renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"} ) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell> </TableCell>
); );
})} })}

View File

@ -4674,20 +4674,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* 새로고침 버튼 */} {/* 새로고침 버튼 (하단 페이지네이션) */}
<Button {(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={handleRefresh} size="sm"
disabled={loading} onClick={handleRefresh}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3" disabled={loading}
> className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} /> >
</Button> <RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</Button>
)}
</div> </div>
</div> </div>
); );
}, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); }, [tableConfig.pagination, tableConfig.toolbar?.showPaginationRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]);
// ======================================== // ========================================
// 렌더링 // 렌더링
@ -4790,57 +4792,67 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 🆕 DevExpress 스타일 기능 툴바 */} {/* 🆕 DevExpress 스타일 기능 툴바 */}
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2"> <div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
{/* 편집 모드 토글 */} {/* 편집 모드 토글 */}
<div className="flex items-center gap-1 border-r border-border pr-2"> {(tableConfig.toolbar?.showEditMode ?? true) && (
<Button <div className="flex items-center gap-1 border-r border-border pr-2">
variant={editMode === "batch" ? "default" : "ghost"} <Button
size="sm" variant={editMode === "batch" ? "default" : "ghost"}
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")} size="sm"
className="h-7 text-xs" onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
title="배치 편집 모드 (Ctrl+B)" className="h-7 text-xs"
> title="배치 편집 모드 (Ctrl+B)"
<Edit className="mr-1 h-3 w-3" /> >
{editMode === "batch" ? "배치 모드" : "즉시 저장"} <Edit className="mr-1 h-3 w-3" />
</Button> {editMode === "batch" ? "배치 모드" : "즉시 저장"}
</div> </Button>
</div>
)}
{/* 내보내기 버튼들 */} {/* 내보내기 버튼들 */}
<div className="flex items-center gap-1 border-r border-border pr-2"> {((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && (
<Button <div className="flex items-center gap-1 border-r border-border pr-2">
variant="ghost" {(tableConfig.toolbar?.showExcel ?? true) && (
size="sm" <Button
onClick={() => exportToExcel(true)} variant="ghost"
className="h-7 text-xs" size="sm"
title="Excel 내보내기" onClick={() => exportToExcel(true)}
> className="h-7 text-xs"
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" /> title="Excel 내보내기"
Excel >
</Button> <FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
<Button Excel
variant="ghost" </Button>
size="sm" )}
onClick={() => exportToPdf(true)} {(tableConfig.toolbar?.showPdf ?? true) && (
className="h-7 text-xs" <Button
title="PDF 내보내기" variant="ghost"
> size="sm"
<FileText className="mr-1 h-3 w-3 text-red-600" /> onClick={() => exportToPdf(true)}
PDF className="h-7 text-xs"
</Button> title="PDF 내보내기"
</div> >
<FileText className="mr-1 h-3 w-3 text-red-600" />
PDF
</Button>
)}
</div>
)}
{/* 복사 버튼 */} {/* 복사 버튼 */}
<div className="flex items-center gap-1 border-r border-border pr-2"> {(tableConfig.toolbar?.showCopy ?? true) && (
<Button <div className="flex items-center gap-1 border-r border-border pr-2">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={handleCopy} size="sm"
disabled={selectedRows.size === 0 && !focusedCell} onClick={handleCopy}
className="h-7 text-xs" disabled={selectedRows.size === 0 && !focusedCell}
title="복사 (Ctrl+C)" className="h-7 text-xs"
> title="복사 (Ctrl+C)"
<Copy className="mr-1 h-3 w-3" /> >
<Copy className="mr-1 h-3 w-3" />
</Button>
</div> </Button>
</div>
)}
{/* 선택 정보 */} {/* 선택 정보 */}
{selectedRows.size > 0 && ( {selectedRows.size > 0 && (
@ -4861,124 +4873,130 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)} )}
{/* 🆕 통합 검색 패널 */} {/* 🆕 통합 검색 패널 */}
<div className="flex items-center gap-1 border-r border-border pr-2"> {(tableConfig.toolbar?.showSearch ?? true) && (
{isSearchPanelOpen ? ( <div className="flex items-center gap-1 border-r border-border pr-2">
<div className="flex items-center gap-1"> {isSearchPanelOpen ? (
<input <div className="flex items-center gap-1">
type="text" <input
value={globalSearchTerm} type="text"
onChange={(e) => setGlobalSearchTerm(e.target.value)} value={globalSearchTerm}
onKeyDown={(e) => { onChange={(e) => setGlobalSearchTerm(e.target.value)}
if (e.key === "Enter") { onKeyDown={(e) => {
executeGlobalSearch(globalSearchTerm); if (e.key === "Enter") {
} else if (e.key === "Escape") { executeGlobalSearch(globalSearchTerm);
clearGlobalSearch(); } else if (e.key === "Escape") {
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { clearGlobalSearch();
e.preventDefault(); } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
if (e.shiftKey) { e.preventDefault();
goToPrevSearchResult(); if (e.shiftKey) {
} else { goToPrevSearchResult();
goToNextSearchResult(); } else {
goToNextSearchResult();
}
} }
} }}
}} placeholder="검색어 입력... (Enter)"
placeholder="검색어 입력... (Enter)" className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48"
className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" autoFocus
autoFocus />
/> {searchHighlights.size > 0 && (
{searchHighlights.size > 0 && ( <span className="text-muted-foreground text-xs">
<span className="text-muted-foreground text-xs"> {searchHighlights.size}
{searchHighlights.size} </span>
)}
<Button
variant="ghost"
size="sm"
onClick={goToPrevSearchResult}
disabled={searchHighlights.size === 0}
className="h-6 w-6 p-0"
title="이전 (Shift+F3)"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNextSearchResult}
disabled={searchHighlights.size === 0}
className="h-6 w-6 p-0"
title="다음 (F3)"
>
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearGlobalSearch}
className="h-6 w-6 p-0"
title="닫기 (Esc)"
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchPanelOpen(true)}
className="h-7 text-xs"
title="통합 검색 (Ctrl+F)"
>
<Filter className="mr-1 h-3 w-3" />
</Button>
)}
</div>
)}
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
{(tableConfig.toolbar?.showFilter ?? true) && (
<div className="flex items-center gap-1 border-r border-border pr-2">
<Button
variant={activeFilterCount > 0 ? "default" : "ghost"}
size="sm"
onClick={() => setIsFilterBuilderOpen(true)}
className="h-7 text-xs"
title="고급 필터"
>
<Layers className="mr-1 h-3 w-3" />
{activeFilterCount > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{activeFilterCount}
</span> </span>
)} )}
</Button>
{activeFilterCount > 0 && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={goToPrevSearchResult} onClick={clearFilterBuilder}
disabled={searchHighlights.size === 0}
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
title="이전 (Shift+F3)" title="필터 초기화"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNextSearchResult}
disabled={searchHighlights.size === 0}
className="h-6 w-6 p-0"
title="다음 (F3)"
>
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearGlobalSearch}
className="h-6 w-6 p-0"
title="닫기 (Esc)"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchPanelOpen(true)}
className="h-7 text-xs"
title="통합 검색 (Ctrl+F)"
>
<Filter className="mr-1 h-3 w-3" />
</Button>
)}
</div>
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
<div className="flex items-center gap-1 border-r border-border pr-2">
<Button
variant={activeFilterCount > 0 ? "default" : "ghost"}
size="sm"
onClick={() => setIsFilterBuilderOpen(true)}
className="h-7 text-xs"
title="고급 필터"
>
<Layers className="mr-1 h-3 w-3" />
{activeFilterCount > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{activeFilterCount}
</span>
)} )}
</Button> </div>
{activeFilterCount > 0 && ( )}
<Button
variant="ghost"
size="sm"
onClick={clearFilterBuilder}
className="h-6 w-6 p-0"
title="필터 초기화"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* 새로고침 */} {/* 새로고침 */}
<div className="ml-auto flex items-center gap-1"> {(tableConfig.toolbar?.showRefresh ?? true) && (
<Button <div className="ml-auto flex items-center gap-1">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={handleRefresh} size="sm"
disabled={loading} onClick={handleRefresh}
className="h-7 text-xs" disabled={loading}
title="새로고침" className="h-7 text-xs"
> title="새로고침"
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} /> >
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
</Button>
</div> </Button>
</div>
)}
</div> </div>
{/* 🆕 배치 편집 툴바 */} {/* 🆕 배치 편집 툴바 */}

View File

@ -765,6 +765,81 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 툴바 버튼 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="showEditMode"
checked={config.toolbar?.showEditMode ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)}
/>
<Label htmlFor="showEditMode" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showExcel"
checked={config.toolbar?.showExcel ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)}
/>
<Label htmlFor="showExcel" className="text-xs">Excel</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPdf"
checked={config.toolbar?.showPdf ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)}
/>
<Label htmlFor="showPdf" className="text-xs">PDF</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showCopy"
checked={config.toolbar?.showCopy ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)}
/>
<Label htmlFor="showCopy" className="text-xs"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showSearch"
checked={config.toolbar?.showSearch ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)}
/>
<Label htmlFor="showSearch" className="text-xs"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFilter"
checked={config.toolbar?.showFilter ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)}
/>
<Label htmlFor="showFilter" className="text-xs"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showRefresh"
checked={config.toolbar?.showRefresh ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)}
/>
<Label htmlFor="showRefresh" className="text-xs"> ()</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPaginationRefresh"
checked={config.toolbar?.showPaginationRefresh ?? true}
onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)}
/>
<Label htmlFor="showPaginationRefresh" className="text-xs"> ()</Label>
</div>
</div>
</div>
{/* 체크박스 설정 */} {/* 체크박스 설정 */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>

View File

@ -164,6 +164,20 @@ export interface PaginationConfig {
pageSizeOptions: number[]; pageSizeOptions: number[];
} }
/**
*
*/
export interface ToolbarConfig {
showEditMode?: boolean; // 즉시 저장/배치 모드 버튼
showExcel?: boolean; // Excel 내보내기 버튼
showPdf?: boolean; // PDF 내보내기 버튼
showCopy?: boolean; // 복사 버튼
showSearch?: boolean; // 검색 버튼
showFilter?: boolean; // 필터 버튼
showRefresh?: boolean; // 상단 툴바 새로고침 버튼
showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼
}
/** /**
* *
*/ */
@ -259,6 +273,9 @@ export interface TableListConfig extends ComponentConfig {
autoLoad: boolean; autoLoad: boolean;
refreshInterval?: number; // 초 단위 refreshInterval?: number; // 초 단위
// 🆕 툴바 버튼 표시 설정
toolbar?: ToolbarConfig;
// 🆕 컬럼 값 기반 데이터 필터링 // 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig; dataFilter?: DataFilterConfig;

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { generateNumberingCode } from "@/lib/api/numberingRule"; import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management"; import { CascadingDropdownConfig } from "@/types/screen-management";
@ -81,11 +81,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
const isDisabled = disabled || !parentValue || loading; const isDisabled = disabled || !parentValue || loading;
return ( return (
<Select <Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
value={value || ""}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger id={fieldId} className="w-full"> <SelectTrigger id={fieldId} className="w-full">
{loading ? ( {loading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -127,11 +123,16 @@ export function UniversalFormModalComponent({
isSelected = false, isSelected = false,
className, className,
style, style,
initialData, initialData: propInitialData,
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
_initialData,
onSave, onSave,
onCancel, onCancel,
onChange, onChange,
}: UniversalFormModalComponentProps) { ...restProps // 나머지 props는 DOM에 전달하지 않음
}: UniversalFormModalComponentProps & { _initialData?: any }) {
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
const initialData = propInitialData || _initialData;
// 설정 병합 // 설정 병합
const config: UniversalFormModalConfig = useMemo(() => { const config: UniversalFormModalConfig = useMemo(() => {
const componentConfig = component?.config || {}; const componentConfig = component?.config || {};
@ -194,10 +195,34 @@ export function UniversalFormModalComponent({
itemId: string; itemId: string;
}>({ open: false, sectionId: "", itemId: "" }); }>({ open: false, sectionId: "", itemId: "" });
// 초기화 // 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
const hasInitialized = useRef(false);
// 초기화 - 최초 마운트 시에만 실행
useEffect(() => { useEffect(() => {
// 이미 초기화되었으면 스킵
if (hasInitialized.current) {
return;
}
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
}
hasInitialized.current = true;
initializeForm(); initializeForm();
}, [config, initialData]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
// config 변경 시에만 재초기화 (initialData 변경은 무시)
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 필드 레벨 linkedFieldGroup 데이터 로드 // 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => { useEffect(() => {
@ -216,7 +241,6 @@ export function UniversalFormModalComponent({
// 각 테이블 데이터 로드 // 각 테이블 데이터 로드
for (const tableName of tablesToLoad) { for (const tableName of tablesToLoad) {
if (!linkedFieldDataCache[tableName]) { if (!linkedFieldDataCache[tableName]) {
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
await loadLinkedFieldData(tableName); await loadLinkedFieldData(tableName);
} }
} }
@ -228,6 +252,9 @@ export function UniversalFormModalComponent({
// 폼 초기화 // 폼 초기화
const initializeForm = useCallback(async () => { const initializeForm = useCallback(async () => {
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
const newFormData: FormDataState = {}; const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
const newCollapsed = new Set<string>(); const newCollapsed = new Set<string>();
@ -253,11 +280,14 @@ export function UniversalFormModalComponent({
// 기본값 설정 // 기본값 설정
let value = field.defaultValue ?? ""; let value = field.defaultValue ?? "";
// 부모에서 전달받은 값 적용 // 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면)
if (field.receiveFromParent && initialData) { if (effectiveInitialData) {
const parentField = field.parentFieldName || field.columnName; const parentField = field.parentFieldName || field.columnName;
if (initialData[parentField] !== undefined) { if (effectiveInitialData[parentField] !== undefined) {
value = initialData[parentField]; // receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용
if (field.receiveFromParent || value === "" || value === undefined) {
value = effectiveInitialData[parentField];
}
} }
} }
@ -269,11 +299,12 @@ export function UniversalFormModalComponent({
setFormData(newFormData); setFormData(newFormData);
setRepeatSections(newRepeatSections); setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed); setCollapsedSections(newCollapsed);
setOriginalData(initialData || {}); setOriginalData(effectiveInitialData || {});
// 채번규칙 자동 생성 // 채번규칙 자동 생성
await generateNumberingValues(newFormData); await generateNumberingValues(newFormData);
}, [config, initialData]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
// 반복 섹션 아이템 생성 // 반복 섹션 아이템 생성
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
@ -423,15 +454,30 @@ export function UniversalFormModalComponent({
if (optionConfig.type === "static") { if (optionConfig.type === "static") {
options = optionConfig.staticOptions || []; options = optionConfig.staticOptions || [];
} else if (optionConfig.type === "table" && optionConfig.tableName) { } else if (optionConfig.type === "table" && optionConfig.tableName) {
const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, { // POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
params: { limit: 1000 }, const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
page: 1,
size: 1000,
autoFilter: { enabled: true, filterColumn: "company_code" },
}); });
if (response.data?.success && response.data?.data) {
options = response.data.data.map((row: any) => ({ // 응답 데이터 파싱
value: String(row[optionConfig.valueColumn || "id"]), let dataArray: any[] = [];
label: String(row[optionConfig.labelColumn || "name"]), if (response.data?.success) {
})); const responseData = response.data?.data;
if (responseData?.data && Array.isArray(responseData.data)) {
dataArray = responseData.data;
} else if (Array.isArray(responseData)) {
dataArray = responseData;
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
dataArray = responseData.rows;
}
} }
options = dataArray.map((row: any) => ({
value: String(row[optionConfig.valueColumn || "id"]),
label: String(row[optionConfig.labelColumn || "name"]),
}));
} else if (optionConfig.type === "code" && optionConfig.codeCategory) { } else if (optionConfig.type === "code" && optionConfig.codeCategory) {
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`); const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
if (response.data?.success && response.data?.data) { if (response.data?.success && response.data?.data) {
@ -471,9 +517,9 @@ export function UniversalFormModalComponent({
size: 1000, size: 1000,
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링 autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
}); });
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data); console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
if (response.data?.success) { if (response.data?.success) {
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] } // data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
const responseData = response.data?.data; const responseData = response.data?.data;
@ -534,18 +580,23 @@ export function UniversalFormModalComponent({
} }
}); });
// 저장 시점 채번규칙 처리 // 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
for (const section of config.sections) { for (const section of config.sections) {
for (const field of section.fields) { for (const field of section.fields) {
if ( if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
field.numberingRule?.enabled && // generateOnSave: 저장 시 새로 생성
field.numberingRule?.generateOnSave && // generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
field.numberingRule?.ruleId && if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
!dataToSave[field.columnName] const response = await allocateNumberingCode(field.numberingRule.ruleId);
) { if (response.success && response.data?.generatedCode) {
const response = await generateNumberingCode(field.numberingRule.ruleId); dataToSave[field.columnName] = response.data.generatedCode;
if (response.success && response.data?.generatedCode) { }
dataToSave[field.columnName] = response.data.generatedCode; } else if (field.numberingRule.generateOnOpen && dataToSave[field.columnName]) {
// generateOnOpen인 경우, 미리보기 값이 있더라도 실제 순번 할당
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
}
} }
} }
} }
@ -570,7 +621,6 @@ export function UniversalFormModalComponent({
if (commonFields.length === 0) { if (commonFields.length === 0) {
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable); const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName)); commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields);
} }
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용 // 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
@ -578,22 +628,9 @@ export function UniversalFormModalComponent({
const repeatableSection = config.sections.find((s) => s.repeatable); const repeatableSection = config.sections.find((s) => s.repeatable);
if (repeatableSection) { if (repeatableSection) {
repeatSectionId = repeatableSection.id; repeatSectionId = repeatableSection.id;
console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId);
} }
} }
// 디버깅: 설정 확인
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
commonFields,
repeatSectionId,
mainSectionFields,
typeColumn,
mainTypeValue,
subTypeValue,
repeatSections,
formData,
});
// 반복 섹션 데이터 // 반복 섹션 데이터
const repeatItems = repeatSections[repeatSectionId] || []; const repeatItems = repeatSections[repeatSectionId] || [];
@ -616,10 +653,6 @@ export function UniversalFormModalComponent({
} }
}); });
console.log("[UniversalFormModal] 공통 데이터:", commonData);
console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
// 메인 행 (공통 데이터 + 메인 섹션 필드) // 메인 행 (공통 데이터 + 메인 섹션 필드)
const mainRow: any = { ...commonData, ...mainSectionData }; const mainRow: any = { ...commonData, ...mainSectionData };
if (typeColumn) { if (typeColumn) {
@ -651,16 +684,20 @@ export function UniversalFormModalComponent({
if (section.repeatable) continue; if (section.repeatable) continue;
for (const field of section.fields) { for (const field of section.fields) {
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
const response = await generateNumberingCode(field.numberingRule.ruleId); // generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
if (response.success && response.data?.generatedCode) { const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우) if (shouldAllocate) {
if (commonFields.includes(field.columnName)) { const response = await allocateNumberingCode(field.numberingRule.ruleId);
rowsToSave.forEach((row) => { if (response.success && response.data?.generatedCode) {
row[field.columnName] = response.data?.generatedCode; // 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
}); if (commonFields.includes(field.columnName)) {
} else { rowsToSave.forEach((row) => {
rowsToSave[0][field.columnName] = response.data?.generatedCode; row[field.columnName] = response.data?.generatedCode;
});
} else {
rowsToSave[0][field.columnName] = response.data?.generatedCode;
}
} }
} }
} }
@ -668,16 +705,11 @@ export function UniversalFormModalComponent({
} }
// 모든 행 저장 // 모든 행 저장
console.log("[UniversalFormModal] 저장할 행들:", rowsToSave);
console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName);
for (let i = 0; i < rowsToSave.length; i++) { for (let i = 0; i < rowsToSave.length; i++) {
const row = rowsToSave[i]; const row = rowsToSave[i];
console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
// 빈 객체 체크 // 빈 객체 체크
if (Object.keys(row).length === 0) { if (Object.keys(row).length === 0) {
console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
continue; continue;
} }
@ -687,87 +719,168 @@ export function UniversalFormModalComponent({
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`); throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
} }
} }
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
}, [config.sections, config.saveConfig, formData, repeatSections]); }, [config.sections, config.saveConfig, formData, repeatSections]);
// 커스텀 API 저장 (사원+부서 통합 저장 등) // 다중 테이블 저장 (범용)
const saveWithCustomApi = useCallback(async () => { const saveWithMultiTable = useCallback(async () => {
const { customApiSave } = config.saveConfig; const { customApiSave } = config.saveConfig;
if (!customApiSave) return; if (!customApiSave?.multiTable) return;
console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType); const { multiTable } = customApiSave;
const saveUserWithDeptApi = async () => { // 1. 메인 테이블 데이터 구성
const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; const mainData: Record<string, any> = {};
config.sections.forEach((section) => {
// 1. userInfo 데이터 구성 if (section.repeatable) return; // 반복 섹션은 제외
const userInfo: Record<string, any> = {}; section.fields.forEach((field) => {
const value = formData[field.columnName];
// 모든 필드에서 user_info에 해당하는 데이터 추출 if (value !== undefined && value !== null && value !== "") {
config.sections.forEach((section) => { mainData[field.columnName] = value;
if (section.repeatable) return; // 반복 섹션은 제외 }
section.fields.forEach((field) => {
const value = formData[field.columnName];
if (value !== undefined && value !== null && value !== "") {
userInfo[field.columnName] = value;
}
});
}); });
});
// 2. mainDept 데이터 구성 // 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; for (const section of config.sections) {
if (section.repeatable) continue;
if (mainDeptFields) {
const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; for (const field of section.fields) {
if (deptCode) { // 채번규칙이 활성화된 필드 처리
mainDept = { if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
dept_code: deptCode, // 신규 생성이거나 값이 없는 경우에만 채번
dept_name: formData[mainDeptFields.deptNameField || "dept_name"], const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
position_name: formData[mainDeptFields.positionNameField || "position_name"], const hasNoValue = !mainData[field.columnName];
};
if (isNewRecord || hasNoValue) {
try {
// allocateNumberingCode로 실제 순번 증가
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
mainData[field.columnName] = response.data.generatedCode;
}
} catch (error) {
console.error(`채번규칙 할당 실패 (${field.columnName}):`, error);
}
}
}
}
}
// 2. 서브 테이블 데이터 구성
const subTablesData: Array<{
tableName: string;
linkColumn: { mainField: string; subColumn: string };
items: Record<string, any>[];
options?: {
saveMainAsFirst?: boolean;
mainFieldMappings?: Array<{ formField: string; targetColumn: string }>;
mainMarkerColumn?: string;
mainMarkerValue?: any;
subMarkerValue?: any;
deleteExistingBefore?: boolean;
};
}> = [];
for (const subTableConfig of multiTable.subTables || []) {
if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) {
continue;
}
const subItems: Record<string, any>[] = [];
const repeatData = repeatSections[subTableConfig.repeatSectionId] || [];
// 반복 섹션 데이터를 필드 매핑에 따라 변환
for (const item of repeatData) {
const mappedItem: Record<string, any> = {};
// 연결 컬럼 값 설정
if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) {
mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField];
}
// 필드 매핑에 따라 데이터 변환
for (const mapping of subTableConfig.fieldMappings || []) {
if (mapping.formField && mapping.targetColumn) {
mappedItem[mapping.targetColumn] = item[mapping.formField];
}
}
// 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값)
if (subTableConfig.options?.mainMarkerColumn) {
mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false;
}
if (Object.keys(mappedItem).length > 0) {
subItems.push(mappedItem);
} }
} }
// 3. subDepts 데이터 구성 (반복 섹션에서) // saveMainAsFirst가 활성화된 경우, 메인 데이터를 서브 테이블에 저장하기 위한 매핑 생성
const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined;
if (subTableConfig.options?.saveMainAsFirst) {
if (subDeptSectionId && repeatSections[subDeptSectionId]) { mainFieldMappings = [];
const subDeptItems = repeatSections[subDeptSectionId];
const deptCodeField = subDeptFields?.deptCodeField || "dept_code";
const deptNameField = subDeptFields?.deptNameField || "dept_name";
const positionNameField = subDeptFields?.positionNameField || "position_name";
subDeptItems.forEach((item) => { // 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑
const deptCode = item[deptCodeField]; // 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑
if (deptCode) { for (const mapping of subTableConfig.fieldMappings || []) {
subDepts.push({ if (mapping.targetColumn) {
dept_code: deptCode, // 메인 데이터에서 동일한 컬럼명이 있으면 매핑
dept_name: item[deptNameField], if (mainData[mapping.targetColumn] !== undefined) {
position_name: item[positionNameField], mainFieldMappings.push({
}); formField: mapping.targetColumn,
targetColumn: mapping.targetColumn,
});
}
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
else {
config.sections.forEach((section) => {
if (section.repeatable) return;
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn);
if (matchingField && mainData[matchingField.columnName] !== undefined) {
mainFieldMappings!.push({
formField: matchingField.columnName,
targetColumn: mapping.targetColumn,
});
}
});
}
} }
}); }
// 중복 제거
mainFieldMappings = mainFieldMappings.filter(
(m, idx, arr) => arr.findIndex((x) => x.targetColumn === m.targetColumn) === idx,
);
} }
// 4. API 호출 subTablesData.push({
console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts }); tableName: subTableConfig.tableName,
linkColumn: subTableConfig.linkColumn,
const { saveUserWithDept } = await import("@/lib/api/user"); items: subItems,
const response = await saveUserWithDept({ options: {
userInfo: userInfo as any, ...subTableConfig.options,
mainDept, mainFieldMappings, // 메인 데이터 매핑 추가
subDepts, },
isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드
}); });
}
if (!response.success) { // 3. 범용 다중 테이블 저장 API 호출
throw new Error(response.message || "사원 저장 실패"); const response = await apiClient.post("/table-management/multi-table-save", {
} mainTable: multiTable.mainTable,
mainData,
subTables: subTablesData,
isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn],
});
console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data); if (!response.data?.success) {
}; throw new Error(response.data?.message || "다중 테이블 저장 실패");
}
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
// 커스텀 API 저장
const saveWithCustomApi = useCallback(async () => {
const { customApiSave } = config.saveConfig;
if (!customApiSave) return;
const saveWithGenericCustomApi = async () => { const saveWithGenericCustomApi = async () => {
if (!customApiSave.customEndpoint) { if (!customApiSave.customEndpoint) {
@ -789,9 +902,10 @@ export function UniversalFormModalComponent({
} }
const method = customApiSave.customMethod || "POST"; const method = customApiSave.customMethod || "POST";
const response = method === "PUT" const response =
? await apiClient.put(customApiSave.customEndpoint, dataToSave) method === "PUT"
: await apiClient.post(customApiSave.customEndpoint, dataToSave); ? await apiClient.put(customApiSave.customEndpoint, dataToSave)
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
if (!response.data?.success) { if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패"); throw new Error(response.data?.message || "저장 실패");
@ -799,8 +913,8 @@ export function UniversalFormModalComponent({
}; };
switch (customApiSave.apiType) { switch (customApiSave.apiType) {
case "user-with-dept": case "multi-table":
await saveUserWithDeptApi(); await saveWithMultiTable();
break; break;
case "custom": case "custom":
await saveWithGenericCustomApi(); await saveWithGenericCustomApi();
@ -808,7 +922,7 @@ export function UniversalFormModalComponent({
default: default:
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`); throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
} }
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]); }, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
// 저장 처리 // 저장 처리
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
@ -869,7 +983,16 @@ export function UniversalFormModalComponent({
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]); }, [
config,
formData,
repeatSections,
onSave,
validateRequiredFields,
saveSingleRow,
saveMultipleRows,
saveWithCustomApi,
]);
// 폼 초기화 // 폼 초기화
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
@ -878,12 +1001,14 @@ export function UniversalFormModalComponent({
}, [initializeForm]); }, [initializeForm]);
// 필드 요소 렌더링 (입력 컴포넌트만) // 필드 요소 렌더링 (입력 컴포넌트만)
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
const renderFieldElement = ( const renderFieldElement = (
field: FormFieldConfig, field: FormFieldConfig,
value: any, value: any,
onChangeHandler: (value: any) => void, onChangeHandler: (value: any) => void,
fieldKey: string, fieldKey: string,
isDisabled: boolean, isDisabled: boolean,
repeatContext?: { sectionId: string; itemId: string },
) => { ) => {
return (() => { return (() => {
switch (field.fieldType) { switch (field.fieldType) {
@ -920,7 +1045,7 @@ export function UniversalFormModalComponent({
if (field.cascading?.enabled) { if (field.cascading?.enabled) {
const cascadingConfig = field.cascading; const cascadingConfig = field.cascading;
const parentValue = formData[cascadingConfig.parentField]; const parentValue = formData[cascadingConfig.parentField];
return ( return (
<CascadingSelectField <CascadingSelectField
fieldId={fieldKey} fieldId={fieldKey}
@ -933,10 +1058,15 @@ export function UniversalFormModalComponent({
/> />
); );
} }
// 다중 컬럼 저장이 활성화된 경우 // 다중 컬럼 저장이 활성화된 경우
const lfgMappings = field.linkedFieldGroup?.mappings; const lfgMappings = field.linkedFieldGroup?.mappings;
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) { if (
field.linkedFieldGroup?.enabled &&
field.linkedFieldGroup?.sourceTable &&
lfgMappings &&
lfgMappings.length > 0
) {
const lfg = field.linkedFieldGroup; const lfg = field.linkedFieldGroup;
const sourceTableName = lfg.sourceTable as string; const sourceTableName = lfg.sourceTable as string;
const cachedData = linkedFieldDataCache[sourceTableName]; const cachedData = linkedFieldDataCache[sourceTableName];
@ -980,11 +1110,24 @@ export function UniversalFormModalComponent({
lfg.mappings.forEach((mapping) => { lfg.mappings.forEach((mapping) => {
if (mapping.sourceColumn && mapping.targetColumn) { if (mapping.sourceColumn && mapping.targetColumn) {
const mappedValue = selectedRow[mapping.sourceColumn]; const mappedValue = selectedRow[mapping.sourceColumn];
// formData에 직접 저장
setFormData((prev) => ({ // 반복 섹션인 경우 repeatSections에 저장, 아니면 formData에 저장
...prev, if (repeatContext) {
[mapping.targetColumn]: mappedValue, setRepeatSections((prev) => {
})); const items = prev[repeatContext.sectionId] || [];
const newItems = items.map((item) =>
item._id === repeatContext.itemId
? { ...item, [mapping.targetColumn]: mappedValue }
: item,
);
return { ...prev, [repeatContext.sectionId]: newItems };
});
} else {
setFormData((prev) => ({
...prev,
[mapping.targetColumn]: mappedValue,
}));
}
} }
}); });
} }
@ -997,10 +1140,7 @@ export function UniversalFormModalComponent({
<SelectContent> <SelectContent>
{sourceData.length > 0 ? ( {sourceData.length > 0 ? (
sourceData.map((row, index) => ( sourceData.map((row, index) => (
<SelectItem <SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
key={`${row[valueColumn] || index}_${index}`}
value={String(row[valueColumn] || "")}
>
{getDisplayText(row)} {getDisplayText(row)}
</SelectItem> </SelectItem>
)) ))
@ -1127,12 +1267,14 @@ export function UniversalFormModalComponent({
}; };
// 필드 렌더링 (섹션 열 수 적용) // 필드 렌더링 (섹션 열 수 적용)
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
const renderFieldWithColumns = ( const renderFieldWithColumns = (
field: FormFieldConfig, field: FormFieldConfig,
value: any, value: any,
onChangeHandler: (value: any) => void, onChangeHandler: (value: any) => void,
fieldKey: string, fieldKey: string,
sectionColumns: number = 2, sectionColumns: number = 2,
repeatContext?: { sectionId: string; itemId: string },
) => { ) => {
// 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선) // 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선)
const defaultSpan = getDefaultGridSpan(sectionColumns); const defaultSpan = getDefaultGridSpan(sectionColumns);
@ -1146,7 +1288,7 @@ export function UniversalFormModalComponent({
return null; return null;
} }
const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled); const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled, repeatContext);
if (field.fieldType === "checkbox") { if (field.fieldType === "checkbox") {
return ( return (
@ -1286,6 +1428,7 @@ export function UniversalFormModalComponent({
(value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value), (value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value),
`${section.id}-${item._id}-${field.id}`, `${section.id}-${item._id}-${field.id}`,
sectionColumns, sectionColumns,
{ sectionId: section.id, itemId: item._id }, // 반복 섹션 컨텍스트 전달
), ),
)} )}
</div> </div>

View File

@ -123,6 +123,7 @@ export interface FormFieldConfig {
// 연동 필드 매핑 설정 // 연동 필드 매핑 설정
export interface LinkedFieldMapping { export interface LinkedFieldMapping {
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code") sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
targetTable?: string; // 저장할 테이블 (선택, 없으면 자동 결정)
targetColumn: string; // 저장할 컬럼 (예: "position_code") targetColumn: string; // 저장할 컬럼 (예: "position_code")
} }
@ -209,42 +210,92 @@ export interface SaveConfig {
}; };
} }
/**
*
* (columnName) .
*/
export interface SubTableFieldMapping {
formField: string; // 폼 필드의 columnName
targetColumn: string; // 서브 테이블의 컬럼명
}
/**
*
* .
*/
export interface SubTableSaveConfig {
enabled: boolean;
tableName: string; // 서브 테이블명 (예: user_dept, order_items)
repeatSectionId: string; // 연결할 반복 섹션 ID
// 연결 설정 (메인 테이블과 서브 테이블 연결)
linkColumn: {
mainField: string; // 메인 테이블의 연결 필드 (예: user_id)
subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id)
};
// 필드 매핑 (반복 섹션 필드 → 서브 테이블 컬럼)
fieldMappings: SubTableFieldMapping[];
// 추가 옵션
options?: {
// 메인 데이터도 서브 테이블에 저장 (1:N에서 메인도 저장할 때)
saveMainAsFirst?: boolean;
mainFieldMappings?: SubTableFieldMapping[]; // 메인 데이터용 필드 매핑
mainMarkerColumn?: string; // 메인 여부 표시 컬럼 (예: is_primary)
mainMarkerValue?: any; // 메인일 때 값 (예: true)
subMarkerValue?: any; // 서브일 때 값 (예: false)
// 저장 전 기존 데이터 삭제
deleteExistingBefore?: boolean;
deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제
};
}
/**
* ()
*
* + () .
*
* ##
*
* ### + (user_info + user_dept)
* - 테이블: user_info ( )
* - 테이블: user_dept ( , + )
*
* ### + (orders + order_items)
* - 테이블: orders ( )
* - 테이블: order_items ( )
*/
export interface MultiTableSaveConfig {
enabled: boolean;
// 메인 테이블 설정
mainTable: {
tableName: string; // 메인 테이블명
primaryKeyColumn: string; // PK 컬럼명
};
// 서브 테이블 설정 (여러 개 가능)
subTables: SubTableSaveConfig[];
}
/** /**
* API * API
* *
* API를 . * API를 .
* ( , ) . * ( , ) .
*
* ## API
* - `user-with-dept`: + (/api/admin/users/with-dept)
*
* ##
* - `userInfoFields`: user_info
* - `mainDeptFields`:
* - `subDeptSectionId`: ID
*/ */
export interface CustomApiSaveConfig { export interface CustomApiSaveConfig {
enabled: boolean; enabled: boolean;
apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입 apiType: "multi-table" | "custom"; // API 타입
// user-with-dept 전용 설정 // 다중 테이블 저장 설정 (범용)
userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName) multiTable?: MultiTableSaveConfig;
mainDeptFields?: {
deptCodeField?: string; // 메인 부서코드 필드명
deptNameField?: string; // 메인 부서명 필드명
positionNameField?: string; // 메인 직급 필드명
};
subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID
subDeptFields?: {
deptCodeField?: string; // 겸직 부서코드 필드명
deptNameField?: string; // 겸직 부서명 필드명
positionNameField?: string; // 겸직 직급 필드명
};
// 커스텀 API 전용 설정 // 커스텀 API 전용 설정
customEndpoint?: string; // 커스텀 API 엔드포인트 customEndpoint?: string; // 커스텀 API 엔드포인트
customMethod?: "POST" | "PUT"; // HTTP 메서드 customMethod?: "POST" | "PUT"; // HTTP 메서드
customDataTransform?: string; // 데이터 변환 함수명 (추후 확장)
} }
// 모달 설정 // 모달 설정

View File

@ -3836,6 +3836,7 @@ export class ButtonActionExecutor {
const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context);
if (keyValue) { if (keyValue) {
// 상태 업데이트
await apiClient.put("/dynamic-form/update-field", { await apiClient.put("/dynamic-form/update-field", {
tableName: statusTableName, tableName: statusTableName,
keyField: keyField, keyField: keyField,
@ -3844,6 +3845,37 @@ export class ButtonActionExecutor {
updateValue: config.trackingStatusOnStart, updateValue: config.trackingStatusOnStart,
}); });
console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart); console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart);
// 🆕 출발지/도착지도 vehicles 테이블에 저장
if (departure) {
try {
await apiClient.put("/dynamic-form/update-field", {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: "departure",
updateValue: departure,
});
console.log("✅ 출발지 저장 완료:", departure);
} catch {
// 컬럼이 없으면 무시
}
}
if (arrival) {
try {
await apiClient.put("/dynamic-form/update-field", {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: "arrival",
updateValue: arrival,
});
console.log("✅ 도착지 저장 완료:", arrival);
} catch {
// 컬럼이 없으면 무시
}
}
} }
} catch (statusError) { } catch (statusError) {
console.warn("⚠️ 상태 변경 실패:", statusError); console.warn("⚠️ 상태 변경 실패:", statusError);
@ -4050,6 +4082,23 @@ export class ButtonActionExecutor {
updateValue: effectiveConfig.trackingStatusOnStop, updateValue: effectiveConfig.trackingStatusOnStop,
}); });
console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop); console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop);
// 🆕 운행 종료 시 vehicles 테이블의 출발지/도착지/위도/경도를 null로 초기화
const fieldsToReset = ["departure", "arrival", "latitude", "longitude"];
for (const field of fieldsToReset) {
try {
await apiClient.put("/dynamic-form/update-field", {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: field,
updateValue: null,
});
} catch {
// 컬럼이 없으면 무시
}
}
console.log("✅ 출발지/도착지/위도/경도 초기화 완료");
} }
} catch (statusError) { } catch (statusError) {
console.warn("⚠️ 상태 변경 실패:", statusError); console.warn("⚠️ 상태 변경 실패:", statusError);