diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 97cd2cc1..98606f51 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -427,7 +427,8 @@ export const updateFieldValue = async ( ): Promise => { try { const { companyCode, userId } = req.user as any; - const { tableName, keyField, keyValue, updateField, updateValue } = req.body; + const { tableName, keyField, keyValue, updateField, updateValue } = + req.body; console.log("๐Ÿ”„ [updateFieldValue] ์š”์ฒญ:", { tableName, @@ -440,16 +441,27 @@ export const updateFieldValue = async ( }); // ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆ - if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { + if ( + !tableName || + !keyField || + keyValue === undefined || + !updateField || + updateValue === undefined + ) { return res.status(400).json({ success: false, - message: "ํ•„์ˆ˜ ํ•„๋“œ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (tableName, keyField, keyValue, updateField, updateValue)", + message: + "ํ•„์ˆ˜ ํ•„๋“œ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (tableName, keyField, keyValue, updateField, updateValue)", }); } // SQL ์ธ์ ์…˜ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ํ…Œ์ด๋ธ”๋ช…/์ปฌ๋Ÿผ๋ช… ๊ฒ€์ฆ const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { + if ( + !validNamePattern.test(tableName) || + !validNamePattern.test(keyField) || + !validNamePattern.test(updateField) + ) { return res.status(400).json({ success: false, message: "์œ ํšจํ•˜์ง€ ์•Š์€ ํ…Œ์ด๋ธ”๋ช… ๋˜๋Š” ์ปฌ๋Ÿผ๋ช…์ž…๋‹ˆ๋‹ค.", diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 9459e1f6..393b33cc 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -837,4 +837,53 @@ export class FlowController { }); } }; + + /** + * ์Šคํ… ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ (์ธ๋ผ์ธ ํŽธ์ง‘) + */ + updateStepData = async (req: Request, res: Response): Promise => { + 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", + }); + } + }; } diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 4a80b007..2dfe0770 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1811,3 +1811,333 @@ export async function getCategoryColumnsByMenu( }); } } + +/** + * ๋ฒ”์šฉ ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ API + * + * ๋ฉ”์ธ ํ…Œ์ด๋ธ”๊ณผ ์„œ๋ธŒ ํ…Œ์ด๋ธ”(๋“ค)์— ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + * + * ์š”์ฒญ ๋ณธ๋ฌธ: + * { + * mainTable: { tableName: string, primaryKeyColumn: string }, + * mainData: Record, + * subTables: Array<{ + * tableName: string, + * linkColumn: { mainField: string, subColumn: string }, + * items: Record[], + * 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 { + 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 = { + [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(); + } +} diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 5816fb8e..e33afac2 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts); router.post("/move", flowController.moveData); router.post("/move-batch", flowController.moveBatchData); +// ==================== ์Šคํ… ๋ฐ์ดํ„ฐ ์ˆ˜์ • (์ธ๋ผ์ธ ํŽธ์ง‘) ==================== +router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData); + // ==================== ์˜ค๋”ง ๋กœ๊ทธ ==================== router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5ea98489..d0716d59 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -24,6 +24,7 @@ import { getLogData, toggleLogTable, getCategoryColumnsByMenu, // ๐Ÿ†• ๋ฉ”๋‰ด๋ณ„ ์นดํ…Œ๊ณ ๋ฆฌ ์ปฌ๋Ÿผ ์กฐํšŒ + multiTableSave, // ๐Ÿ†• ๋ฒ”์šฉ ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ } from "../controllers/tableManagementController"; const router = express.Router(); @@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable); */ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); +// ======================================== +// ๋ฒ”์šฉ ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ API +// ======================================== + +/** + * ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ (๋ฉ”์ธ + ์„œ๋ธŒ ํ…Œ์ด๋ธ”) + * POST /api/table-management/multi-table-save + * + * ๋ฉ”์ธ ํ…Œ์ด๋ธ”๊ณผ ์„œ๋ธŒ ํ…Œ์ด๋ธ”(๋“ค)์— ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + * ์‚ฌ์›+๋ถ€์„œ, ์ฃผ๋ฌธ+์ฃผ๋ฌธ์ƒ์„ธ ๋“ฑ 1:N ๊ด€๊ณ„ ๋ฐ์ดํ„ฐ ์ €์žฅ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. + */ +router.post("/multi-table-save", multiTableSave); + export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 743c0386..f6fe56a1 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -65,12 +65,18 @@ export class BatchSchedulerService { `๋ฐฐ์น˜ ์Šค์ผ€์ค„ ๋“ฑ๋ก: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` ); - const task = cron.schedule(config.cron_schedule, async () => { - logger.info( - `์Šค์ผ€์ค„์— ์˜ํ•œ ๋ฐฐ์น˜ ์‹คํ–‰ ์‹œ์ž‘: ${config.batch_name} (ID: ${config.id})` - ); - await this.executeBatchConfig(config); - }); + const task = cron.schedule( + config.cron_schedule, + async () => { + logger.info( + `์Šค์ผ€์ค„์— ์˜ํ•œ ๋ฐฐ์น˜ ์‹คํ–‰ ์‹œ์ž‘: ${config.batch_name} (ID: ${config.id})` + ); + await this.executeBatchConfig(config); + }, + { + timezone: "Asia/Seoul", // ํ•œ๊ตญ ์‹œ๊ฐ„ ๊ธฐ์ค€์œผ๋กœ ์Šค์ผ€์ค„ ์‹คํ–‰ + } + ); this.scheduledTasks.set(config.id, task); } catch (error) { diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 39ab6013..09058502 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -72,6 +72,11 @@ export class FlowDataMoveService { // ๋‚ด๋ถ€ DB ์ฒ˜๋ฆฌ (๊ธฐ์กด ๋กœ์ง) return await db.transaction(async (client) => { try { + // ํŠธ๋žœ์žญ์…˜ ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • (ํŠธ๋ฆฌ๊ฑฐ์—์„œ changed_by ๊ธฐ๋ก์šฉ) + await client.query("SELECT set_config('app.user_id', $1, true)", [ + userId || "system", + ]); + // 1. ๋‹จ๊ณ„ ์ •๋ณด ์กฐํšŒ const fromStep = await this.flowStepService.findById(fromStepId); const toStep = await this.flowStepService.findById(toStepId); @@ -684,6 +689,14 @@ export class FlowDataMoveService { dbConnectionId, async (externalClient, dbType) => { try { + // ์™ธ๋ถ€ DB๊ฐ€ PostgreSQL์ธ ๊ฒฝ์šฐ์—๋งŒ ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • ์‹œ๋„ + if (dbType.toLowerCase() === "postgresql") { + await externalClient.query( + "SELECT set_config('app.user_id', $1, true)", + [userId || "system"] + ); + } + // 1. ๋‹จ๊ณ„ ์ •๋ณด ์กฐํšŒ (๋‚ด๋ถ€ DB์—์„œ) const fromStep = await this.flowStepService.findById(fromStepId); const toStep = await this.flowStepService.findById(toStepId); diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 966842b8..bbabb935 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -263,4 +263,139 @@ export class FlowExecutionService { tableName: result[0].table_name, }; } + + /** + * ์Šคํ… ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ (์ธ๋ผ์ธ ํŽธ์ง‘) + * ์›๋ณธ ํ…Œ์ด๋ธ”์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ง์ ‘ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค. + */ + async updateStepData( + flowId: number, + stepId: number, + recordId: string, + updateData: Record, + 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; + } + } } diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 7901702a..a7333af4 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -191,6 +191,12 @@ export class NodeFlowExecutionService { try { result = await transaction(async (client) => { + // ๐Ÿ”ฅ ์‚ฌ์šฉ์ž ID ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • (ํŠธ๋ฆฌ๊ฑฐ์šฉ) + const userId = context.buttonContext?.userId || "system"; + await client.query("SELECT set_config('app.user_id', $1, true)", [ + userId, + ]); + // ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ๋ ˆ๋ฒจ๋ณ„ ์‹คํ–‰ for (const level of levels) { await this.executeLevel(level, nodes, edges, context, client); diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 83b4f63b..5272547a 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -897,13 +897,13 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { // ์ˆœ๋ฒˆ (ํ˜„์žฌ ์ˆœ๋ฒˆ์œผ๋กœ ๋ฏธ๋ฆฌ๋ณด๊ธฐ, ์ฆ๊ฐ€ ์•ˆ ํ•จ) - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; return String(rule.currentSequence || 1).padStart(length, "0"); } case "number": { // ์ˆซ์ž (๊ณ ์ • ์ž๋ฆฟ์ˆ˜) - const length = autoConfig.numberLength || 4; + const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } @@ -957,13 +957,13 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { // ์ˆœ๋ฒˆ (์ž๋™ ์ฆ๊ฐ€ ์ˆซ์ž) - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; return String(rule.currentSequence || 1).padStart(length, "0"); } case "number": { // ์ˆซ์ž (๊ณ ์ • ์ž๋ฆฟ์ˆ˜) - const length = autoConfig.numberLength || 4; + const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index c93c117a..f8d5d8d6 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -1,6 +1,4 @@ -import { - Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package -} from "lucide-react"; +import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react"; import Link from "next/link"; import { GlobalFileViewer } from "@/components/GlobalFileViewer"; @@ -9,208 +7,206 @@ import { GlobalFileViewer } from "@/components/GlobalFileViewer"; */ export default function AdminPage() { return ( -
-
- - {/* ์ฃผ์š” ๊ด€๋ฆฌ ๊ธฐ๋Šฅ */} -
-
-

์ฃผ์š” ๊ด€๋ฆฌ ๊ธฐ๋Šฅ

-

์‹œ์Šคํ…œ์˜ ํ•ต์‹ฌ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ๋“ค์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค

-
-
- -
-
-
- -
-
-

์‚ฌ์šฉ์ž ๊ด€๋ฆฌ

-

์‚ฌ์šฉ์ž ๊ณ„์ • ๋ฐ ๊ถŒํ•œ ๊ด€๋ฆฌ

-
-
+
+
+ {/* ์ฃผ์š” ๊ด€๋ฆฌ ๊ธฐ๋Šฅ */} +
+
+

์ฃผ์š” ๊ด€๋ฆฌ ๊ธฐ๋Šฅ

+

์‹œ์Šคํ…œ์˜ ํ•ต์‹ฌ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ๋“ค์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค

- +
+ +
+
+
+ +
+
+

์‚ฌ์šฉ์ž ๊ด€๋ฆฌ

+

์‚ฌ์šฉ์ž ๊ณ„์ • ๋ฐ ๊ถŒํ•œ ๊ด€๋ฆฌ

+
+
+
+ -
-
-
- + {/*
+
+
+ +
+
+

๊ถŒํ•œ ๊ด€๋ฆฌ

+

๋ฉ”๋‰ด ๋ฐ ๊ธฐ๋Šฅ ๊ถŒํ•œ ์„ค์ •

+
+
-
-

๊ถŒํ•œ ๊ด€๋ฆฌ

-

๋ฉ”๋‰ด ๋ฐ ๊ธฐ๋Šฅ ๊ถŒํ•œ ์„ค์ •

+ +
+
+
+ +
+
+

์‹œ์Šคํ…œ ์„ค์ •

+

๊ธฐ๋ณธ ์„ค์ • ๋ฐ ํ™˜๊ฒฝ ๊ตฌ์„ฑ

+
+
+ +
+
+
+ +
+
+

ํ†ต๊ณ„ ๋ฐ ๋ฆฌํฌํŠธ

+

์‹œ์Šคํ…œ ์‚ฌ์šฉ ํ˜„ํ™ฉ ๋ถ„์„

+
+
+
*/} + + +
+
+
+ +
+
+

ํ™”๋ฉด๊ด€๋ฆฌ

+

๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ์œผ๋กœ ํ™”๋ฉด ์„ค๊ณ„ ๋ฐ ๊ด€๋ฆฌ

+
+
+
+
-
-
-
- -
-
-

์‹œ์Šคํ…œ ์„ค์ •

-

๊ธฐ๋ณธ ์„ค์ • ๋ฐ ํ™˜๊ฒฝ ๊ตฌ์„ฑ

-
+ {/* ํ‘œ์ค€ ๊ด€๋ฆฌ ์„น์…˜ */} +
+
+

ํ‘œ์ค€ ๊ด€๋ฆฌ

+

์‹œ์Šคํ…œ ํ‘œ์ค€ ๋ฐ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ†ตํ•ฉ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค

+
+
+ {/* +
+
+
+ +
+
+

์›นํƒ€์ž… ๊ด€๋ฆฌ

+

์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ์›นํƒ€์ž… ํ‘œ์ค€ ๊ด€๋ฆฌ

+
+
+
+ + + +
+
+
+ +
+
+

ํ…œํ”Œ๋ฆฟ ๊ด€๋ฆฌ

+

ํ™”๋ฉด ๋””์ž์ด๋„ˆ ํ…œํ”Œ๋ฆฟ ํ‘œ์ค€ ๊ด€๋ฆฌ

+
+
+
+ */} + + +
+
+
+ +
+
+

ํ…Œ์ด๋ธ” ๊ด€๋ฆฌ

+

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ” ๋ฐ ์›นํƒ€์ž… ๋งคํ•‘

+
+
+
+ + + {/* +
+
+
+ +
+
+

์ปดํฌ๋„ŒํŠธ ๊ด€๋ฆฌ

+

ํ™”๋ฉด ๋””์ž์ด๋„ˆ ์ปดํฌ๋„ŒํŠธ ํ‘œ์ค€ ๊ด€๋ฆฌ

+
+
+
+ */}
-
-
-
- -
-
-

ํ†ต๊ณ„ ๋ฐ ๋ฆฌํฌํŠธ

-

์‹œ์Šคํ…œ ์‚ฌ์šฉ ํ˜„ํ™ฉ ๋ถ„์„

-
+ {/* ๋น ๋ฅธ ์•ก์„ธ์Šค */} +
+
+

๋น ๋ฅธ ์•ก์„ธ์Šค

+

์ž์ฃผ ์‚ฌ์šฉํ•˜๋Š” ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์— ๋น ๋ฅด๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

+
+
+ +
+
+
+ +
+
+

๋ฉ”๋‰ด ๊ด€๋ฆฌ

+

์‹œ์Šคํ…œ ๋ฉ”๋‰ด ๋ฐ ๋„ค๋น„๊ฒŒ์ด์…˜ ์„ค์ •

+
+
+
+ + + +
+
+
+ +
+
+

์™ธ๋ถ€ ์—ฐ๊ฒฐ ๊ด€๋ฆฌ

+

์™ธ๋ถ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ค์ •

+
+
+
+ + + +
+
+
+ +
+
+

๊ณตํ†ต ์ฝ”๋“œ ๊ด€๋ฆฌ

+

์‹œ์Šคํ…œ ๊ณตํ†ต ์ฝ”๋“œ ๋ฐ ์„ค์ •

+
+
+
+
- -
-
-
- -
-
-

ํ™”๋ฉด๊ด€๋ฆฌ

-

๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ์œผ๋กœ ํ™”๋ฉด ์„ค๊ณ„ ๋ฐ ๊ด€๋ฆฌ

-
-
+ {/* ์ „์—ญ ํŒŒ์ผ ๊ด€๋ฆฌ */} +
+
+

์ „์—ญ ํŒŒ์ผ ๊ด€๋ฆฌ

+

๋ชจ๋“  ํŽ˜์ด์ง€์—์„œ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ๋“ค์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค

- +
- - {/* ํ‘œ์ค€ ๊ด€๋ฆฌ ์„น์…˜ */} -
-
-

ํ‘œ์ค€ ๊ด€๋ฆฌ

-

์‹œ์Šคํ…œ ํ‘œ์ค€ ๋ฐ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ†ตํ•ฉ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค

-
-
- -
-
-
- -
-
-

์›นํƒ€์ž… ๊ด€๋ฆฌ

-

์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ์›นํƒ€์ž… ํ‘œ์ค€ ๊ด€๋ฆฌ

-
-
-
- - - -
-
-
- -
-
-

ํ…œํ”Œ๋ฆฟ ๊ด€๋ฆฌ

-

ํ™”๋ฉด ๋””์ž์ด๋„ˆ ํ…œํ”Œ๋ฆฟ ํ‘œ์ค€ ๊ด€๋ฆฌ

-
-
-
- - - -
-
-
- -
-
-

ํ…Œ์ด๋ธ” ๊ด€๋ฆฌ

-

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ” ๋ฐ ์›นํƒ€์ž… ๋งคํ•‘

-
-
-
- - - -
-
-
- -
-
-

์ปดํฌ๋„ŒํŠธ ๊ด€๋ฆฌ

-

ํ™”๋ฉด ๋””์ž์ด๋„ˆ ์ปดํฌ๋„ŒํŠธ ํ‘œ์ค€ ๊ด€๋ฆฌ

-
-
-
- -
-
- - {/* ๋น ๋ฅธ ์•ก์„ธ์Šค */} -
-
-

๋น ๋ฅธ ์•ก์„ธ์Šค

-

์ž์ฃผ ์‚ฌ์šฉํ•˜๋Š” ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์— ๋น ๋ฅด๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

-
-
- -
-
-
- -
-
-

๋ฉ”๋‰ด ๊ด€๋ฆฌ

-

์‹œ์Šคํ…œ ๋ฉ”๋‰ด ๋ฐ ๋„ค๋น„๊ฒŒ์ด์…˜ ์„ค์ •

-
-
-
- - - -
-
-
- -
-
-

์™ธ๋ถ€ ์—ฐ๊ฒฐ ๊ด€๋ฆฌ

-

์™ธ๋ถ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ค์ •

-
-
-
- - - -
-
-
- -
-
-

๊ณตํ†ต ์ฝ”๋“œ ๊ด€๋ฆฌ

-

์‹œ์Šคํ…œ ๊ณตํ†ต ์ฝ”๋“œ ๋ฐ ์„ค์ •

-
-
-
- -
-
- - {/* ์ „์—ญ ํŒŒ์ผ ๊ด€๋ฆฌ */} -
-
-

์ „์—ญ ํŒŒ์ผ ๊ด€๋ฆฌ

-

๋ชจ๋“  ํŽ˜์ด์ง€์—์„œ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ๋“ค์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค

-
- -
- -
); } diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 0a9cecd0..4c60c5af 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -276,12 +276,12 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: return ( - - + + {connection ? "REST API ์—ฐ๊ฒฐ ์ˆ˜์ •" : "์ƒˆ REST API ์—ฐ๊ฒฐ ์ถ”๊ฐ€"} -
+
{/* ๊ธฐ๋ณธ ์ •๋ณด */}

๊ธฐ๋ณธ ์ •๋ณด

@@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
- + + + +
+ ์ปฌ๋Ÿผ ์„ ํƒ + +
+
+ {/* ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ ์ปฌ๋Ÿผ ๋ชฉ๋ก */} + {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 ( +
{ + 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 }, + }); + }} + > + + {col} +
+ ); + })} + {(!queryResult?.columns || queryResult.columns.length === 0) && ( +

+ ์ฟผ๋ฆฌ๋ฅผ ๋จผ์ € ์‹คํ–‰ํ•ด์ฃผ์„ธ์š” +

+ )} +
+
+ +

๋น„์›Œ๋‘๋ฉด ๋ชจ๋“  ์ปฌ๋Ÿผ์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค

+ + {/* ์„ ํƒ๋œ ์ปฌ๋Ÿผ ๋ผ๋ฒจ ํŽธ์ง‘ */} + {(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && ( +
+ +
+ {popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return ( +
+ + {column} + + { + 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" + /> + +
+ ); + })} +
+
+ )} +
+
+ )} +
+ + {/* ํ•„๋“œ ๊ทธ๋ฃน ์„ค์ • */} +
+
+ + +
+

์„ค์ •ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ชจ๋“  ํ•„๋“œ๊ฐ€ ์ž๋™์œผ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

+ + {/* ํ•„๋“œ ๊ทธ๋ฃน ๋ชฉ๋ก */} + {(popupConfig.fieldGroups || []).map((group) => ( +
+ {/* ๊ทธ๋ฃน ํ—ค๋” */} +
+ + +
+ + {/* ๊ทธ๋ฃน ์ƒ์„ธ (ํ™•์žฅ ์‹œ) */} + {expandedGroups[group.id] && ( +
+ {/* ๊ทธ๋ฃน ์ œ๋ชฉ */} +
+
+ + updateFieldGroup(group.id, { title: e.target.value })} + className="mt-1 h-7 text-xs" + /> +
+
+ + +
+
+ + {/* ์•„์ด์ฝ˜ */} +
+ + +
+ + {/* ํ•„๋“œ ๋ชฉ๋ก */} +
+
+ + +
+ + {group.fields.map((field, fieldIndex) => ( +
+ updateField(group.id, fieldIndex, { column: e.target.value })} + placeholder="์ปฌ๋Ÿผ๋ช…" + className="h-6 flex-1 text-xs" + /> + updateField(group.id, fieldIndex, { label: e.target.value })} + placeholder="๋ผ๋ฒจ" + className="h-6 flex-1 text-xs" + /> + + +
+ ))} +
+
+ )} +
+ ))} +
+
+ )} +
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 8193aea4..2e69f72d 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -1,11 +1,20 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { DashboardElement, QueryResult, ListWidgetConfig } from "../types"; +import React, { useState, useEffect, useCallback } from "react"; +import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; +import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; @@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); + // ํ–‰ ์ƒ์„ธ ํŒ์—… ์ƒํƒœ + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [detailPopupData, setDetailPopupData] = useState | null>(null); + const [detailPopupLoading, setDetailPopupLoading] = useState(false); + const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { cardColumns: 3, }; + // ํ–‰ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ - ํŒ์—… ์—ด๊ธฐ + const handleRowClick = useCallback( + async (row: Record) => { + // ํŒ์—…์ด ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์œผ๋ฉด ๋ฌด์‹œ + 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 ; + case "clock": + return ; + case "map": + return ; + case "package": + return ; + default: + return ; + } + }; + + // ํ•„๋“œ ๊ทธ๋ฃน ๋ Œ๋”๋ง + const renderFieldGroup = (group: FieldGroup, data: Record) => { + 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 ( +
+
+ {renderIcon(group.icon, group.color)} + {group.title} +
+
+ {group.fields.map((field) => ( +
+ + {field.label} + + {formatValue(data[field.column], field.format)} +
+ ))} +
+
+ ); + }; + + // ๊ธฐ๋ณธ ํ•„๋“œ ๊ทธ๋ฃน ์ƒ์„ฑ (์„ค์ •์ด ์—†์„ ๊ฒฝ์šฐ) + const getDefaultFieldGroups = (row: Record, additional: Record | null): FieldGroup[] => { + const groups: FieldGroup[] = []; + const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; + + // ๊ธฐ๋ณธ ์ •๋ณด ๊ทธ๋ฃน - displayColumns๊ฐ€ ์žˆ์œผ๋ฉด ํ•ด๋‹น ์ปฌ๋Ÿผ๋งŒ, ์—†์œผ๋ฉด ์ „์ฒด + let basicFields: { column: string; label: string }[] = []; + + if (displayColumns && displayColumns.length > 0) { + // DisplayColumnConfig ํ˜•์‹ ์ง€์› + basicFields = displayColumns + .map((colConfig) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return { column, label }; + }) + .filter((item) => item.column in row); + } else { + // ์ „์ฒด ์ปฌ๋Ÿผ + basicFields = Object.keys(row).map((key) => ({ column: key, label: key })); + } + + groups.push({ + id: "basic", + title: "๊ธฐ๋ณธ ์ •๋ณด", + icon: "info", + color: "gray", + fields: basicFields.map((item) => ({ + column: item.column, + label: item.label, + format: "text", + })), + }); + + // ์ถ”๊ฐ€ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๊ณ  vehicles ํ…Œ์ด๋ธ”์ธ ๊ฒฝ์šฐ ์šดํ–‰/๊ณต์ฐจ ์ •๋ณด ์ถ”๊ฐ€ + if (additional && Object.keys(additional).length > 0) { + // ์šดํ–‰ ์ •๋ณด + if (additional.last_trip_start || additional.last_trip_end) { + groups.push({ + id: "trip", + title: "์šดํ–‰ ์ •๋ณด", + icon: "truck", + color: "blue", + fields: [ + { column: "last_trip_start", label: "์‹œ์ž‘", format: "datetime" }, + { column: "last_trip_end", label: "์ข…๋ฃŒ", format: "datetime" }, + { column: "last_trip_distance", label: "๊ฑฐ๋ฆฌ", format: "distance" }, + { column: "last_trip_time", label: "์‹œ๊ฐ„", format: "duration" }, + { column: "departure", label: "์ถœ๋ฐœ์ง€", format: "text" }, + { column: "arrival", label: "๋„์ฐฉ์ง€", format: "text" }, + ], + }); + } + + // ๊ณต์ฐจ ์ •๋ณด + if (additional.last_empty_start) { + groups.push({ + id: "empty", + title: "๊ณต์ฐจ ์ •๋ณด", + icon: "package", + color: "orange", + fields: [ + { column: "last_empty_start", label: "์‹œ์ž‘", format: "datetime" }, + { column: "last_empty_end", label: "์ข…๋ฃŒ", format: "datetime" }, + { column: "last_empty_distance", label: "๊ฑฐ๋ฆฌ", format: "distance" }, + { column: "last_empty_time", label: "์‹œ๊ฐ„", format: "duration" }, + ], + }); + } + } + + return groups; + }; + // ๋ฐ์ดํ„ฐ ๋กœ๋“œ useEffect(() => { const loadData = async () => { @@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { ) : ( paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns .filter((col) => col.visible) .map((col) => ( @@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { }} > {paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + >
{displayColumns .filter((col) => col.visible) @@ -345,6 +577,49 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
)} + + {/* ํ–‰ ์ƒ์„ธ ํŒ์—… */} + + + + {config.rowDetailPopup?.title || "์ƒ์„ธ ์ •๋ณด"} + + {detailPopupLoading + ? "์ถ”๊ฐ€ ์ •๋ณด๋ฅผ ๋กœ๋”ฉ ์ค‘์ž…๋‹ˆ๋‹ค..." + : detailPopupData + ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` + : "์„ ํƒ๋œ ํ•ญ๋ชฉ์˜ ์ƒ์„ธ ์ •๋ณด์ž…๋‹ˆ๋‹ค."} + + + + {detailPopupLoading ? ( +
+
+
+ ) : ( +
+ {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 }), + )} + + )} +
+ )} + + + + + +
); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index a3b29042..ad8add20 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -526,7 +526,8 @@ function MaterialBox({ case "location-temp": case "location-dest": // ๋ฒ ๋“œ ํƒ€์ž… Location: ํšŒ์ƒ‰ ์ฒ ํŒ๋“ค์ด ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜๋งŒํผ ์Œ“์ด๋Š” ํ˜•ํƒœ - const locPlateCount = placement.material_count || placement.quantity || 5; // ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜ + // ์ž์žฌ๊ฐ€ ์—†์œผ๋ฉด 0, ์žˆ์œผ๋ฉด ํ•ด๋‹น ๊ฐœ์ˆ˜ ํ‘œ์‹œ (๊ธฐ๋ณธ๊ฐ’ 5 ์ œ๊ฑฐ) + const locPlateCount = placement.material_count ?? placement.quantity ?? 0; const locVisiblePlateCount = locPlateCount; // ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜๋งŒํผ ๋ชจ๋‘ ๋ Œ๋”๋ง const locPlateThickness = 0.15; // ๊ฐ ์ฒ ํŒ ๋‘๊ป˜ const locPlateGap = 0.03; // ์ฒ ํŒ ์‚ฌ์ด ๋ฏธ์„ธํ•œ ๊ฐ„๊ฒฉ @@ -538,8 +539,32 @@ function MaterialBox({ return ( <> - {/* ์ฒ ํŒ ์Šคํƒ - ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜๋งŒํผ ํšŒ์ƒ‰ ํŒ ์Œ“๊ธฐ (์ตœ๋Œ€ 20๊ฐœ) */} - {Array.from({ length: locVisiblePlateCount }).map((_, idx) => { + {/* ์ž์žฌ๊ฐ€ ์—†์„ ๋•Œ: ํฐ์ƒ‰ ์‹ค์„  ํ…Œ๋‘๋ฆฌ ๋ฐ”๋‹ฅํŒ */} + {locPlateCount === 0 && ( + <> + {/* ์–‡์€ ํฐ์ƒ‰ ๋ฐ”๋‹ฅํŒ */} + + + + {/* ํฐ์ƒ‰ ์‹ค์„  ํ…Œ๋‘๋ฆฌ */} + + + + + + )} + + {/* ์ฒ ํŒ ์Šคํƒ - ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜๋งŒํผ ํšŒ์ƒ‰ ํŒ ์Œ“๊ธฐ */} + {locPlateCount > 0 && Array.from({ length: locVisiblePlateCount }).map((_, idx) => { const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap); // ์•ฝ๊ฐ„์˜ ๋žœ๋ค ์˜คํ”„์…‹์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฌ์›€ ์ถ”๊ฐ€ const xOffset = (Math.sin(idx * 0.5) * 0.02); @@ -570,7 +595,7 @@ function MaterialBox({ {/* Location ์ด๋ฆ„ - ์‹ค์ œ ํด๋ฆฌ๊ณค ๋†’์ด ๊ธฐ์ค€, ๋’ค์ชฝ(+Z)์— ๋ฐฐ์น˜ */} {placement.name && ( 0 ? locVisibleStackHeight : 0.1) + 0.3, boxDepth * 0.3]} rotation={[-Math.PI / 2, 0, 0]} fontSize={Math.min(boxWidth, boxDepth) * 0.18} color="#374151" diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index a40c1211..91299457 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -115,24 +115,33 @@ export function TableHistoryModal({ const getOperationBadge = (type: string) => { switch (type) { case "INSERT": - return ์ถ”๊ฐ€; + return ์ถ”๊ฐ€; case "UPDATE": - return ์ˆ˜์ •; + return ์ˆ˜์ •; case "DELETE": - return ์‚ญ์ œ; + return ์‚ญ์ œ; default: - return ( - - {type} - - ); + return {type}; } }; const formatDate = (dateString: string) => { try { - // DB๋Š” UTC๋กœ ์ €์žฅ, ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž๋™์œผ๋กœ ๋กœ์ปฌ ์‹œ๊ฐ„(KST)์œผ๋กœ ๋ณ€ํ™˜ const date = new Date(dateString); + + // ๐Ÿšจ ํƒ€์ž„์กด ๋ณด์ • ๋กœ์ง + // ์‹ค ์„œ๋น„์Šค DB๋Š” UTC๋กœ ์ €์žฅ๋˜๋Š”๋ฐ, ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์ด๋ฅผ KST๋กœ ์ธ์‹ํ•˜์ง€ ๋ชปํ•˜๊ณ  + // UTC ์‹œ๊ฐ„ ๊ทธ๋Œ€๋กœ(์˜ˆ: 02:55)๋ฅผ ํ•œ๊ตญ ์‹œ๊ฐ„ 02:55๋กœ ๋ณด์—ฌ์ฃผ๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์Œ (9์‹œ๊ฐ„ ๋А๋ฆผ). + // ๋ฐ˜๋ฉด ๋กœ์ปฌ DB๋Š” ์ด๋ฏธ KST๋กœ ์ €์žฅ๋˜์–ด ์žˆ์–ด์„œ ๋ณ€ํ™˜ํ•˜๋ฉด ์•ˆ ๋จ. + // ๋”ฐ๋ผ์„œ ๋กœ์ปฌ ํ™˜๊ฒฝ์ด ์•„๋‹ ๋•Œ๋งŒ ๊ฐ•์ œ๋กœ 9์‹œ๊ฐ„์„ ๋”ํ•ด์คŒ. + const isLocal = + typeof window !== "undefined" && + (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); + + if (!isLocal) { + date.setHours(date.getHours() + 9); + } + return format(date, "yyyy๋…„ MM์›” dd์ผ HH:mm:ss", { locale: ko }); } catch { return dateString; diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index c46244b1..24b9e320 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -1,11 +1,19 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; +import { DashboardElement, ChartDataSource, FieldGroup } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; -import { Loader2, RefreshCw } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); + // ํ–‰ ์ƒ์„ธ ํŒ์—… ์ƒํƒœ + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [detailPopupData, setDetailPopupData] = useState | null>(null); + const [detailPopupLoading, setDetailPopupLoading] = useState(false); + const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // // console.log("๐Ÿงช ListTestWidget ๋ Œ๋”๋ง!", element); const dataSources = useMemo(() => { @@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { cardColumns: 3, }; + // ํ–‰ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ - ํŒ์—… ์—ด๊ธฐ + const handleRowClick = useCallback( + async (row: Record) => { + // ํŒ์—…์ด ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์œผ๋ฉด ๋ฌด์‹œ + 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 ; + case "clock": + return ; + case "map": + return ; + case "package": + return ; + default: + return ; + } + }; + + // ํ•„๋“œ ๊ทธ๋ฃน ๋ Œ๋”๋ง + const renderFieldGroup = (group: FieldGroup, groupData: Record) => { + 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 ( +
+
+ {renderIcon(group.icon, group.color)} + {group.title} +
+
+ {group.fields.map((field) => ( +
+ + {field.label} + + {formatValue(groupData[field.column], field.format)} +
+ ))} +
+
+ ); + }; + + // ๊ธฐ๋ณธ ํ•„๋“œ ๊ทธ๋ฃน ์ƒ์„ฑ (์„ค์ •์ด ์—†์„ ๊ฒฝ์šฐ) + const getDefaultFieldGroups = (row: Record, additional: Record | null): FieldGroup[] => { + const groups: FieldGroup[] = []; + const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; + + // ๊ธฐ๋ณธ ์ •๋ณด ๊ทธ๋ฃน - displayColumns๊ฐ€ ์žˆ์œผ๋ฉด ํ•ด๋‹น ์ปฌ๋Ÿผ๋งŒ, ์—†์œผ๋ฉด ์ „์ฒด + const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source ๋“ฑ ๋‚ด๋ถ€ ํ•„๋“œ ์ œ์™ธ + let basicFields: { column: string; label: string }[] = []; + + if (displayColumns && displayColumns.length > 0) { + // DisplayColumnConfig ํ˜•์‹ ์ง€์› + basicFields = displayColumns + .map((colConfig) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return { column, label }; + }) + .filter((item) => allKeys.includes(item.column)); + } else { + // ์ „์ฒด ์ปฌ๋Ÿผ + basicFields = allKeys.map((key) => ({ column: key, label: key })); + } + + groups.push({ + id: "basic", + title: "๊ธฐ๋ณธ ์ •๋ณด", + icon: "info", + color: "gray", + fields: basicFields.map((item) => ({ + column: item.column, + label: item.label, + format: "text" as const, + })), + }); + + // ์ถ”๊ฐ€ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๊ณ  vehicles ํ…Œ์ด๋ธ”์ธ ๊ฒฝ์šฐ ์šดํ–‰/๊ณต์ฐจ ์ •๋ณด ์ถ”๊ฐ€ + if (additional && Object.keys(additional).length > 0) { + // ์šดํ–‰ ์ •๋ณด + if (additional.last_trip_start || additional.last_trip_end) { + groups.push({ + id: "trip", + title: "์šดํ–‰ ์ •๋ณด", + icon: "truck", + color: "blue", + fields: [ + { column: "last_trip_start", label: "์‹œ์ž‘", format: "datetime" as const }, + { column: "last_trip_end", label: "์ข…๋ฃŒ", format: "datetime" as const }, + { column: "last_trip_distance", label: "๊ฑฐ๋ฆฌ", format: "distance" as const }, + { column: "last_trip_time", label: "์‹œ๊ฐ„", format: "duration" as const }, + { column: "departure", label: "์ถœ๋ฐœ์ง€", format: "text" as const }, + { column: "arrival", label: "๋„์ฐฉ์ง€", format: "text" as const }, + ], + }); + } + + // ๊ณต์ฐจ ์ •๋ณด + if (additional.last_empty_start) { + groups.push({ + id: "empty", + title: "๊ณต์ฐจ ์ •๋ณด", + icon: "package", + color: "orange", + fields: [ + { column: "last_empty_start", label: "์‹œ์ž‘", format: "datetime" as const }, + { column: "last_empty_end", label: "์ข…๋ฃŒ", format: "datetime" as const }, + { column: "last_empty_distance", label: "๊ฑฐ๋ฆฌ", format: "distance" as const }, + { column: "last_empty_time", label: "์‹œ๊ฐ„", format: "duration" as const }, + ], + }); + } + } + + return groups; + }; + // visible ์ปฌ๋Ÿผ ์„ค์ • ๊ฐ์ฒด ๋ฐฐ์—ด (field + label) const visibleColumnConfigs = useMemo(() => { if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") { @@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { )} {paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns.map((field) => ( {String(row[field] ?? "")} @@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { return (
{paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns.map((field) => (
{getLabel(field)}: @@ -489,6 +721,49 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
)} + + {/* ํ–‰ ์ƒ์„ธ ํŒ์—… */} + + + + {config.rowDetailPopup?.title || "์ƒ์„ธ ์ •๋ณด"} + + {detailPopupLoading + ? "์ถ”๊ฐ€ ์ •๋ณด๋ฅผ ๋กœ๋”ฉ ์ค‘์ž…๋‹ˆ๋‹ค..." + : detailPopupData + ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` + : "์„ ํƒ๋œ ํ•ญ๋ชฉ์˜ ์ƒ์„ธ ์ •๋ณด์ž…๋‹ˆ๋‹ค."} + + + + {detailPopupLoading ? ( +
+
+
+ ) : ( +
+ {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 }), + )} + + )} +
+ )} + + + + + +
); } diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 9b0db43a..bc7b995d 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { Loader2, RefreshCw } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; +import { regionOptions, filterVehiclesByRegion } from "@/lib/constants/regionBounds"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import "leaflet/dist/leaflet.css"; // Popup ๋งํ’์„  ๊ผฌ๋ฆฌ ์ œ๊ฑฐ ์Šคํƒ€์ผ @@ -101,6 +103,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [routeLoading, setRouteLoading] = useState(false); const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD ํ˜•์‹ + // ๊ณต์ฐจ/์šดํ–‰ ์ •๋ณด ์ƒํƒœ + const [tripInfo, setTripInfo] = useState>({}); + const [tripInfoLoading, setTripInfoLoading] = useState(null); + + // Popup ์—ด๋ฆผ ์ƒํƒœ (์ž๋™ ์ƒˆ๋กœ๊ณ ์นจ ์ผ์‹œ ์ค‘์ง€์šฉ) + const [isPopupOpen, setIsPopupOpen] = useState(false); + + // ์ง€์—ญ ํ•„ํ„ฐ ์ƒํƒœ + const [selectedRegion, setSelectedRegion] = useState("all"); + // dataSources๋ฅผ useMemo๋กœ ์ถ”์ถœ (circular reference ๋ฐฉ์ง€) const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; @@ -182,6 +194,151 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setRoutePoints([]); }, []); + // ๊ณต์ฐจ/์šดํ–‰ ์ •๋ณด ๋กœ๋“œ ํ•จ์ˆ˜ + const loadTripInfo = useCallback(async (identifier: string) => { + if (!identifier || tripInfo[identifier]) { + return; // ์ด๋ฏธ ๋กœ๋“œ๋จ + } + + setTripInfoLoading(identifier); + + try { + // user_id ๋˜๋Š” vehicle_number๋กœ ์กฐํšŒ + const query = `SELECT + id, vehicle_number, user_id, + last_trip_start, last_trip_end, last_trip_distance, last_trip_time, + last_empty_start, last_empty_end, last_empty_distance, last_empty_time, + departure, arrival, status + FROM vehicles + WHERE user_id = '${identifier}' + OR vehicle_number = '${identifier}' + LIMIT 1`; + + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + setTripInfo((prev) => ({ + ...prev, + [identifier]: result.data.rows[0], + })); + } else { + // ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ์—๋„ "๋กœ๋“œ ์™„๋ฃŒ" ์ƒํƒœ๋กœ ํ‘œ์‹œ (๋นˆ ๊ฐ์ฒด ์ €์žฅ) + setTripInfo((prev) => ({ + ...prev, + [identifier]: { _noData: true }, + })); + } + } else { + // API ์‹คํŒจ ์‹œ์—๋„ "๋กœ๋“œ ์™„๋ฃŒ" ์ƒํƒœ๋กœ ํ‘œ์‹œ + setTripInfo((prev) => ({ + ...prev, + [identifier]: { _noData: true }, + })); + } + } catch (err) { + console.error("๊ณต์ฐจ/์šดํ–‰ ์ •๋ณด ๋กœ๋“œ ์‹คํŒจ:", err); + // ์—๋Ÿฌ ์‹œ์—๋„ "๋กœ๋“œ ์™„๋ฃŒ" ์ƒํƒœ๋กœ ํ‘œ์‹œ + setTripInfo((prev) => ({ + ...prev, + [identifier]: { _noData: true }, + })); + } + + setTripInfoLoading(null); + }, [tripInfo]); + + // ๋งˆ์ปค ๋กœ๋“œ ์‹œ ์šดํ–‰/๊ณต์ฐจ ์ •๋ณด ๋ฏธ๋ฆฌ ์ผ๊ด„ ์กฐํšŒ + const preloadTripInfo = useCallback(async (loadedMarkers: MarkerData[]) => { + if (!loadedMarkers || loadedMarkers.length === 0) return; + + // ๋งˆ์ปค์—์„œ identifier ์ถ”์ถœ (user_id ๋˜๋Š” vehicle_number) + const identifiers: string[] = []; + loadedMarkers.forEach((marker) => { + try { + const parsed = JSON.parse(marker.description || "{}"); + const identifier = parsed.user_id || parsed.vehicle_number || parsed.id; + if (identifier && !tripInfo[identifier]) { + identifiers.push(identifier); + } + } catch { + // ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ๋ฌด์‹œ + } + }); + + if (identifiers.length === 0) return; + + try { + // ๋ชจ๋“  ๋งˆ์ปค์˜ ์šดํ–‰/๊ณต์ฐจ ์ •๋ณด๋ฅผ ํ•œ ๋ฒˆ์— ์กฐํšŒ + const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); + const query = `SELECT + id, vehicle_number, user_id, + last_trip_start, last_trip_end, last_trip_distance, last_trip_time, + last_empty_start, last_empty_end, last_empty_distance, last_empty_time, + departure, arrival, status + FROM vehicles + WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")}) + OR vehicle_number IN (${identifiers.map(id => `'${id}'`).join(", ")})`; + + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const newTripInfo: Record = {}; + + // ์กฐํšŒ๋œ ๋ฐ์ดํ„ฐ๋ฅผ 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 = {}; + identifiers.forEach((id) => { + noDataInfo[id] = { _noData: true }; + }); + setTripInfo((prev) => ({ ...prev, ...noDataInfo })); + } + } + } catch (err) { + console.error("์šดํ–‰/๊ณต์ฐจ ์ •๋ณด ๋ฏธ๋ฆฌ ๋กœ๋“œ ์‹คํŒจ:", err); + } + }, [tripInfo]); + // ๋‹ค์ค‘ ๋ฐ์ดํ„ฐ ์†Œ์Šค ๋กœ๋”ฉ const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { @@ -254,6 +411,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setMarkers(markersWithHeading); setPolygons(allPolygons); setLastRefreshTime(new Date()); + + // ๋งˆ์ปค ๋กœ๋“œ ํ›„ ์šดํ–‰/๊ณต์ฐจ ์ •๋ณด ๋ฏธ๋ฆฌ ์ผ๊ด„ ์กฐํšŒ + preloadTripInfo(markersWithHeading); } catch (err: any) { setError(err.message); } finally { @@ -1130,14 +1290,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } const intervalId = setInterval(() => { - loadMultipleDataSources(); + // Popup์ด ์—ด๋ ค์žˆ์œผ๋ฉด ์ž๋™ ์ƒˆ๋กœ๊ณ ์นจ ๊ฑด๋„ˆ๋›ฐ๊ธฐ + if (!isPopupOpen) { + loadMultipleDataSources(); + } }, refreshInterval * 1000); return () => { clearInterval(intervalId); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSources, element?.chartConfig?.refreshInterval]); + }, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]); // ํƒ€์ผ๋งต URL (VWorld ํ•œ๊ตญ ์ง€๋„) const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; @@ -1165,6 +1328,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

+ {/* ์ง€์—ญ ํ•„ํ„ฐ */} + + {/* ์ด๋™๊ฒฝ๋กœ ๋‚ ์งœ ์„ ํƒ */} {selectedUserId && (
@@ -1371,6 +1548,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { fillOpacity: 0.3, weight: 2, }} + eventHandlers={{ + popupopen: () => setIsPopupOpen(true), + popupclose: () => setIsPopupOpen(false), + }} >
@@ -1442,8 +1623,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { ); })} - {/* ๋งˆ์ปค ๋ Œ๋”๋ง */} - {markers.map((marker) => { + {/* ๋งˆ์ปค ๋ Œ๋”๋ง (์ง€์—ญ ํ•„ํ„ฐ ์ ์šฉ) */} + {filterVehiclesByRegion(markers, selectedRegion).map((marker) => { // ๋งˆ์ปค์˜ ์†Œ์Šค์— ํ•ด๋‹นํ•˜๋Š” ๋ฐ์ดํ„ฐ ์†Œ์Šค ์ฐพ๊ธฐ const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0]; const markerType = sourceDataSource?.markerType || "circle"; @@ -1602,7 +1783,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } return ( - + setIsPopupOpen(true), + popupclose: () => setIsPopupOpen(false), + }} + >
{/* ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ช…๋งŒ ํ‘œ์‹œ */} @@ -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 ( +
+ +
+ ); + } + + // ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ํ‘œ์‹œ ์•ˆ ํ•จ + if (!hasEmptyTripInfo && !hasTripInfo) return null; + + return ( +
+ {/* ์šดํ–‰ ์ •๋ณด */} + {hasTripInfo && ( +
+
๐Ÿš› ์ตœ๊ทผ ์šดํ–‰
+
+ {(info.last_trip_start || info.last_trip_end) && ( +
+ ์‹œ๊ฐ„:{" "} + {formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)} +
+ )} +
+ {info.last_trip_distance !== undefined && info.last_trip_distance !== null && ( + + ๊ฑฐ๋ฆฌ:{" "} + {formatDistance(info.last_trip_distance)} + + )} + {info.last_trip_time !== undefined && info.last_trip_time !== null && ( + + ์†Œ์š”:{" "} + {formatTime(info.last_trip_time)} + + )} +
+ {/* ์ถœ๋ฐœ์ง€/๋„์ฐฉ์ง€ */} + {(info.departure || info.arrival) && ( +
+ {info.departure && ์ถœ๋ฐœ: {info.departure}} + {info.departure && info.arrival && " โ†’ "} + {info.arrival && ๋„์ฐฉ: {info.arrival}} +
+ )} +
+
+ )} + + {/* ๊ณต์ฐจ ์ •๋ณด */} + {hasEmptyTripInfo && ( +
+
๐Ÿ“ฆ ์ตœ๊ทผ ๊ณต์ฐจ
+
+ {(info.last_empty_start || info.last_empty_end) && ( +
+ ์‹œ๊ฐ„:{" "} + {formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)} +
+ )} +
+ {info.last_empty_distance !== undefined && info.last_empty_distance !== null && ( + + ๊ฑฐ๋ฆฌ:{" "} + {formatDistance(info.last_empty_distance)} + + )} + {info.last_empty_time !== undefined && info.last_empty_time !== null && ( + + ์†Œ์š”:{" "} + {formatTime(info.last_empty_time)} + + )} +
+
+
+ )} +
+ ); + } catch { + return null; + } + })()} + {/* ์ขŒํ‘œ */}
{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 && `๋งˆ์ปค ${markers.length}๊ฐœ`} + {markers.length > 0 && ( + <> + ๋งˆ์ปค {filterVehiclesByRegion(markers, selectedRegion).length}๊ฐœ + {selectedRegion !== "all" && ` (์ „์ฒด ${markers.length}๊ฐœ)`} + + )} {markers.length > 0 && polygons.length > 0 && " ยท "} {polygons.length > 0 && `์˜์—ญ ${polygons.length}๊ฐœ`}
diff --git a/frontend/components/flow/FlowDataListModal.tsx b/frontend/components/flow/FlowDataListModal.tsx index 352860e5..61264ffb 100644 --- a/frontend/components/flow/FlowDataListModal.tsx +++ b/frontend/components/flow/FlowDataListModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; +import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; @@ -130,11 +130,11 @@ export function FlowDataListModal({ - + {stepName} {data.length}๊ฑด - - ์ด ๋‹จ๊ณ„์— ํ•ด๋‹นํ•˜๋Š” ๋ฐ์ดํ„ฐ ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค + + ์ด ๋‹จ๊ณ„์— ํ•ด๋‹นํ•˜๋Š” ๋ฐ์ดํ„ฐ ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค
diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index 83fcd3a2..8d362f5d 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -48,7 +48,20 @@ export const NumberingRuleCard: React.FC = ({ - setSearchValues((prev) => ({ - ...prev, - [col]: e.target.value, - })) +
+ {/* ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ๋“ค */} +
+ + +
+ + {/* ๋ณต์‚ฌ ๋ฒ„ํŠผ */} +
+ +
+ + {/* ์„ ํƒ ์ •๋ณด */} + {selectedRows.size > 0 && ( +
+ + {selectedRows.size}๊ฐœ ์„ ํƒ๋จ + + +
+ )} + + {/* ๐Ÿ†• ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ํŒจ๋„ */} +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); } - placeholder={`${columnLabels[col] || col} ๊ฒ€์ƒ‰...`} - className="h-8 text-xs w-40" - /> - ))} - {Object.keys(searchValues).length > 0 && ( - + + +
+ ) : ( + )} - - )} +
{/* ํ•„ํ„ฐ/๊ทธ๋ฃน ์„ค์ • ๋ฒ„ํŠผ */} -
+
+ + {/* ์ƒˆ๋กœ๊ณ ์นจ */} +
+
+ {/* ๊ฒ€์ƒ‰ ํ•„ํ„ฐ ์ž…๋ ฅ ์˜์—ญ */} + {searchFilterColumns.size > 0 && ( +
+
+ {Array.from(searchFilterColumns).map((col) => ( + + setSearchValues((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + placeholder={`${columnLabels[col] || col} ๊ฒ€์ƒ‰...`} + className="h-8 text-xs w-40" + /> + ))} + {Object.keys(searchValues).length > 0 && ( + + )} +
+
+ )} + {/* ๐Ÿ†• ๊ทธ๋ฃน ํ‘œ์‹œ ๋ฐฐ์ง€ */} {groupByColumns.length > 0 && (
@@ -1039,15 +1843,15 @@ export function FlowWidget({
{allowDataMove && (
์„ ํƒ toggleRowSelection(actualIndex)} + checked={selectedRows.has(getRowKey(row))} + onCheckedChange={() => toggleRowSelection(row)} />
)} @@ -1065,13 +1869,16 @@ export function FlowWidget({
- {/* ๋ฐ์Šคํฌํ†ฑ: ํ…Œ์ด๋ธ” ๋ทฐ - ๊ณ ์ • ๋†’์ด + ์Šคํฌ๋กค */} -
+ {/* ๋ฐ์Šคํฌํ†ฑ: ํ…Œ์ด๋ธ” ๋ทฐ - SingleTableWithSticky ์‚ฌ์šฉ */} +
+ {groupByColumns.length > 0 && groupedData.length > 0 ? ( + // ๊ทธ๋ฃนํ™”๋œ ๋ Œ๋”๋ง (๊ธฐ์กด ๋ฐฉ์‹ ์œ ์ง€) +
- + {allowDataMove && ( - + 0} onCheckedChange={toggleAllRows} @@ -1081,17 +1888,23 @@ export function FlowWidget({ {stepDataColumns.map((col) => ( - {columnLabels[col] || col} + className="bg-background border-b px-6 py-3 text-sm font-semibold whitespace-nowrap cursor-pointer hover:bg-muted/50" + onClick={() => handleSort(col)} + > +
+ {columnLabels[col] || col} + {sortColumn === col && ( + + {sortDirection === "asc" ? "โ†‘" : "โ†“"} + + )} +
))}
- {groupByColumns.length > 0 && groupedData.length > 0 ? ( - // ๊ทธ๋ฃนํ™”๋œ ๋ Œ๋”๋ง - groupedData.flatMap((group) => { + {groupedData.flatMap((group) => { const isCollapsed = collapsedGroups.has(group.groupKey); const groupRows = [ @@ -1117,17 +1930,17 @@ export function FlowWidget({ if (!isCollapsed) { const dataRows = group.items.map((row, itemIndex) => { - const actualIndex = displayData.indexOf(row); + const actualIndex = sortedDisplayData.indexOf(row); return ( {allowDataMove && ( toggleRowSelection(actualIndex)} + checked={selectedRows.has(getRowKey(row))} + onCheckedChange={() => toggleRowSelection(row)} /> )} @@ -1143,35 +1956,42 @@ export function FlowWidget({ } return groupRows; - }) + })} + +
+
) : ( - // ์ผ๋ฐ˜ ๋ Œ๋”๋ง (๊ทธ๋ฃน ์—†์Œ) - paginatedStepData.map((row, pageIndex) => { - const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> - - )} - {stepDataColumns.map((col) => ( - - {formatValue(row[col])} - - ))} - - ); - }) - )} - - + // ์ผ๋ฐ˜ ๋ Œ๋”๋ง - SingleTableWithSticky ์‚ฌ์šฉ + 0} + onSort={handleSort} + handleSelectAll={handleSelectAll} + handleRowClick={handleRowClick} + renderCheckboxCell={renderCheckboxCell} + formatCellValue={formatCellValue} + getColumnWidth={getColumnWidth} + loading={stepDataLoading} + // ์ธ๋ผ์ธ ํŽธ์ง‘ props + onCellDoubleClick={handleCellDoubleClick} + editingCell={editingCell} + editingValue={editingValue} + onEditingValueChange={setEditingValue} + onEditKeyDown={handleEditKeyDown} + editInputRef={editInputRef} + // ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ props (ํ˜„์žฌ ํŽ˜์ด์ง€ ๊ธฐ์ค€์œผ๋กœ ๋ณ€ํ™˜๋œ ๊ฐ’) + searchHighlights={pageSearchHighlights} + currentSearchIndex={pageCurrentSearchIndex} + searchTerm={globalSearchTerm} + /> + )}
)} diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx index 9112ad33..9748e9e3 100644 --- a/frontend/components/tax-invoice/TaxInvoiceForm.tsx +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -62,7 +62,7 @@ import { CostType, costTypeLabels, } from "@/lib/api/taxInvoice"; -import { apiClient } from "@/lib/api/client"; +import { uploadFiles } from "@/lib/api/file"; interface TaxInvoiceFormProps { open: boolean; @@ -223,36 +223,35 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor }); }; - // ํŒŒ์ผ ์—…๋กœ๋“œ + // ํŒŒ์ผ ์—…๋กœ๋“œ (ํ™”๋ฉด ๊ด€๋ฆฌ ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ์™€ ๋™์ผํ•œ ๋ฐฉ์‹ ์‚ฌ์šฉ) const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; setUploading(true); try { - for (const file of Array.from(files)) { - const formDataUpload = new FormData(); - formDataUpload.append("files", file); // ๋ฐฑ์—”๋“œ Multer ํ•„๋“œ๋ช…: "files" - formDataUpload.append("category", "tax-invoice"); + // ํ™”๋ฉด ๊ด€๋ฆฌ ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ์™€ ๋™์ผํ•œ uploadFiles ํ•จ์ˆ˜ ์‚ฌ์šฉ + const response = await uploadFiles({ + files: files, + tableName: "tax_invoice", + fieldName: "attachments", + recordId: invoice?.id, + docType: "tax-invoice", + docTypeName: "์„ธ๊ธˆ๊ณ„์‚ฐ์„œ", + }); - const response = await apiClient.post("/files/upload", formDataUpload, { - headers: { "Content-Type": "multipart/form-data" }, - }); - - if (response.data.success && response.data.files?.length > 0) { - const uploadedFile = response.data.files[0]; - const newAttachment: TaxInvoiceAttachment = { - id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - file_name: uploadedFile.realFileName || file.name, - file_path: uploadedFile.filePath, - file_size: uploadedFile.fileSize || file.size, - file_type: file.type, - uploaded_at: new Date().toISOString(), - uploaded_by: "", - }; - setAttachments((prev) => [...prev, newAttachment]); - toast.success(`'${file.name}' ์—…๋กœ๋“œ ์™„๋ฃŒ`); - } + if (response.success && response.files?.length > 0) { + const newAttachments: TaxInvoiceAttachment[] = response.files.map((uploadedFile) => ({ + id: uploadedFile.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + file_name: uploadedFile.name, + file_path: uploadedFile.serverPath || "", + file_size: uploadedFile.size, + file_type: uploadedFile.type, + uploaded_at: uploadedFile.uploadedAt || new Date().toISOString(), + uploaded_by: "", + })); + setAttachments((prev) => [...prev, ...newAttachments]); + toast.success(`${response.files.length}๊ฐœ ํŒŒ์ผ ์—…๋กœ๋“œ ์™„๋ฃŒ`); } } catch (error: any) { toast.error("ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ", { description: error.message }); diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index 0a917692..ff2a81a2 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -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, +): Promise> { + 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, + }; + } +} diff --git a/frontend/lib/constants/regionBounds.ts b/frontend/lib/constants/regionBounds.ts new file mode 100644 index 00000000..2b0f15ba --- /dev/null +++ b/frontend/lib/constants/regionBounds.ts @@ -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 = { + // ์„œ์šธํŠน๋ณ„์‹œ + 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; +} + diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 8609623b..b039ac38 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -447,7 +447,13 @@ export const DynamicComponentRenderer: React.FC = // ๋””์ž์ธ ๋ชจ๋“œ ํ”Œ๋ž˜๊ทธ ์ „๋‹ฌ - isPreview์™€ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, // ๐Ÿ†• ๊ทธ๋ฃน ๋ฐ์ดํ„ฐ ์ „๋‹ฌ (EditModal โ†’ ConditionalContainer โ†’ ModalRepeaterTable) - groupedData: props.groupedData, + // Note: ์ด props๋“ค์€ DOM ์š”์†Œ์— ์ „๋‹ฌ๋˜๋ฉด ์•ˆ ๋จ + // ๊ฐ ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ช…์‹œ์ ์œผ๋กœ destructureํ•˜์—ฌ ์‚ฌ์šฉํ•ด์•ผ ํ•จ + _groupedData: props.groupedData, + // ๐Ÿ†• UniversalFormModal์šฉ initialData ์ „๋‹ฌ + // originalData๋ฅผ ์‚ฌ์šฉ (์ตœ์ดˆ ์ „๋‹ฌ๋œ ๊ฐ’, formData๋Š” ๊ณ„์† ๋ณ€๊ฒฝ๋˜๋ฏ€๋กœ ์‚ฌ์šฉํ•˜๋ฉด ์•ˆ๋จ) + _initialData: originalData || formData, + _originalData: originalData, }; // ๋ Œ๋”๋Ÿฌ๊ฐ€ ํด๋ž˜์Šค์ธ์ง€ ํ•จ์ˆ˜์ธ์ง€ ํ™•์ธ diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index 3bb81986..6303cdee 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; -import { Trash2, Loader2, X } from "lucide-react"; -import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types"; +import { Trash2, Loader2, X, Plus } from "lucide-react"; +import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types"; import { cn } from "@/lib/utils"; import { ComponentRendererProps } from "@/types/component"; import { useCalculation } from "./useCalculation"; @@ -21,6 +21,7 @@ export interface SimpleRepeaterTableComponentProps extends ComponentRendererProp readOnly?: boolean; showRowNumber?: boolean; allowDelete?: boolean; + allowAdd?: boolean; maxHeight?: string; } @@ -44,10 +45,31 @@ export function SimpleRepeaterTableComponent({ readOnly: propReadOnly, showRowNumber: propShowRowNumber, allowDelete: propAllowDelete, + allowAdd: propAllowAdd, maxHeight: propMaxHeight, + // DynamicComponentRenderer์—์„œ ์ „๋‹ฌ๋˜๋Š” props (DOM ์ „๋‹ฌ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด _ prefix ์‚ฌ์šฉ) + _initialData, + _originalData, + _groupedData, + // ๋ ˆ๊ฑฐ์‹œ ํ˜ธํ™˜์„ฑ (์ผ๋ถ€ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ง์ ‘ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Œ) + initialData: legacyInitialData, + originalData: legacyOriginalData, + groupedData: legacyGroupedData, + ...props -}: SimpleRepeaterTableComponentProps) { +}: SimpleRepeaterTableComponentProps & { + _initialData?: any; + _originalData?: any; + _groupedData?: any; + initialData?: any; + originalData?: any; + groupedData?: any; +}) { + // ์‹ค์ œ ์‚ฌ์šฉํ•  ๋ฐ์ดํ„ฐ (์ƒˆ props ์šฐ์„ , ๋ ˆ๊ฑฐ์‹œ fallback) + const effectiveInitialData = _initialData || legacyInitialData; + const effectiveOriginalData = _originalData || legacyOriginalData; + const effectiveGroupedData = _groupedData || legacyGroupedData; // config ๋˜๋Š” component.config ๋˜๋Š” ๊ฐœ๋ณ„ prop ์šฐ์„ ์ˆœ์œ„๋กœ ๋ณ‘ํ•ฉ const componentConfig = { ...config, @@ -60,6 +82,13 @@ export function SimpleRepeaterTableComponent({ const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false; const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true; const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true; + const allowAdd = componentConfig?.allowAdd ?? propAllowAdd ?? false; + const addButtonText = componentConfig?.addButtonText || "ํ–‰ ์ถ”๊ฐ€"; + const addButtonPosition = componentConfig?.addButtonPosition || "bottom"; + const minRows = componentConfig?.minRows ?? 0; + const maxRows = componentConfig?.maxRows ?? Infinity; + const newRowDefaults = componentConfig?.newRowDefaults || {}; + const summaryConfig = componentConfig?.summaryConfig; const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px"; // value๋Š” formData[columnName] ์šฐ์„ , ์—†์œผ๋ฉด prop ์‚ฌ์šฉ @@ -256,7 +285,7 @@ export function SimpleRepeaterTableComponent({ useEffect(() => { const handleSaveRequest = async (event: Event) => { if (value.length === 0) { - console.warn("โš ๏ธ [SimpleRepeaterTable] ์ €์žฅํ•  ๋ฐ์ดํ„ฐ ์—†์Œ"); + // console.warn("โš ๏ธ [SimpleRepeaterTable] ์ €์žฅํ•  ๋ฐ์ดํ„ฐ ์—†์Œ"); return; } @@ -297,7 +326,7 @@ export function SimpleRepeaterTableComponent({ }); }); - console.log("โœ… [SimpleRepeaterTable] ํ…Œ์ด๋ธ”๋ณ„ ์ €์žฅ ๋ฐ์ดํ„ฐ:", dataByTable); + // console.log("โœ… [SimpleRepeaterTable] ํ…Œ์ด๋ธ”๋ณ„ ์ €์žฅ ๋ฐ์ดํ„ฐ:", dataByTable); // CustomEvent์˜ detail์— ํ…Œ์ด๋ธ”๋ณ„ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ if (event instanceof CustomEvent && event.detail) { @@ -310,10 +339,10 @@ export function SimpleRepeaterTableComponent({ })); }); - console.log("โœ… [SimpleRepeaterTable] ์ €์žฅ ๋ฐ์ดํ„ฐ ์ค€๋น„:", { - tables: Object.keys(dataByTable), - totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0), - }); + // console.log("โœ… [SimpleRepeaterTable] ์ €์žฅ ๋ฐ์ดํ„ฐ ์ค€๋น„:", { + // tables: Object.keys(dataByTable), + // totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0), + // }); } // ๊ธฐ์กด onFormDataChange๋„ ํ˜ธ์ถœ (ํ˜ธํ™˜์„ฑ) @@ -345,10 +374,137 @@ export function SimpleRepeaterTableComponent({ }; const handleRowDelete = (rowIndex: number) => { + // ์ตœ์†Œ ํ–‰ ์ˆ˜ ์ฒดํฌ + if (value.length <= minRows) { + return; + } const newData = value.filter((_, i) => i !== rowIndex); handleChange(newData); }; + // ํ–‰ ์ถ”๊ฐ€ ํ•จ์ˆ˜ + const handleAddRow = () => { + // ์ตœ๋Œ€ ํ–‰ ์ˆ˜ ์ฒดํฌ + if (value.length >= maxRows) { + return; + } + + // ์ƒˆ ํ–‰ ์ƒ์„ฑ (๊ธฐ๋ณธ๊ฐ’ ์ ์šฉ) + const newRow: Record = { ...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 = {}; + + // ๋จผ์ € ๊ธฐ๋ณธ ์ง‘๊ณ„ ํ•จ์ˆ˜ ๊ณ„์‚ฐ + 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 ( + + ); + }; + const renderCell = ( row: any, column: SimpleRepeaterColumnConfig, @@ -457,8 +613,18 @@ export function SimpleRepeaterTableComponent({ ); } + // ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์ˆ˜ ๊ณ„์‚ฐ + const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0); + return (
+ {/* ์ƒ๋‹จ ํ–‰ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + {allowAdd && addButtonPosition !== "bottom" && ( +
+ +
+ )} +
- ํ‘œ์‹œํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค + {allowAdd ? ( +
+ ํ‘œ์‹œํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค + +
+ ) : ( + "ํ‘œ์‹œํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค" + )} ) : ( @@ -517,7 +690,8 @@ export function SimpleRepeaterTableComponent({ variant="ghost" size="sm" onClick={() => handleRowDelete(rowIndex)} - className="h-7 w-7 p-0 text-destructive hover:text-destructive" + disabled={value.length <= minRows} + className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50" > @@ -529,6 +703,58 @@ export function SimpleRepeaterTableComponent({
+ + {/* ํ•ฉ๊ณ„ ํ‘œ์‹œ */} + {summaryConfig?.enabled && summaryValues && ( +
+
+ {summaryConfig.title && ( +
+ {summaryConfig.title} +
+ )} +
+ {summaryConfig.fields.map((field) => ( +
+ {field.label} + + {formatSummaryValue(field, summaryValues[field.field] || 0)} + +
+ ))} +
+
+
+ )} + + {/* ํ•˜๋‹จ ํ–‰ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + {allowAdd && addButtonPosition !== "top" && value.length > 0 && ( +
+ + {maxRows !== Infinity && ( + + {value.length} / {maxRows} + + )} +
+ )}
); } diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx index 69b2b597..41e70e08 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx @@ -15,6 +15,8 @@ import { ColumnTargetConfig, InitialDataConfig, DataFilterCondition, + SummaryConfig, + SummaryFieldConfig, } from "./types"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -482,6 +484,81 @@ export function SimpleRepeaterTableConfigPanel({

+
+
+ + updateConfig({ allowAdd: checked })} + /> +
+

+ ์‚ฌ์šฉ์ž๊ฐ€ ์ƒˆ ํ–‰์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค +

+
+ + {localConfig.allowAdd && ( + <> +
+ + updateConfig({ addButtonText: e.target.value })} + placeholder="ํ–‰ ์ถ”๊ฐ€" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + +
+ +
+
+ + updateConfig({ minRows: parseInt(e.target.value) || 0 })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 0์ด๋ฉด ์ œํ•œ ์—†์Œ +

+
+ +
+ + updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })} + placeholder="๋ฌด์ œํ•œ" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ ๋น„์›Œ๋‘๋ฉด ๋ฌด์ œํ•œ +

+
+
+ + )} +

- ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•  ํ…Œ์ด๋ธ” (์˜ˆ: sales_order_mng) + ์„ ํƒ ์•ˆ ํ•˜๋ฉด ๋นˆ ํ…Œ์ด๋ธ”๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค (์ƒˆ ๋ฐ์ดํ„ฐ ์ž…๋ ฅ์šฉ)

@@ -1002,48 +1087,71 @@ export function SimpleRepeaterTableConfigPanel({ )}
- {/* ๐Ÿ†• ๋ฐ์ดํ„ฐ ํƒ€๊ฒŸ ์„ค์ • (์–ด๋””์— ์ €์žฅํ• ์ง€) */} -
-
-
- + {/* ๐Ÿ†• ๋ฐ์ดํ„ฐ ํƒ€๊ฒŸ ์„ค์ • - ๋ถ€๋ชจ-์ž์‹ ๋ชจ๋“œ๋ฉด ์ˆจ๊น€ */} + {localConfig.parentChildConfig?.enabled ? ( + // ๋ถ€๋ชจ-์ž์‹ ๋ชจ๋“œ: ๊ฐ„๋‹จํ•œ ์•ˆ๋‚ด๋งŒ ํ‘œ์‹œ +
+
+

+ ๋ถ€๋ชจ-์ž์‹ ๋ชจ๋“œ +
+ โ†’ {localConfig.parentChildConfig.childTable || "์ž์‹ ํ…Œ์ด๋ธ”"}.{col.field || "ํ•„๋“œ๋ช…"} ์— ์ €์žฅ +

+
+ ) : ( + // ์ผ๋ฐ˜ ๋ชจ๋“œ: ํƒ€๊ฒŸ ์„ค์ • (์„ ํƒ์‚ฌํ•ญ) +
+
+
+ +
-
- - -

- ์ด ์ปฌ๋Ÿผ์˜ ๊ฐ’์„ ์ €์žฅํ•  ํ…Œ์ด๋ธ” -

-
+
+ + +

+ ์„ ํƒ ์•ˆ ํ•˜๋ฉด ์ด ์ปฌ๋Ÿผ์€ ์ €์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค +

+
- {col.targetConfig?.targetTable && ( - <> + {col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" && (
updateColumn(index, { targetConfig: { ...col.targetConfig, @@ -1052,37 +1160,10 @@ export function SimpleRepeaterTableConfigPanel({ })} showTableName={true} /> -

- ์ €์žฅํ•  ์ปฌ๋Ÿผ๋ช… -

- -
-
- - updateColumn(index, { - targetConfig: { - ...col.targetConfig, - saveEnabled: checked - } - })} - /> -
-

- ๋น„ํ™œ์„ฑํ™” ์‹œ ์ €์žฅํ•˜์ง€ ์•Š์Œ (ํ‘œ์‹œ ์ „์šฉ) -

-
- - {col.targetConfig.targetTable && col.targetConfig.targetColumn && ( -
- ์ €์žฅ: {col.targetConfig.targetTable}.{col.targetConfig.targetColumn} -
- )} - - )} -
+ )} +
+ )} {/* ํŽธ์ง‘ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ */}
@@ -1235,11 +1316,13 @@ export function SimpleRepeaterTableConfigPanel({ - {(localConfig.columns || []).map((col, colIndex) => ( - - {col.label} ({col.field || '๋ฏธ์„ค์ •'}) - - ))} + {(localConfig.columns || []) + .filter((col) => col.field && col.field.trim() !== "") + .map((col, colIndex) => ( + + {col.label} ({col.field}) + + ))}

@@ -1314,15 +1397,285 @@ export function SimpleRepeaterTableConfigPanel({ )}

+ {/* ํ•ฉ๊ณ„ ์„ค์ • */} +
+
+

ํ•ฉ๊ณ„ ์„ค์ •

+

+ ํ…Œ์ด๋ธ” ํ•˜๋‹จ์— ํ•ฉ๊ณ„๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค +

+
+ +
+
+ + updateConfig({ + summaryConfig: { + ...localConfig.summaryConfig, + enabled: checked, + fields: localConfig.summaryConfig?.fields || [], + } + })} + /> +
+
+ + {localConfig.summaryConfig?.enabled && ( + <> +
+ + 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" + /> +
+ +
+ + +
+ +
+
+ + +
+ + {localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? ( +
+ {localConfig.summaryConfig.fields.map((field, index) => ( +
+
+ ํ•ฉ๊ณ„ ํ•„๋“œ {index + 1} + +
+ +
+
+ + +
+ +
+ + { + 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" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + { + const fields = [...(localConfig.summaryConfig?.fields || [])]; + fields[index] = { ...fields[index], highlight: checked }; + updateConfig({ + summaryConfig: { + ...localConfig.summaryConfig, + enabled: true, + fields, + } + }); + }} + /> +
+
+ ))} +
+ ) : ( +
+

+ ํ•ฉ๊ณ„ ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š” +

+
+ )} +
+ +
+

์‚ฌ์šฉ ์˜ˆ์‹œ

+
+

โ€ข ๊ณต๊ธ‰๊ฐ€์•ก ํ•ฉ๊ณ„: supply_amount ํ•„๋“œ์˜ SUM

+

โ€ข ์„ธ์•ก ํ•ฉ๊ณ„: tax_amount ํ•„๋“œ์˜ SUM

+

โ€ข ์ด์•ก: supply_amount + tax_amount (์ˆ˜์‹ ํ•„๋“œ)

+
+
+ + )} +
+ {/* ์‚ฌ์šฉ ์•ˆ๋‚ด */}

SimpleRepeaterTable ์‚ฌ์šฉ๋ฒ•:

  • ์ฃผ์–ด์ง„ ๋ฐ์ดํ„ฐ๋ฅผ ํ‘œ์‹œํ•˜๊ณ  ํŽธ์ง‘ํ•˜๋Š” ๊ฒฝ๋Ÿ‰ ํ…Œ์ด๋ธ”์ž…๋‹ˆ๋‹ค
  • -
  • ๊ฒ€์ƒ‰/์ถ”๊ฐ€ ๊ธฐ๋Šฅ์€ ์—†์œผ๋ฉฐ, ์ƒ์œ„ ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌ๋ฐ›์Šต๋‹ˆ๋‹ค
  • +
  • ํ–‰ ์ถ”๊ฐ€ ํ—ˆ์šฉ ์˜ต์…˜์œผ๋กœ ์‚ฌ์šฉ์ž๊ฐ€ ์ƒˆ ํ–‰์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
  • ์ฃผ๋กœ EditModal๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉ๋˜๋ฉฐ, ์„ ํƒ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ผ๊ด„ ์ˆ˜์ •ํ•  ๋•Œ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค
  • readOnly ์˜ต์…˜์œผ๋กœ ์ „์ฒด ํ…Œ์ด๋ธ”์„ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
  • ์ž๋™ ๊ณ„์‚ฐ ๊ทœ์น™์„ ํ†ตํ•ด ์ˆ˜๋Ÿ‰ * ๋‹จ๊ฐ€ = ๊ธˆ์•ก ๊ฐ™์€ ๊ณ„์‚ฐ์„ ์ž๋™ํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
  • +
  • ํ•ฉ๊ณ„ ์„ค์ •์œผ๋กœ ํ…Œ์ด๋ธ” ํ•˜๋‹จ์— ํ•ฉ๊ณ„/ํ‰๊ท  ๋“ฑ์„ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค
diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx index 13e75743..31a83548 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx @@ -9,7 +9,7 @@ import { ComponentRendererProps } from "@/types/component"; // ์ปดํฌ๋„ŒํŠธ ์ž๋™ ๋“ฑ๋ก ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition); -console.log("โœ… SimpleRepeaterTable ์ปดํฌ๋„ŒํŠธ ๋“ฑ๋ก ์™„๋ฃŒ"); +// console.log("โœ… SimpleRepeaterTable ์ปดํฌ๋„ŒํŠธ ๋“ฑ๋ก ์™„๋ฃŒ"); export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) { return ; diff --git a/frontend/lib/registry/components/simple-repeater-table/index.ts b/frontend/lib/registry/components/simple-repeater-table/index.ts index 0c6457ac..9cb2d3f2 100644 --- a/frontend/lib/registry/components/simple-repeater-table/index.ts +++ b/frontend/lib/registry/components/simple-repeater-table/index.ts @@ -31,6 +31,15 @@ export const SimpleRepeaterTableDefinition = createComponentDefinition({ readOnly: false, showRowNumber: true, allowDelete: true, + allowAdd: false, + addButtonText: "ํ–‰ ์ถ”๊ฐ€", + addButtonPosition: "bottom", + minRows: 0, + maxRows: undefined, + summaryConfig: { + enabled: false, + fields: [], + }, maxHeight: "240px", }, defaultSize: { width: 800, height: 400 }, @@ -51,6 +60,8 @@ export type { InitialDataConfig, DataFilterCondition, SourceJoinCondition, + SummaryConfig, + SummaryFieldConfig, } from "./types"; // ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ณด๋‚ด๊ธฐ diff --git a/frontend/lib/registry/components/simple-repeater-table/types.ts b/frontend/lib/registry/components/simple-repeater-table/types.ts index 8b137891..0ace80aa 100644 --- a/frontend/lib/registry/components/simple-repeater-table/types.ts +++ b/frontend/lib/registry/components/simple-repeater-table/types.ts @@ -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; + + // ํ•ฉ๊ณ„ ์„ค์ • + summaryConfig?: SummaryConfig; + + // ๋ฐ์ดํ„ฐ + value?: any[]; + onChange?: (newData: any[]) => void; +} diff --git a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts index 8a0fdba5..7cb66219 100644 --- a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts +++ b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts @@ -30,7 +30,8 @@ export function useCalculation(calculationRules: CalculationRule[] = []) { // ๊ฒฐ๊ณผ ํ•„๋“œ๋Š” ์ œ์™ธ if (dep === rule.result) continue; - const value = parseFloat(row[dep]) || 0; + // ์ด์ „ ๊ณ„์‚ฐ ๊ฒฐ๊ณผ(updatedRow)๋ฅผ ์šฐ์„  ์‚ฌ์šฉ, ์—†์œผ๋ฉด ์›๋ณธ(row) ์‚ฌ์šฉ + const value = parseFloat(updatedRow[dep] ?? row[dep]) || 0; // ์ •ํ™•ํ•œ ํ•„๋“œ๋ช…๋งŒ ๋Œ€์ฒด (๋‹จ์–ด ๊ฒฝ๊ณ„ ์‚ฌ์šฉ) formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString()); } diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 1ee1218a..679a64c9 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -905,11 +905,22 @@ export const SplitPanelLayout2Component: React.FC { - const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; + // ID ์ปฌ๋Ÿผ ๊ฒฐ์ •: ์„ค์ •๊ฐ’ > ๋ฐ์ดํ„ฐ์— ์กด์žฌํ•˜๋Š” ์ผ๋ฐ˜์ ์ธ ID ์ปฌ๋Ÿผ > ํด๋ฐฑ + const configIdColumn = config.leftPanel?.hierarchyConfig?.idColumn; + const idColumn = configIdColumn || + (item["id"] !== undefined ? "id" : + item["dept_code"] !== undefined ? "dept_code" : + item["code"] !== undefined ? "code" : "id"); const itemId = item[idColumn] ?? `item-${level}-${index}`; const hasChildren = item.children?.length > 0; const isExpanded = expandedItems.has(String(itemId)); - const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; + // ์„ ํƒ ์ƒํƒœ ํ™•์ธ: ๋™์ผํ•œ ๊ฐ์ฒด์ด๊ฑฐ๋‚˜ idColumn ๊ฐ’์ด ์ผ์น˜ํ•ด์•ผ ํ•จ + const isSelected = selectedLeftItem && ( + selectedLeftItem === item || + (item[idColumn] !== undefined && + selectedLeftItem[idColumn] !== undefined && + selectedLeftItem[idColumn] === item[idColumn]) + ); // displayRow ์„ค์ •์— ๋”ฐ๋ผ ์ปฌ๋Ÿผ ๋ถ„๋ฅ˜ const displayColumns = config.leftPanel?.displayColumns || []; diff --git a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx index 45143dc3..0f11bbf2 100644 --- a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx @@ -28,6 +28,17 @@ interface SingleTableWithStickyProps { containerWidth?: string; // ์ปจํ…Œ์ด๋„ˆ ๋„ˆ๋น„ ์„ค์ • loading?: boolean; error?: string | null; + // ์ธ๋ผ์ธ ํŽธ์ง‘ ๊ด€๋ จ props + onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void; + editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null; + editingValue?: string; + onEditingValueChange?: (value: string) => void; + onEditKeyDown?: (e: React.KeyboardEvent) => void; + editInputRef?: React.RefObject; + // ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ props + searchHighlights?: Set; + currentSearchIndex?: number; + searchTerm?: string; } export const SingleTableWithSticky: React.FC = ({ @@ -51,6 +62,17 @@ export const SingleTableWithSticky: React.FC = ({ containerWidth, loading = false, error = null, + // ์ธ๋ผ์ธ ํŽธ์ง‘ ๊ด€๋ จ props + onCellDoubleClick, + editingCell, + editingValue, + onEditingValueChange, + onEditKeyDown, + editInputRef, + // ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ props + searchHighlights, + currentSearchIndex = 0, + searchTerm = "", }) => { const checkboxConfig = tableConfig?.checkbox || {}; const actualColumns = visibleColumns || columns || []; @@ -58,14 +80,13 @@ export const SingleTableWithSticky: React.FC = ({ return (
-
+
= ({ }} > {actualColumns.map((column, colIndex) => { @@ -215,9 +229,65 @@ export const SingleTableWithSticky: React.FC = ({ ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) : 0; + // ํ˜„์žฌ ์…€์ด ํŽธ์ง‘ ์ค‘์ธ์ง€ ํ™•์ธ + const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex; + + // ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ํ™•์ธ - ์‹ค์ œ ์…€ ๊ฐ’์— ๊ฒ€์ƒ‰์–ด๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€๋„ ํ™•์ธ + const cellKey = `${index}-${colIndex}`; + const cellValue = String(row[column.columnName] ?? "").toLowerCase(); + const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false; + + // ์ธ๋ฑ์Šค ๊ธฐ๋ฐ˜ ํ•˜์ด๋ผ์ดํŠธ + ์‹ค์ œ ๊ฐ’ ๊ฒ€์ฆ + const isHighlighted = column.columnName !== "__checkbox__" && + hasSearchTerm && + (searchHighlights?.has(cellKey) ?? false); + + // ํ˜„์žฌ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์ธ์ง€ ํ™•์ธ (currentSearchIndex๊ฐ€ -1์ด๋ฉด ํ˜„์žฌ ํŽ˜์ด์ง€์— ์—†์Œ) + const highlightArray = searchHighlights ? Array.from(searchHighlights) : []; + const isCurrentSearchResult = isHighlighted && + currentSearchIndex >= 0 && + currentSearchIndex < highlightArray.length && + highlightArray[currentSearchIndex] === cellKey; + + // ์…€ ๊ฐ’์—์„œ ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง + const renderCellContent = () => { + const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; + + if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") { + return cellValue; + } + + // ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŠธ ์ฒ˜๋ฆฌ + const lowerValue = String(cellValue).toLowerCase(); + const lowerTerm = searchTerm.toLowerCase(); + const startIndex = lowerValue.indexOf(lowerTerm); + + if (startIndex === -1) return cellValue; + + const before = String(cellValue).slice(0, startIndex); + const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); + const after = String(cellValue).slice(startIndex + searchTerm.length); + + return ( + <> + {before} + + {match} + + {after} + + ); + }; + return ( = ({ "sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm", column.fixed === "right" && "sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm", + // ํŽธ์ง‘ ๊ฐ€๋Šฅ ์…€ ์Šคํƒ€์ผ + onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text", )} style={{ width: getColumnWidth(column), @@ -239,10 +311,36 @@ export const SingleTableWithSticky: React.FC = ({ ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} + onDoubleClick={(e) => { + if (onCellDoubleClick && column.columnName !== "__checkbox__") { + e.stopPropagation(); + onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]); + } + }} > - {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"} + {column.columnName === "__checkbox__" ? ( + renderCheckboxCell(row, index) + ) : isEditing ? ( + // ์ธ๋ผ์ธ ํŽธ์ง‘ ์ž…๋ ฅ ํ•„๋“œ + onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={() => { + // blur ์‹œ ์ €์žฅ (Enter์™€ ๋™์ผ) + if (onEditKeyDown) { + const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent; + 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() + )} ); })} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 982a7f8c..9fec8fc5 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4674,20 +4674,22 @@ export const TableListComponent: React.FC = ({ - {/* ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ */} - + {/* ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ (ํ•˜๋‹จ ํŽ˜์ด์ง€๋„ค์ด์…˜) */} + {(tableConfig.toolbar?.showPaginationRefresh ?? true) && ( + + )} ); - }, [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 = ({ {/* ๐Ÿ†• DevExpress ์Šคํƒ€์ผ ๊ธฐ๋Šฅ ํˆด๋ฐ” */}
{/* ํŽธ์ง‘ ๋ชจ๋“œ ํ† ๊ธ€ */} -
- -
+ {(tableConfig.toolbar?.showEditMode ?? true) && ( +
+ +
+ )} {/* ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ๋“ค */} -
- - -
+ {((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && ( +
+ {(tableConfig.toolbar?.showExcel ?? true) && ( + + )} + {(tableConfig.toolbar?.showPdf ?? true) && ( + + )} +
+ )} {/* ๋ณต์‚ฌ ๋ฒ„ํŠผ */} -
- -
+ {(tableConfig.toolbar?.showCopy ?? true) && ( +
+ +
+ )} {/* ์„ ํƒ ์ •๋ณด */} {selectedRows.size > 0 && ( @@ -4861,124 +4873,130 @@ export const TableListComponent: React.FC = ({ )} {/* ๐Ÿ†• ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ํŒจ๋„ */} -
- {isSearchPanelOpen ? ( -
- setGlobalSearchTerm(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - executeGlobalSearch(globalSearchTerm); - } else if (e.key === "Escape") { - clearGlobalSearch(); - } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); + {(tableConfig.toolbar?.showSearch ?? true) && ( +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); + } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } } - } - }} - placeholder="๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ... (Enter)" - className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" - autoFocus - /> - {searchHighlights.size > 0 && ( - - {searchHighlights.size}๊ฐœ + }} + placeholder="๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ... (Enter)" + className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" + autoFocus + /> + {searchHighlights.size > 0 && ( + + {searchHighlights.size}๊ฐœ + + )} + + + +
+ ) : ( + + )} +
+ )} + + {/* ๐Ÿ†• Filter Builder (๊ณ ๊ธ‰ ํ•„ํ„ฐ) ๋ฒ„ํŠผ */} + {(tableConfig.toolbar?.showFilter ?? true) && ( +
+ + {activeFilterCount > 0 && ( - - -
- ) : ( - - )} -
- - {/* ๐Ÿ†• Filter Builder (๊ณ ๊ธ‰ ํ•„ํ„ฐ) ๋ฒ„ํŠผ */} -
- - {activeFilterCount > 0 && ( - - )} -
+
+ )} {/* ์ƒˆ๋กœ๊ณ ์นจ */} -
- -
+ {(tableConfig.toolbar?.showRefresh ?? true) && ( +
+ +
+ )}
{/* ๐Ÿ†• ๋ฐฐ์น˜ ํŽธ์ง‘ ํˆด๋ฐ” */} diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 3ea73aa9..fbdaf6da 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -765,6 +765,81 @@ export const TableListConfigPanel: React.FC = ({ + {/* ํˆด๋ฐ” ๋ฒ„ํŠผ ์„ค์ • */} +
+
+

ํˆด๋ฐ” ๋ฒ„ํŠผ ์„ค์ •

+

ํ…Œ์ด๋ธ” ์ƒ๋‹จ์— ํ‘œ์‹œํ•  ๋ฒ„ํŠผ์„ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค

+
+
+
+
+ handleNestedChange("toolbar", "showEditMode", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showExcel", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showPdf", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showCopy", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showSearch", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showFilter", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showRefresh", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showPaginationRefresh", checked)} + /> + +
+
+
+ {/* ์ฒดํฌ๋ฐ•์Šค ์„ค์ • */}
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index a619baa0..7adb87d1 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -164,6 +164,20 @@ export interface PaginationConfig { pageSizeOptions: number[]; } +/** + * ํˆด๋ฐ” ๋ฒ„ํŠผ ํ‘œ์‹œ ์„ค์ • + */ +export interface ToolbarConfig { + showEditMode?: boolean; // ์ฆ‰์‹œ ์ €์žฅ/๋ฐฐ์น˜ ๋ชจ๋“œ ๋ฒ„ํŠผ + showExcel?: boolean; // Excel ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ + showPdf?: boolean; // PDF ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ + showCopy?: boolean; // ๋ณต์‚ฌ ๋ฒ„ํŠผ + showSearch?: boolean; // ๊ฒ€์ƒ‰ ๋ฒ„ํŠผ + showFilter?: boolean; // ํ•„ํ„ฐ ๋ฒ„ํŠผ + showRefresh?: boolean; // ์ƒ๋‹จ ํˆด๋ฐ” ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ + showPaginationRefresh?: boolean; // ํ•˜๋‹จ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ +} + /** * ์ฒดํฌ๋ฐ•์Šค ์„ค์ • */ @@ -259,6 +273,9 @@ export interface TableListConfig extends ComponentConfig { autoLoad: boolean; refreshInterval?: number; // ์ดˆ ๋‹จ์œ„ + // ๐Ÿ†• ํˆด๋ฐ” ๋ฒ„ํŠผ ํ‘œ์‹œ ์„ค์ • + toolbar?: ToolbarConfig; + // ๐Ÿ†• ์ปฌ๋Ÿผ ๊ฐ’ ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ๋ง dataFilter?: DataFilterConfig; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 8a2d2c17..713f4999 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import { generateNumberingCode } from "@/lib/api/numberingRule"; +import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; @@ -81,11 +81,7 @@ const CascadingSelectField: React.FC = ({ const isDisabled = disabled || !parentValue || loading; return ( - {loading ? (
@@ -127,11 +123,16 @@ export function UniversalFormModalComponent({ isSelected = false, className, style, - initialData, + initialData: propInitialData, + // DynamicComponentRenderer์—์„œ ์ „๋‹ฌ๋˜๋Š” props (DOM ์ „๋‹ฌ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด _ prefix ์‚ฌ์šฉ) + _initialData, onSave, onCancel, onChange, -}: UniversalFormModalComponentProps) { + ...restProps // ๋‚˜๋จธ์ง€ props๋Š” DOM์— ์ „๋‹ฌํ•˜์ง€ ์•Š์Œ +}: UniversalFormModalComponentProps & { _initialData?: any }) { + // initialData ์šฐ์„ ์ˆœ์œ„: ์ง์ ‘ ์ „๋‹ฌ๋œ prop > DynamicComponentRenderer์—์„œ ์ „๋‹ฌ๋œ prop + const initialData = propInitialData || _initialData; // ์„ค์ • ๋ณ‘ํ•ฉ const config: UniversalFormModalConfig = useMemo(() => { const componentConfig = component?.config || {}; @@ -194,10 +195,34 @@ export function UniversalFormModalComponent({ itemId: string; }>({ open: false, sectionId: "", itemId: "" }); - // ์ดˆ๊ธฐํ™” + // ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์บก์ฒ˜ (์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ) + const capturedInitialData = useRef | undefined>(undefined); + const hasInitialized = useRef(false); + + // ์ดˆ๊ธฐํ™” - ์ตœ์ดˆ ๋งˆ์šดํŠธ ์‹œ์—๋งŒ ์‹คํ–‰ useEffect(() => { + // ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋˜์—ˆ์œผ๋ฉด ์Šคํ‚ต + if (hasInitialized.current) { + return; + } + + // ์ตœ์ดˆ initialData ์บก์ฒ˜ (์ดํ›„ ๋ณ€๊ฒฝ๋˜์–ด๋„ ์ด ๊ฐ’ ์‚ฌ์šฉ) + if (initialData && Object.keys(initialData).length > 0) { + capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // ๊นŠ์€ ๋ณต์‚ฌ + } + + hasInitialized.current = true; initializeForm(); - }, [config, initialData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // ๋นˆ ์˜์กด์„ฑ ๋ฐฐ์—ด - ๋งˆ์šดํŠธ ์‹œ ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰ + + // config ๋ณ€๊ฒฝ ์‹œ์—๋งŒ ์žฌ์ดˆ๊ธฐํ™” (initialData ๋ณ€๊ฒฝ์€ ๋ฌด์‹œ) + useEffect(() => { + if (!hasInitialized.current) return; // ์ตœ์ดˆ ์ดˆ๊ธฐํ™” ์ „์ด๋ฉด ์Šคํ‚ต + + initializeForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); // ํ•„๋“œ ๋ ˆ๋ฒจ linkedFieldGroup ๋ฐ์ดํ„ฐ ๋กœ๋“œ useEffect(() => { @@ -216,7 +241,6 @@ export function UniversalFormModalComponent({ // ๊ฐ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ๋กœ๋“œ for (const tableName of tablesToLoad) { if (!linkedFieldDataCache[tableName]) { - console.log(`[UniversalFormModal] linkedFieldGroup ๋ฐ์ดํ„ฐ ๋กœ๋“œ: ${tableName}`); await loadLinkedFieldData(tableName); } } @@ -228,6 +252,9 @@ export function UniversalFormModalComponent({ // ํผ ์ดˆ๊ธฐํ™” const initializeForm = useCallback(async () => { + // ์บก์ฒ˜๋œ initialData ์‚ฌ์šฉ (props๋กœ ์ „๋‹ฌ๋œ initialData๊ฐ€ ์•„๋‹Œ) + const effectiveInitialData = capturedInitialData.current || initialData; + const newFormData: FormDataState = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newCollapsed = new Set(); @@ -253,11 +280,14 @@ export function UniversalFormModalComponent({ // ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • let value = field.defaultValue ?? ""; - // ๋ถ€๋ชจ์—์„œ ์ „๋‹ฌ๋ฐ›์€ ๊ฐ’ ์ ์šฉ - if (field.receiveFromParent && initialData) { + // ๋ถ€๋ชจ์—์„œ ์ „๋‹ฌ๋ฐ›์€ ๊ฐ’ ์ ์šฉ (receiveFromParent ๋˜๋Š” effectiveInitialData์— ํ•ด๋‹น ๊ฐ’์ด ์žˆ์œผ๋ฉด) + if (effectiveInitialData) { const parentField = field.parentFieldName || field.columnName; - if (initialData[parentField] !== undefined) { - value = initialData[parentField]; + if (effectiveInitialData[parentField] !== undefined) { + // receiveFromParent๊ฐ€ true์ด๊ฑฐ๋‚˜, effectiveInitialData์— ๊ฐ’์ด ์žˆ์œผ๋ฉด ์ ์šฉ + if (field.receiveFromParent || value === "" || value === undefined) { + value = effectiveInitialData[parentField]; + } } } @@ -269,11 +299,12 @@ export function UniversalFormModalComponent({ setFormData(newFormData); setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); - setOriginalData(initialData || {}); + setOriginalData(effectiveInitialData || {}); // ์ฑ„๋ฒˆ๊ทœ์น™ ์ž๋™ ์ƒ์„ฑ await generateNumberingValues(newFormData); - }, [config, initialData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); // initialData๋Š” ์˜์กด์„ฑ์—์„œ ์ œ๊ฑฐ (capturedInitialData.current ์‚ฌ์šฉ) // ๋ฐ˜๋ณต ์„น์…˜ ์•„์ดํ…œ ์ƒ์„ฑ const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { @@ -423,15 +454,30 @@ export function UniversalFormModalComponent({ if (optionConfig.type === "static") { options = optionConfig.staticOptions || []; } else if (optionConfig.type === "table" && optionConfig.tableName) { - const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, { - params: { limit: 1000 }, + // POST ๋ฐฉ์‹์œผ๋กœ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ์กฐํšŒ (autoFilter ํฌํ•จ) + const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, { + page: 1, + size: 1000, + autoFilter: { enabled: true, filterColumn: "company_code" }, }); - if (response.data?.success && response.data?.data) { - options = response.data.data.map((row: any) => ({ - value: String(row[optionConfig.valueColumn || "id"]), - label: String(row[optionConfig.labelColumn || "name"]), - })); + + // ์‘๋‹ต ๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ + let dataArray: any[] = []; + if (response.data?.success) { + const responseData = response.data?.data; + if (responseData?.data && Array.isArray(responseData.data)) { + dataArray = responseData.data; + } else if (Array.isArray(responseData)) { + dataArray = responseData; + } else if (responseData?.rows && Array.isArray(responseData.rows)) { + dataArray = responseData.rows; + } } + + options = dataArray.map((row: any) => ({ + value: String(row[optionConfig.valueColumn || "id"]), + label: String(row[optionConfig.labelColumn || "name"]), + })); } else if (optionConfig.type === "code" && optionConfig.codeCategory) { const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`); if (response.data?.success && response.data?.data) { @@ -471,9 +517,9 @@ export function UniversalFormModalComponent({ size: 1000, autoFilter: { enabled: true, filterColumn: "company_code" }, // ํ˜„์žฌ ํšŒ์‚ฌ ๊ธฐ์ค€ ์ž๋™ ํ•„ํ„ฐ๋ง }); - + console.log(`[์—ฐ๋™ํ•„๋“œ] ${sourceTable} API ์‘๋‹ต:`, response.data); - + if (response.data?.success) { // data ๊ตฌ์กฐ ํ™•์ธ: { data: { data: [...], total, page, ... } } ๋˜๋Š” { data: [...] } const responseData = response.data?.data; @@ -534,18 +580,23 @@ export function UniversalFormModalComponent({ } }); - // ์ €์žฅ ์‹œ์  ์ฑ„๋ฒˆ๊ทœ์น™ ์ฒ˜๋ฆฌ + // ์ €์žฅ ์‹œ์  ์ฑ„๋ฒˆ๊ทœ์น™ ์ฒ˜๋ฆฌ (allocateNumberingCode๋กœ ์‹ค์ œ ์ˆœ๋ฒˆ ์ฆ๊ฐ€) for (const section of config.sections) { for (const field of section.fields) { - if ( - field.numberingRule?.enabled && - field.numberingRule?.generateOnSave && - field.numberingRule?.ruleId && - !dataToSave[field.columnName] - ) { - const response = await generateNumberingCode(field.numberingRule.ruleId); - if (response.success && response.data?.generatedCode) { - dataToSave[field.columnName] = response.data.generatedCode; + if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { + // generateOnSave: ์ €์žฅ ์‹œ ์ƒˆ๋กœ ์ƒ์„ฑ + // generateOnOpen: ์—ด ๋•Œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋กœ ํ‘œ์‹œํ–ˆ์ง€๋งŒ, ์ €์žฅ ์‹œ ์‹ค์ œ ์ˆœ๋ฒˆ ํ• ๋‹น ํ•„์š” + if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) { + const response = await allocateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + dataToSave[field.columnName] = response.data.generatedCode; + } + } else if (field.numberingRule.generateOnOpen && dataToSave[field.columnName]) { + // generateOnOpen์ธ ๊ฒฝ์šฐ, ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฐ’์ด ์žˆ๋”๋ผ๋„ ์‹ค์ œ ์ˆœ๋ฒˆ ํ• ๋‹น + const response = await allocateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + dataToSave[field.columnName] = response.data.generatedCode; + } } } } @@ -570,7 +621,6 @@ export function UniversalFormModalComponent({ if (commonFields.length === 0) { const nonRepeatableSections = config.sections.filter((s) => !s.repeatable); commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName)); - console.log("[UniversalFormModal] ๊ณตํ†ต ํ•„๋“œ ์ž๋™ ์„ค์ •:", commonFields); } // ๋ฐ˜๋ณต ์„น์…˜ ID๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ, ์ฒซ ๋ฒˆ์งธ ๋ฐ˜๋ณต ์„น์…˜ ์‚ฌ์šฉ @@ -578,22 +628,9 @@ export function UniversalFormModalComponent({ const repeatableSection = config.sections.find((s) => s.repeatable); if (repeatableSection) { repeatSectionId = repeatableSection.id; - console.log("[UniversalFormModal] ๋ฐ˜๋ณต ์„น์…˜ ์ž๋™ ์„ค์ •:", repeatSectionId); } } - // ๋””๋ฒ„๊น…: ์„ค์ • ํ™•์ธ - console.log("[UniversalFormModal] ๋‹ค์ค‘ ํ–‰ ์ €์žฅ ์„ค์ •:", { - commonFields, - repeatSectionId, - mainSectionFields, - typeColumn, - mainTypeValue, - subTypeValue, - repeatSections, - formData, - }); - // ๋ฐ˜๋ณต ์„น์…˜ ๋ฐ์ดํ„ฐ const repeatItems = repeatSections[repeatSectionId] || []; @@ -616,10 +653,6 @@ export function UniversalFormModalComponent({ } }); - console.log("[UniversalFormModal] ๊ณตํ†ต ๋ฐ์ดํ„ฐ:", commonData); - console.log("[UniversalFormModal] ๋ฉ”์ธ ์„น์…˜ ๋ฐ์ดํ„ฐ:", mainSectionData); - console.log("[UniversalFormModal] ๋ฐ˜๋ณต ํ•ญ๋ชฉ:", repeatItems); - // ๋ฉ”์ธ ํ–‰ (๊ณตํ†ต ๋ฐ์ดํ„ฐ + ๋ฉ”์ธ ์„น์…˜ ํ•„๋“œ) const mainRow: any = { ...commonData, ...mainSectionData }; if (typeColumn) { @@ -651,16 +684,20 @@ export function UniversalFormModalComponent({ if (section.repeatable) continue; for (const field of section.fields) { - if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { - const response = await generateNumberingCode(field.numberingRule.ruleId); - if (response.success && response.data?.generatedCode) { - // ๋ชจ๋“  ํ–‰์— ๋™์ผํ•œ ์ฑ„๋ฒˆ ๊ฐ’ ์ ์šฉ (๊ณตํ†ต ํ•„๋“œ์ธ ๊ฒฝ์šฐ) - if (commonFields.includes(field.columnName)) { - rowsToSave.forEach((row) => { - row[field.columnName] = response.data?.generatedCode; - }); - } else { - rowsToSave[0][field.columnName] = response.data?.generatedCode; + if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { + // generateOnSave ๋˜๋Š” generateOnOpen ๋ชจ๋‘ ์ €์žฅ ์‹œ ์‹ค์ œ ์ˆœ๋ฒˆ ํ• ๋‹น + const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen; + if (shouldAllocate) { + const response = await allocateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + // ๋ชจ๋“  ํ–‰์— ๋™์ผํ•œ ์ฑ„๋ฒˆ ๊ฐ’ ์ ์šฉ (๊ณตํ†ต ํ•„๋“œ์ธ ๊ฒฝ์šฐ) + if (commonFields.includes(field.columnName)) { + rowsToSave.forEach((row) => { + row[field.columnName] = response.data?.generatedCode; + }); + } else { + rowsToSave[0][field.columnName] = response.data?.generatedCode; + } } } } @@ -668,16 +705,11 @@ export function UniversalFormModalComponent({ } // ๋ชจ๋“  ํ–‰ ์ €์žฅ - console.log("[UniversalFormModal] ์ €์žฅํ•  ํ–‰๋“ค:", rowsToSave); - console.log("[UniversalFormModal] ์ €์žฅ ํ…Œ์ด๋ธ”:", config.saveConfig.tableName); - for (let i = 0; i < rowsToSave.length; i++) { const row = rowsToSave[i]; - console.log(`[UniversalFormModal] ${i + 1}๋ฒˆ์งธ ํ–‰ ์ €์žฅ ์‹œ๋„:`, row); // ๋นˆ ๊ฐ์ฒด ์ฒดํฌ if (Object.keys(row).length === 0) { - console.warn(`[UniversalFormModal] ${i + 1}๋ฒˆ์งธ ํ–‰์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค. ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.`); continue; } @@ -687,87 +719,168 @@ export function UniversalFormModalComponent({ throw new Error(response.data?.message || `${i + 1}๋ฒˆ์งธ ํ–‰ ์ €์žฅ ์‹คํŒจ`); } } - - console.log(`[UniversalFormModal] ${rowsToSave.length}๊ฐœ ํ–‰ ์ €์žฅ ์™„๋ฃŒ`); }, [config.sections, config.saveConfig, formData, repeatSections]); - // ์ปค์Šคํ…€ API ์ €์žฅ (์‚ฌ์›+๋ถ€์„œ ํ†ตํ•ฉ ์ €์žฅ ๋“ฑ) - const saveWithCustomApi = useCallback(async () => { + // ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ (๋ฒ”์šฉ) + const saveWithMultiTable = useCallback(async () => { const { customApiSave } = config.saveConfig; - if (!customApiSave) return; + if (!customApiSave?.multiTable) return; - console.log("[UniversalFormModal] ์ปค์Šคํ…€ API ์ €์žฅ ์‹œ์ž‘:", customApiSave.apiType); + const { multiTable } = customApiSave; - const saveUserWithDeptApi = async () => { - const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; - - // 1. userInfo ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ - const userInfo: Record = {}; - - // ๋ชจ๋“  ํ•„๋“œ์—์„œ user_info์— ํ•ด๋‹นํ•˜๋Š” ๋ฐ์ดํ„ฐ ์ถ”์ถœ - config.sections.forEach((section) => { - if (section.repeatable) return; // ๋ฐ˜๋ณต ์„น์…˜์€ ์ œ์™ธ - - section.fields.forEach((field) => { - const value = formData[field.columnName]; - if (value !== undefined && value !== null && value !== "") { - userInfo[field.columnName] = value; - } - }); + // 1. ๋ฉ”์ธ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + const mainData: Record = {}; + config.sections.forEach((section) => { + if (section.repeatable) return; // ๋ฐ˜๋ณต ์„น์…˜์€ ์ œ์™ธ + section.fields.forEach((field) => { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + mainData[field.columnName] = value; + } }); + }); - // 2. mainDept ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ - let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; - - if (mainDeptFields) { - const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; - if (deptCode) { - mainDept = { - dept_code: deptCode, - dept_name: formData[mainDeptFields.deptNameField || "dept_name"], - position_name: formData[mainDeptFields.positionNameField || "position_name"], - }; + // 1-1. ์ฑ„๋ฒˆ๊ทœ์น™ ์ฒ˜๋ฆฌ (์ €์žฅ ์‹œ์ ์— ์‹ค์ œ ์ˆœ๋ฒˆ ํ• ๋‹น) + for (const section of config.sections) { + if (section.repeatable) continue; + + for (const field of section.fields) { + // ์ฑ„๋ฒˆ๊ทœ์น™์ด ํ™œ์„ฑํ™”๋œ ํ•„๋“œ ์ฒ˜๋ฆฌ + if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { + // ์‹ ๊ทœ ์ƒ์„ฑ์ด๊ฑฐ๋‚˜ ๊ฐ’์ด ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ฑ„๋ฒˆ + const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn]; + const hasNoValue = !mainData[field.columnName]; + + if (isNewRecord || hasNoValue) { + try { + // allocateNumberingCode๋กœ ์‹ค์ œ ์ˆœ๋ฒˆ ์ฆ๊ฐ€ + const response = await allocateNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + mainData[field.columnName] = response.data.generatedCode; + } + } catch (error) { + console.error(`์ฑ„๋ฒˆ๊ทœ์น™ ํ• ๋‹น ์‹คํŒจ (${field.columnName}):`, error); + } + } + } + } + } + + // 2. ์„œ๋ธŒ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + const subTablesData: Array<{ + tableName: string; + linkColumn: { mainField: string; subColumn: string }; + items: Record[]; + 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[] = []; + const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; + + // ๋ฐ˜๋ณต ์„น์…˜ ๋ฐ์ดํ„ฐ๋ฅผ ํ•„๋“œ ๋งคํ•‘์— ๋”ฐ๋ผ ๋ณ€ํ™˜ + for (const item of repeatData) { + const mappedItem: Record = {}; + + // ์—ฐ๊ฒฐ ์ปฌ๋Ÿผ ๊ฐ’ ์„ค์ • + if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { + mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; + } + + // ํ•„๋“œ ๋งคํ•‘์— ๋”ฐ๋ผ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + mappedItem[mapping.targetColumn] = item[mapping.formField]; + } + } + + // ๋ฉ”์ธ/์„œ๋ธŒ ๊ตฌ๋ถ„ ์ปฌ๋Ÿผ ์„ค์ • (์„œ๋ธŒ ๋ฐ์ดํ„ฐ๋Š” ์„œ๋ธŒ ๋งˆ์ปค ๊ฐ’) + if (subTableConfig.options?.mainMarkerColumn) { + mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; + } + + if (Object.keys(mappedItem).length > 0) { + subItems.push(mappedItem); } } - // 3. subDepts ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ (๋ฐ˜๋ณต ์„น์…˜์—์„œ) - const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; - - if (subDeptSectionId && repeatSections[subDeptSectionId]) { - const subDeptItems = repeatSections[subDeptSectionId]; - const deptCodeField = subDeptFields?.deptCodeField || "dept_code"; - const deptNameField = subDeptFields?.deptNameField || "dept_name"; - const positionNameField = subDeptFields?.positionNameField || "position_name"; + // saveMainAsFirst๊ฐ€ ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ, ๋ฉ”์ธ ๋ฐ์ดํ„ฐ๋ฅผ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๊ธฐ ์œ„ํ•œ ๋งคํ•‘ ์ƒ์„ฑ + let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined; + if (subTableConfig.options?.saveMainAsFirst) { + mainFieldMappings = []; - subDeptItems.forEach((item) => { - const deptCode = item[deptCodeField]; - if (deptCode) { - subDepts.push({ - dept_code: deptCode, - dept_name: item[deptNameField], - position_name: item[positionNameField], - }); + // ๋ฉ”์ธ ์„น์…˜(๋น„๋ฐ˜๋ณต)์˜ ํ•„๋“œ๋“ค์„ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ๋งคํ•‘ + // ์„œ๋ธŒ ํ…Œ์ด๋ธ”์˜ fieldMappings์—์„œ targetColumn์„ ์ฐพ์•„์„œ ๋งคํ•‘ + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.targetColumn) { + // ๋ฉ”์ธ ๋ฐ์ดํ„ฐ์—์„œ ๋™์ผํ•œ ์ปฌ๋Ÿผ๋ช…์ด ์žˆ์œผ๋ฉด ๋งคํ•‘ + if (mainData[mapping.targetColumn] !== undefined) { + mainFieldMappings.push({ + formField: mapping.targetColumn, + targetColumn: mapping.targetColumn, + }); + } + // ๋˜๋Š” ๋ฉ”์ธ ์„น์…˜์˜ ํ•„๋“œ ์ค‘ ๊ฐ™์€ ์ด๋ฆ„์ด ์žˆ์œผ๋ฉด ๋งคํ•‘ + else { + config.sections.forEach((section) => { + if (section.repeatable) return; + const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn); + if (matchingField && mainData[matchingField.columnName] !== undefined) { + mainFieldMappings!.push({ + formField: matchingField.columnName, + targetColumn: mapping.targetColumn, + }); + } + }); + } } - }); + } + + // ์ค‘๋ณต ์ œ๊ฑฐ + mainFieldMappings = mainFieldMappings.filter( + (m, idx, arr) => arr.findIndex((x) => x.targetColumn === m.targetColumn) === idx, + ); } - // 4. API ํ˜ธ์ถœ - console.log("[UniversalFormModal] ์‚ฌ์›+๋ถ€์„œ ์ €์žฅ ๋ฐ์ดํ„ฐ:", { userInfo, mainDept, subDepts }); - - const { saveUserWithDept } = await import("@/lib/api/user"); - const response = await saveUserWithDept({ - userInfo: userInfo as any, - mainDept, - subDepts, - isUpdate: !!initialData?.user_id, // ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์ˆ˜์ • ๋ชจ๋“œ + subTablesData.push({ + tableName: subTableConfig.tableName, + linkColumn: subTableConfig.linkColumn, + items: subItems, + options: { + ...subTableConfig.options, + mainFieldMappings, // ๋ฉ”์ธ ๋ฐ์ดํ„ฐ ๋งคํ•‘ ์ถ”๊ฐ€ + }, }); + } - if (!response.success) { - throw new Error(response.message || "์‚ฌ์› ์ €์žฅ ์‹คํŒจ"); - } + // 3. ๋ฒ”์šฉ ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ API ํ˜ธ์ถœ + const response = await apiClient.post("/table-management/multi-table-save", { + mainTable: multiTable.mainTable, + mainData, + subTables: subTablesData, + isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn], + }); - console.log("[UniversalFormModal] ์‚ฌ์›+๋ถ€์„œ ์ €์žฅ ์™„๋ฃŒ:", response.data); - }; + if (!response.data?.success) { + throw new Error(response.data?.message || "๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์‹คํŒจ"); + } + }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + + // ์ปค์Šคํ…€ API ์ €์žฅ + const saveWithCustomApi = useCallback(async () => { + const { customApiSave } = config.saveConfig; + if (!customApiSave) return; const saveWithGenericCustomApi = async () => { if (!customApiSave.customEndpoint) { @@ -789,9 +902,10 @@ export function UniversalFormModalComponent({ } const method = customApiSave.customMethod || "POST"; - const response = method === "PUT" - ? await apiClient.put(customApiSave.customEndpoint, dataToSave) - : await apiClient.post(customApiSave.customEndpoint, dataToSave); + const response = + method === "PUT" + ? await apiClient.put(customApiSave.customEndpoint, dataToSave) + : await apiClient.post(customApiSave.customEndpoint, dataToSave); if (!response.data?.success) { throw new Error(response.data?.message || "์ €์žฅ ์‹คํŒจ"); @@ -799,8 +913,8 @@ export function UniversalFormModalComponent({ }; switch (customApiSave.apiType) { - case "user-with-dept": - await saveUserWithDeptApi(); + case "multi-table": + await saveWithMultiTable(); break; case "custom": await saveWithGenericCustomApi(); @@ -808,7 +922,7 @@ export function UniversalFormModalComponent({ default: throw new Error(`์ง€์›ํ•˜์ง€ ์•Š๋Š” API ํƒ€์ž…: ${customApiSave.apiType}`); } - }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + }, [config.saveConfig, formData, repeatSections, saveWithMultiTable]); // ์ €์žฅ ์ฒ˜๋ฆฌ const handleSave = useCallback(async () => { @@ -869,7 +983,16 @@ export function UniversalFormModalComponent({ } finally { setSaving(false); } - }, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]); + }, [ + config, + formData, + repeatSections, + onSave, + validateRequiredFields, + saveSingleRow, + saveMultipleRows, + saveWithCustomApi, + ]); // ํผ ์ดˆ๊ธฐํ™” const handleReset = useCallback(() => { @@ -878,12 +1001,14 @@ export function UniversalFormModalComponent({ }, [initializeForm]); // ํ•„๋“œ ์š”์†Œ ๋ Œ๋”๋ง (์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋งŒ) + // repeatContext: ๋ฐ˜๋ณต ์„น์…˜์ธ ๊ฒฝ์šฐ { sectionId, itemId }๋ฅผ ์ „๋‹ฌ const renderFieldElement = ( field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string, isDisabled: boolean, + repeatContext?: { sectionId: string; itemId: string }, ) => { return (() => { switch (field.fieldType) { @@ -920,7 +1045,7 @@ export function UniversalFormModalComponent({ if (field.cascading?.enabled) { const cascadingConfig = field.cascading; const parentValue = formData[cascadingConfig.parentField]; - + return ( ); } - + // ๋‹ค์ค‘ ์ปฌ๋Ÿผ ์ €์žฅ์ด ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ const lfgMappings = field.linkedFieldGroup?.mappings; - if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) { + if ( + field.linkedFieldGroup?.enabled && + field.linkedFieldGroup?.sourceTable && + lfgMappings && + lfgMappings.length > 0 + ) { const lfg = field.linkedFieldGroup; const sourceTableName = lfg.sourceTable as string; const cachedData = linkedFieldDataCache[sourceTableName]; @@ -980,11 +1110,24 @@ export function UniversalFormModalComponent({ lfg.mappings.forEach((mapping) => { if (mapping.sourceColumn && mapping.targetColumn) { const mappedValue = selectedRow[mapping.sourceColumn]; - // formData์— ์ง์ ‘ ์ €์žฅ - setFormData((prev) => ({ - ...prev, - [mapping.targetColumn]: mappedValue, - })); + + // ๋ฐ˜๋ณต ์„น์…˜์ธ ๊ฒฝ์šฐ repeatSections์— ์ €์žฅ, ์•„๋‹ˆ๋ฉด formData์— ์ €์žฅ + if (repeatContext) { + setRepeatSections((prev) => { + const items = prev[repeatContext.sectionId] || []; + const newItems = items.map((item) => + item._id === repeatContext.itemId + ? { ...item, [mapping.targetColumn]: mappedValue } + : item, + ); + return { ...prev, [repeatContext.sectionId]: newItems }; + }); + } else { + setFormData((prev) => ({ + ...prev, + [mapping.targetColumn]: mappedValue, + })); + } } }); } @@ -997,10 +1140,7 @@ export function UniversalFormModalComponent({ {sourceData.length > 0 ? ( sourceData.map((row, index) => ( - + {getDisplayText(row)} )) @@ -1127,12 +1267,14 @@ export function UniversalFormModalComponent({ }; // ํ•„๋“œ ๋ Œ๋”๋ง (์„น์…˜ ์—ด ์ˆ˜ ์ ์šฉ) + // repeatContext: ๋ฐ˜๋ณต ์„น์…˜์ธ ๊ฒฝ์šฐ { sectionId, itemId }๋ฅผ ์ „๋‹ฌ const renderFieldWithColumns = ( field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string, sectionColumns: number = 2, + repeatContext?: { sectionId: string; itemId: string }, ) => { // ์„น์…˜ ์—ด ์ˆ˜์— ๋”ฐ๋ฅธ ๊ธฐ๋ณธ gridSpan ๊ณ„์‚ฐ (์„น์…˜ ์—ด ์ˆ˜๊ฐ€ ์šฐ์„ ) const defaultSpan = getDefaultGridSpan(sectionColumns); @@ -1146,7 +1288,7 @@ export function UniversalFormModalComponent({ return null; } - const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled); + const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled, repeatContext); if (field.fieldType === "checkbox") { return ( @@ -1286,6 +1428,7 @@ export function UniversalFormModalComponent({ (value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value), `${section.id}-${item._id}-${field.id}`, sectionColumns, + { sectionId: section.id, itemId: item._id }, // ๋ฐ˜๋ณต ์„น์…˜ ์ปจํ…์ŠคํŠธ ์ „๋‹ฌ ), )}
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 8552cd6f..e503ebfb 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -108,6 +108,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.sections]); + // ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ •์˜ ๋ฉ”์ธ/์„œ๋ธŒ ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ๋กœ๋“œ + useEffect(() => { + const customApiSave = config.saveConfig.customApiSave; + if (customApiSave?.enabled && customApiSave?.multiTable) { + // ๋ฉ”์ธ ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ๋กœ๋“œ + const mainTableName = customApiSave.multiTable.mainTable?.tableName; + if (mainTableName && !tableColumns[mainTableName]) { + loadTableColumns(mainTableName); + } + // ์„œ๋ธŒ ํ…Œ์ด๋ธ”๋“ค ์ปฌ๋Ÿผ ๋กœ๋“œ + customApiSave.multiTable.subTables?.forEach((subTable) => { + if (subTable.tableName && !tableColumns[subTable.tableName]) { + loadTableColumns(subTable.tableName); + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.saveConfig.customApiSave]); + const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); @@ -425,58 +444,58 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
) : ( <> - - - - - - - - - ํ…Œ์ด๋ธ”์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค - - {tables.map((t) => ( - { - updateSaveConfig({ tableName: t.name }); - setTableSelectOpen(false); - }} - className="text-xs" - > - - {t.name} - {t.label !== t.name && ( - ({t.label}) - )} - - ))} - - - - - - {config.saveConfig.tableName && ( -

- ์ปฌ๋Ÿผ {currentColumns.length}๊ฐœ ๋กœ๋“œ๋จ -

+ + + + + + + + + ํ…Œ์ด๋ธ”์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + setTableSelectOpen(false); + }} + className="text-xs" + > + + {t.name} + {t.label !== t.name && ( + ({t.label}) + )} + + ))} + + + + + + {config.saveConfig.tableName && ( +

+ ์ปฌ๋Ÿผ {currentColumns.length}๊ฐœ ๋กœ๋“œ๋จ +

)} )} @@ -592,29 +611,41 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
)} - {/* ์ปค์Šคํ…€ API ์ €์žฅ ์„ค์ • */} -
-
- ์ „์šฉ API ์ €์žฅ + {/* ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ • (๋ฒ”์šฉ) */} +
+
+ ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ updateSaveConfig({ - customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" }, + customApiSave: { + ...config.saveConfig.customApiSave, + enabled: checked, + apiType: "multi-table", + multiTable: checked ? { + enabled: true, + mainTable: { tableName: config.saveConfig.tableName || "", primaryKeyColumn: "" }, + subTables: [], + } : undefined, + }, }) } />
- ํ…Œ์ด๋ธ” ์ง์ ‘ ์ €์žฅ ๋Œ€์‹  ์ „์šฉ ๋ฐฑ์—”๋“œ API๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง(๋‹ค์ค‘ ํ…Œ์ด๋ธ”, ํŠธ๋žœ์žญ์…˜)์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค. + + ๋ฉ”์ธ ํ…Œ์ด๋ธ” + ์„œ๋ธŒ ํ…Œ์ด๋ธ”(๋ฐ˜๋ณต ์„น์…˜)์— ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. +
์˜ˆ: ์‚ฌ์›+๋ถ€์„œ, ์ฃผ๋ฌธ+์ฃผ๋ฌธ์ƒ์„ธ, ํ”„๋กœ์ ํŠธ+๋ฉค๋ฒ„ ๋“ฑ +
{config.saveConfig.customApiSave?.enabled && ( -
+
{/* API ํƒ€์ž… ์„ ํƒ */}
- +
- {/* ์‚ฌ์›+๋ถ€์„œ ํ†ตํ•ฉ ์ €์žฅ ์„ค์ • */} - {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( -
-

- user_info์™€ user_dept ํ…Œ์ด๋ธ”์— ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. - ๋ฉ”์ธ ๋ถ€์„œ ๋ณ€๊ฒฝ ์‹œ ๊ธฐ์กด ๋ฉ”์ธ์€ ๊ฒธ์ง์œผ๋กœ ์ž๋™ ์ „ํ™˜๋ฉ๋‹ˆ๋‹ค. -

- - {/* ๋ฉ”์ธ ๋ถ€์„œ ํ•„๋“œ ๋งคํ•‘ */} -
- -
-
- ๋ถ€์„œ์ฝ”๋“œ: - -
-
- ๋ถ€์„œ๋ช…: - -
-
- ์ง๊ธ‰: - -
+ {/* ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ • */} + {config.saveConfig.customApiSave?.apiType === "multi-table" && ( +
+ {/* ๋ฉ”์ธ ํ…Œ์ด๋ธ” ์„ค์ • */} +
+ + ๋น„๋ฐ˜๋ณต ์„น์…˜์˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋  ๋ฉ”์ธ ํ…Œ์ด๋ธ”์ž…๋‹ˆ๋‹ค. + +
+ + + + + + + + + + ํ…Œ์ด๋ธ”์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค + + {tables.map((table) => ( + { + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...config.saveConfig.customApiSave?.multiTable?.mainTable, + tableName: table.name, + }, + subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], + }, + }, + }); + // ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ๋กœ๋“œ + if (!tableColumns[table.name]) { + loadTableColumns(table.name); + } + }} + className="text-[10px]" + > + +
+ {table.label || table.name} + {table.label && {table.name}} +
+
+ ))} +
+
+
+
+
-
- - {/* ๊ฒธ์ง ๋ถ€์„œ ๋ฐ˜๋ณต ์„น์…˜ */} -
- - + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...config.saveConfig.customApiSave?.multiTable?.mainTable, + tableName: config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || "", + primaryKeyColumn: value === "_none_" ? "" : value, + }, + subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], + }, + }, + }) + } + > + + + + + ์„ ํƒ ์•ˆํ•จ + {(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => ( + + {col.label || col.name} ))} - - + + + ์„œ๋ธŒ ํ…Œ์ด๋ธ”๊ณผ ์—ฐ๊ฒฐํ•  ๋•Œ ์‚ฌ์šฉํ•  PK ์ปฌ๋Ÿผ +
- {/* ๊ฒธ์ง ๋ถ€์„œ ํ•„๋“œ ๋งคํ•‘ */} - {config.saveConfig.customApiSave?.subDeptSectionId && ( -
- -
-
- ๋ถ€์„œ์ฝ”๋“œ: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { ...newSubTables[subIndex], repeatSectionId: value === "_none_" ? "" : value }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + > + + + ์„ ํƒ ์•ˆํ•จ {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} + .filter((s) => s.repeatable) + .map((section) => ( + + ๋ฐ˜๋ณต ์„น์…˜: {section.title} ))}
-
- ๋ถ€์„œ๋ช…: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + linkColumn: { ...newSubTables[subIndex].linkColumn, mainField: value === "_none_" ? "" : value }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, }, - }, - }) - } - > - - - - - {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} + }); + }} + > + + + + + ์„ ํƒ + {/* ๋ฉ”์ธ ํ…Œ์ด๋ธ”์˜ ์ปฌ๋Ÿผ ๋ชฉ๋ก์—์„œ ์„ ํƒ */} + {(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => ( + + {col.label || col.name} ))} - - + + +
โ†“
+ {/* ์„œ๋ธŒ ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์„ ํƒ (FK ์ปฌ๋Ÿผ) */} + +
+ ๋ฉ”์ธ ํ…Œ์ด๋ธ”๊ณผ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์„ ์—ฐ๊ฒฐํ•  ์ปฌ๋Ÿผ
-
- ์ง๊ธ‰: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + const newMappings = [...(newSubTables[subIndex].fieldMappings || [])]; + newMappings[mapIndex] = { ...newMappings[mapIndex], formField: value === "_none_" ? "" : value }; + newSubTables[subIndex] = { ...newSubTables[subIndex], fieldMappings: newMappings }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + > + + + + + ์„ ํƒ + {sectionFields + .filter((f) => f.columnName && f.columnName.trim() !== "") + .map((field) => ( + + {field.label} + + ))} + + +
โ†“
+ +
+ ); + })} +
+ )} + + {/* ์ถ”๊ฐ€ ์˜ต์…˜ */} +
+ +
+ { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + options: { ...newSubTables[subIndex].options, saveMainAsFirst: !!checked }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, }, - }, - }) - } - > - - - - - {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} - - ))} - - + }); + }} + className="shrink-0" + /> + +
+ + {subTable.options?.saveMainAsFirst && ( +
+ + + ๋ฉ”์ธ/์„œ๋ธŒ ๊ตฌ๋ถ„์šฉ ์ปฌ๋Ÿผ (์˜ˆ: is_primary) +
+ )} + +
+ { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + options: { ...newSubTables[subIndex].options, deleteExistingBefore: !!checked }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + className="shrink-0" + /> + +
-
- )} + ))} + + {(config.saveConfig.customApiSave?.multiTable?.subTables || []).length === 0 && ( +

+ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์„ ์ถ”๊ฐ€ํ•˜์„ธ์š” +

+ )} +
)} {/* ์ปค์Šคํ…€ API ์„ค์ • */} {config.saveConfig.customApiSave?.apiType === "custom" && (
-
+
- - updateSaveConfig({ + onChange={(e) => + updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, - }) - } + }) + } placeholder="/api/custom/endpoint" - className="h-6 text-[10px] mt-1" - /> -
-
+ className="h-6 text-[10px] mt-1" + /> +
+
-
+
)}
@@ -1571,9 +1983,9 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* ๋‹ค์ค‘ ์ปฌ๋Ÿผ ์ €์žฅ (select ํƒ€์ž…๋งŒ) */} {selectedField.fieldType === "select" && ( -
-
- ๋‹ค์ค‘ ์ปฌ๋Ÿผ ์ €์žฅ +
+
+ ๋‹ค์ค‘ ์ปฌ๋Ÿผ ์ €์žฅ @@ -1592,10 +2004,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {selectedField.linkedFieldGroup?.enabled && ( -
+
{/* ์†Œ์Šค ํ…Œ์ด๋ธ” */} -
- +
+ { + const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) => + i === mappingIndex ? { ...m, sourceColumn: value } : m + ); + updateField(selectedSection.id, selectedField.id, { + linkedFieldGroup: { + ...selectedField.linkedFieldGroup, + mappings: updatedMappings, + }, + }); + }} + > + + + + + {(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => ( + + {col.label || col.name} + + ))} + + +
+
โ†“
+ {/* ์ €์žฅํ•  ํ…Œ์ด๋ธ” ์„ ํƒ */} +
+ + +
+ {/* ์ €์žฅํ•  ์ปฌ๋Ÿผ ์„ ํƒ */} +
+ + +
-
- - -
-
- - -
-
- ))} + ); + })} {(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (

diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 40ee48e0..d4b109d5 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -123,6 +123,7 @@ export interface FormFieldConfig { // ์—ฐ๋™ ํ•„๋“œ ๋งคํ•‘ ์„ค์ • export interface LinkedFieldMapping { sourceColumn: string; // ์†Œ์Šค ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ (์˜ˆ: "dept_code") + targetTable?: string; // ์ €์žฅํ•  ํ…Œ์ด๋ธ” (์„ ํƒ, ์—†์œผ๋ฉด ์ž๋™ ๊ฒฐ์ •) targetColumn: string; // ์ €์žฅํ•  ์ปฌ๋Ÿผ (์˜ˆ: "position_code") } @@ -209,42 +210,92 @@ export interface SaveConfig { }; } +/** + * ์„œ๋ธŒ ํ…Œ์ด๋ธ” ํ•„๋“œ ๋งคํ•‘ + * ํผ ํ•„๋“œ(columnName)๋ฅผ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์˜ ์ปฌ๋Ÿผ์— ๋งคํ•‘ํ•ฉ๋‹ˆ๋‹ค. + */ +export interface SubTableFieldMapping { + formField: string; // ํผ ํ•„๋“œ์˜ columnName + targetColumn: string; // ์„œ๋ธŒ ํ…Œ์ด๋ธ”์˜ ์ปฌ๋Ÿผ๋ช… +} + +/** + * ์„œ๋ธŒ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ • + * ๋ฐ˜๋ณต ์„น์…˜์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅํ•˜๋Š” ์„ค์ •์ž…๋‹ˆ๋‹ค. + */ +export interface SubTableSaveConfig { + enabled: boolean; + tableName: string; // ์„œ๋ธŒ ํ…Œ์ด๋ธ”๋ช… (์˜ˆ: user_dept, order_items) + repeatSectionId: string; // ์—ฐ๊ฒฐํ•  ๋ฐ˜๋ณต ์„น์…˜ ID + + // ์—ฐ๊ฒฐ ์„ค์ • (๋ฉ”์ธ ํ…Œ์ด๋ธ”๊ณผ ์„œ๋ธŒ ํ…Œ์ด๋ธ” ์—ฐ๊ฒฐ) + linkColumn: { + mainField: string; // ๋ฉ”์ธ ํ…Œ์ด๋ธ”์˜ ์—ฐ๊ฒฐ ํ•„๋“œ (์˜ˆ: user_id) + subColumn: string; // ์„œ๋ธŒ ํ…Œ์ด๋ธ”์˜ ์—ฐ๊ฒฐ ์ปฌ๋Ÿผ (์˜ˆ: user_id) + }; + + // ํ•„๋“œ ๋งคํ•‘ (๋ฐ˜๋ณต ์„น์…˜ ํ•„๋“œ โ†’ ์„œ๋ธŒ ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ) + fieldMappings: SubTableFieldMapping[]; + + // ์ถ”๊ฐ€ ์˜ต์…˜ + options?: { + // ๋ฉ”์ธ ๋ฐ์ดํ„ฐ๋„ ์„œ๋ธŒ ํ…Œ์ด๋ธ”์— ์ €์žฅ (1:N์—์„œ ๋ฉ”์ธ๋„ ์ €์žฅํ•  ๋•Œ) + saveMainAsFirst?: boolean; + mainFieldMappings?: SubTableFieldMapping[]; // ๋ฉ”์ธ ๋ฐ์ดํ„ฐ์šฉ ํ•„๋“œ ๋งคํ•‘ + mainMarkerColumn?: string; // ๋ฉ”์ธ ์—ฌ๋ถ€ ํ‘œ์‹œ ์ปฌ๋Ÿผ (์˜ˆ: is_primary) + mainMarkerValue?: any; // ๋ฉ”์ธ์ผ ๋•Œ ๊ฐ’ (์˜ˆ: true) + subMarkerValue?: any; // ์„œ๋ธŒ์ผ ๋•Œ ๊ฐ’ (์˜ˆ: false) + + // ์ €์žฅ ์ „ ๊ธฐ์กด ๋ฐ์ดํ„ฐ ์‚ญ์ œ + deleteExistingBefore?: boolean; + deleteOnlySubItems?: boolean; // ๋ฉ”์ธ ํ•ญ๋ชฉ์€ ์œ ์ง€ํ•˜๊ณ  ์„œ๋ธŒ๋งŒ ์‚ญ์ œ + }; +} + +/** + * ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ • (๋ฒ”์šฉ) + * + * ๋ฉ”์ธ ํ…Œ์ด๋ธ” + ์„œ๋ธŒ ํ…Œ์ด๋ธ”(๋“ค)์— ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + * + * ## ์‚ฌ์šฉ ์˜ˆ์‹œ + * + * ### ์‚ฌ์› + ๋ถ€์„œ (user_info + user_dept) + * - ๋ฉ”์ธ ํ…Œ์ด๋ธ”: user_info (์‚ฌ์› ์ •๋ณด) + * - ์„œ๋ธŒ ํ…Œ์ด๋ธ”: user_dept (๋ถ€์„œ ๊ด€๊ณ„, ๋ฉ”์ธ ๋ถ€์„œ + ๊ฒธ์ง ๋ถ€์„œ) + * + * ### ์ฃผ๋ฌธ + ์ฃผ๋ฌธ์ƒ์„ธ (orders + order_items) + * - ๋ฉ”์ธ ํ…Œ์ด๋ธ”: orders (์ฃผ๋ฌธ ์ •๋ณด) + * - ์„œ๋ธŒ ํ…Œ์ด๋ธ”: order_items (์ฃผ๋ฌธ ์ƒํ’ˆ ๋ชฉ๋ก) + */ +export interface MultiTableSaveConfig { + enabled: boolean; + + // ๋ฉ”์ธ ํ…Œ์ด๋ธ” ์„ค์ • + mainTable: { + tableName: string; // ๋ฉ”์ธ ํ…Œ์ด๋ธ”๋ช… + primaryKeyColumn: string; // PK ์ปฌ๋Ÿผ๋ช… + }; + + // ์„œ๋ธŒ ํ…Œ์ด๋ธ” ์„ค์ • (์—ฌ๋Ÿฌ ๊ฐœ ๊ฐ€๋Šฅ) + subTables: SubTableSaveConfig[]; +} + /** * ์ปค์Šคํ…€ API ์ €์žฅ ์„ค์ • * * ํ…Œ์ด๋ธ” ์ง์ ‘ ์ €์žฅ ๋Œ€์‹  ์ „์šฉ ๋ฐฑ์—”๋“œ API๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. * ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง(๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ, ํŠธ๋žœ์žญ์…˜ ๋“ฑ)์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. - * - * ## ์ง€์›ํ•˜๋Š” API ํƒ€์ž… - * - `user-with-dept`: ์‚ฌ์› + ๋ถ€์„œ ํ†ตํ•ฉ ์ €์žฅ (/api/admin/users/with-dept) - * - * ## ๋ฐ์ดํ„ฐ ๋งคํ•‘ ์„ค์ • - * - `userInfoFields`: user_info ํ…Œ์ด๋ธ”์— ์ €์žฅํ•  ํ•„๋“œ ๋งคํ•‘ - * - `mainDeptFields`: ๋ฉ”์ธ ๋ถ€์„œ ์ •๋ณด ํ•„๋“œ ๋งคํ•‘ - * - `subDeptSectionId`: ๊ฒธ์ง ๋ถ€์„œ ๋ฐ˜๋ณต ์„น์…˜ ID */ export interface CustomApiSaveConfig { enabled: boolean; - apiType: "user-with-dept" | "custom"; // ํ™•์žฅ ๊ฐ€๋Šฅํ•œ API ํƒ€์ž… + apiType: "multi-table" | "custom"; // API ํƒ€์ž… - // user-with-dept ์ „์šฉ ์„ค์ • - userInfoFields?: string[]; // user_info์— ์ €์žฅํ•  ํ•„๋“œ ๋ชฉ๋ก (columnName) - mainDeptFields?: { - deptCodeField?: string; // ๋ฉ”์ธ ๋ถ€์„œ์ฝ”๋“œ ํ•„๋“œ๋ช… - deptNameField?: string; // ๋ฉ”์ธ ๋ถ€์„œ๋ช… ํ•„๋“œ๋ช… - positionNameField?: string; // ๋ฉ”์ธ ์ง๊ธ‰ ํ•„๋“œ๋ช… - }; - subDeptSectionId?: string; // ๊ฒธ์ง ๋ถ€์„œ ๋ฐ˜๋ณต ์„น์…˜ ID - subDeptFields?: { - deptCodeField?: string; // ๊ฒธ์ง ๋ถ€์„œ์ฝ”๋“œ ํ•„๋“œ๋ช… - deptNameField?: string; // ๊ฒธ์ง ๋ถ€์„œ๋ช… ํ•„๋“œ๋ช… - positionNameField?: string; // ๊ฒธ์ง ์ง๊ธ‰ ํ•„๋“œ๋ช… - }; + // ๋‹ค์ค‘ ํ…Œ์ด๋ธ” ์ €์žฅ ์„ค์ • (๋ฒ”์šฉ) + multiTable?: MultiTableSaveConfig; // ์ปค์Šคํ…€ API ์ „์šฉ ์„ค์ • customEndpoint?: string; // ์ปค์Šคํ…€ API ์—”๋“œํฌ์ธํŠธ customMethod?: "POST" | "PUT"; // HTTP ๋ฉ”์„œ๋“œ - customDataTransform?: string; // ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ํ•จ์ˆ˜๋ช… (์ถ”ํ›„ ํ™•์žฅ) } // ๋ชจ๋‹ฌ ์„ค์ • diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b3d080b0..02168e8f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3836,6 +3836,7 @@ export class ButtonActionExecutor { const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); if (keyValue) { + // ์ƒํƒœ ์—…๋ฐ์ดํŠธ await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, @@ -3844,6 +3845,37 @@ export class ButtonActionExecutor { updateValue: config.trackingStatusOnStart, }); console.log("โœ… ์ƒํƒœ ๋ณ€๊ฒฝ ์™„๋ฃŒ:", config.trackingStatusOnStart); + + // ๐Ÿ†• ์ถœ๋ฐœ์ง€/๋„์ฐฉ์ง€๋„ vehicles ํ…Œ์ด๋ธ”์— ์ €์žฅ + if (departure) { + try { + await apiClient.put("/dynamic-form/update-field", { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: "departure", + updateValue: departure, + }); + console.log("โœ… ์ถœ๋ฐœ์ง€ ์ €์žฅ ์™„๋ฃŒ:", departure); + } catch { + // ์ปฌ๋Ÿผ์ด ์—†์œผ๋ฉด ๋ฌด์‹œ + } + } + + if (arrival) { + try { + await apiClient.put("/dynamic-form/update-field", { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: "arrival", + updateValue: arrival, + }); + console.log("โœ… ๋„์ฐฉ์ง€ ์ €์žฅ ์™„๋ฃŒ:", arrival); + } catch { + // ์ปฌ๋Ÿผ์ด ์—†์œผ๋ฉด ๋ฌด์‹œ + } + } } } catch (statusError) { console.warn("โš ๏ธ ์ƒํƒœ ๋ณ€๊ฒฝ ์‹คํŒจ:", statusError); @@ -4050,6 +4082,23 @@ export class ButtonActionExecutor { updateValue: effectiveConfig.trackingStatusOnStop, }); console.log("โœ… ์ƒํƒœ ๋ณ€๊ฒฝ ์™„๋ฃŒ:", effectiveConfig.trackingStatusOnStop); + + // ๐Ÿ†• ์šดํ–‰ ์ข…๋ฃŒ ์‹œ vehicles ํ…Œ์ด๋ธ”์˜ ์ถœ๋ฐœ์ง€/๋„์ฐฉ์ง€/์œ„๋„/๊ฒฝ๋„๋ฅผ null๋กœ ์ดˆ๊ธฐํ™” + const fieldsToReset = ["departure", "arrival", "latitude", "longitude"]; + for (const field of fieldsToReset) { + try { + await apiClient.put("/dynamic-form/update-field", { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: field, + updateValue: null, + }); + } catch { + // ์ปฌ๋Ÿผ์ด ์—†์œผ๋ฉด ๋ฌด์‹œ + } + } + console.log("โœ… ์ถœ๋ฐœ์ง€/๋„์ฐฉ์ง€/์œ„๋„/๊ฒฝ๋„ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ"); } } catch (statusError) { console.warn("โš ๏ธ ์ƒํƒœ ๋ณ€๊ฒฝ ์‹คํŒจ:", statusError);