Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
8d07458c94
|
|
@ -8,6 +8,7 @@ import path from "path";
|
|||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
|
|
@ -169,6 +170,10 @@ const limiter = rateLimit({
|
|||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
|
|
|
|||
|
|
@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* POST /api/table-categories/labels-by-codes
|
||||
*
|
||||
* Body:
|
||||
* - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||
*
|
||||
* Response:
|
||||
* - { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { valueCodes } = req.body;
|
||||
|
||||
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("카테고리 코드로 라벨 조회", {
|
||||
valueCodes,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
|
||||
valueCodes,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: labels,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1811,3 +1811,333 @@ export async function getCategoryColumnsByMenu(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 범용 다중 테이블 저장 API
|
||||
*
|
||||
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
||||
*
|
||||
* 요청 본문:
|
||||
* {
|
||||
* mainTable: { tableName: string, primaryKeyColumn: string },
|
||||
* mainData: Record<string, any>,
|
||||
* subTables: Array<{
|
||||
* tableName: string,
|
||||
* linkColumn: { mainField: string, subColumn: string },
|
||||
* items: Record<string, any>[],
|
||||
* options?: {
|
||||
* saveMainAsFirst?: boolean,
|
||||
* mainFieldMappings?: Array<{ formField: string, targetColumn: string }>,
|
||||
* mainMarkerColumn?: string,
|
||||
* mainMarkerValue?: any,
|
||||
* subMarkerValue?: any,
|
||||
* deleteExistingBefore?: boolean,
|
||||
* }
|
||||
* }>,
|
||||
* isUpdate?: boolean
|
||||
* }
|
||||
*/
|
||||
export async function multiTableSave(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const pool = require("../database/db").getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const { mainTable, mainData, subTables, isUpdate } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
logger.info("=== 다중 테이블 저장 시작 ===", {
|
||||
mainTable,
|
||||
mainDataKeys: Object.keys(mainData || {}),
|
||||
subTablesCount: subTables?.length || 0,
|
||||
isUpdate,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 유효성 검사
|
||||
if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "메인 테이블 설정이 올바르지 않습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mainData || Object.keys(mainData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "저장할 메인 데이터가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 메인 테이블 저장
|
||||
const mainTableName = mainTable.tableName;
|
||||
const pkColumn = mainTable.primaryKeyColumn;
|
||||
const pkValue = mainData[pkColumn];
|
||||
|
||||
// company_code 자동 추가 (최고 관리자가 아닌 경우)
|
||||
if (companyCode !== "*" && !mainData.company_code) {
|
||||
mainData.company_code = companyCode;
|
||||
}
|
||||
|
||||
let mainResult: any;
|
||||
|
||||
if (isUpdate && pkValue) {
|
||||
// UPDATE
|
||||
const updateColumns = Object.keys(mainData)
|
||||
.filter(col => col !== pkColumn)
|
||||
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||
.join(", ");
|
||||
const updateValues = Object.keys(mainData)
|
||||
.filter(col => col !== pkColumn)
|
||||
.map(col => mainData[col]);
|
||||
|
||||
// updated_at 컬럼 존재 여부 확인
|
||||
const hasUpdatedAt = await client.query(`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'updated_at'
|
||||
`, [mainTableName]);
|
||||
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE "${mainTableName}"
|
||||
SET ${updateColumns}${updatedAtClause}
|
||||
WHERE "${pkColumn}" = $${updateValues.length + 1}
|
||||
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const updateParams = companyCode !== "*"
|
||||
? [...updateValues, pkValue, companyCode]
|
||||
: [...updateValues, pkValue];
|
||||
|
||||
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
|
||||
mainResult = await client.query(updateQuery, updateParams);
|
||||
} else {
|
||||
// INSERT
|
||||
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
|
||||
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||
const values = Object.values(mainData);
|
||||
|
||||
// updated_at 컬럼 존재 여부 확인
|
||||
const hasUpdatedAt = await client.query(`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'updated_at'
|
||||
`, [mainTableName]);
|
||||
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
|
||||
|
||||
const updateSetClause = Object.keys(mainData)
|
||||
.filter(col => col !== pkColumn)
|
||||
.map(col => `"${col}" = EXCLUDED."${col}"`)
|
||||
.join(", ");
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO "${mainTableName}" (${columns})
|
||||
VALUES (${placeholders})
|
||||
ON CONFLICT ("${pkColumn}") DO UPDATE SET
|
||||
${updateSetClause}${updatedAtClause}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
|
||||
mainResult = await client.query(insertQuery, values);
|
||||
}
|
||||
|
||||
if (mainResult.rowCount === 0) {
|
||||
throw new Error("메인 테이블 저장 실패");
|
||||
}
|
||||
|
||||
const savedMainData = mainResult.rows[0];
|
||||
const savedPkValue = savedMainData[pkColumn];
|
||||
logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue });
|
||||
|
||||
// 2. 서브 테이블 저장
|
||||
const subTableResults: any[] = [];
|
||||
|
||||
for (const subTableConfig of subTables || []) {
|
||||
const { tableName, linkColumn, items, options } = subTableConfig;
|
||||
|
||||
if (!tableName || !items || items.length === 0) {
|
||||
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||
itemsCount: items.length,
|
||||
linkColumn,
|
||||
options,
|
||||
});
|
||||
|
||||
// 기존 데이터 삭제 옵션
|
||||
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
|
||||
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
|
||||
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
|
||||
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
|
||||
|
||||
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
|
||||
? [savedPkValue, options.subMarkerValue ?? false]
|
||||
: [savedPkValue];
|
||||
|
||||
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
|
||||
await client.query(deleteQuery, deleteParams);
|
||||
}
|
||||
|
||||
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
|
||||
const mainSubItem: Record<string, any> = {
|
||||
[linkColumn.subColumn]: savedPkValue,
|
||||
};
|
||||
|
||||
// 메인 필드 매핑 적용
|
||||
for (const mapping of options.mainFieldMappings) {
|
||||
if (mapping.formField && mapping.targetColumn) {
|
||||
mainSubItem[mapping.targetColumn] = mainData[mapping.formField];
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 마커 설정
|
||||
if (options.mainMarkerColumn) {
|
||||
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
|
||||
}
|
||||
|
||||
// company_code 추가
|
||||
if (companyCode !== "*") {
|
||||
mainSubItem.company_code = companyCode;
|
||||
}
|
||||
|
||||
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
|
||||
const checkQuery = `
|
||||
SELECT * FROM "${tableName}"
|
||||
WHERE "${linkColumn.subColumn}" = $1
|
||||
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
|
||||
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
|
||||
LIMIT 1
|
||||
`;
|
||||
const checkParams: any[] = [savedPkValue];
|
||||
if (options.mainMarkerColumn) {
|
||||
checkParams.push(options.mainMarkerValue ?? true);
|
||||
}
|
||||
if (companyCode !== "*") {
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existingResult = await client.query(checkQuery, checkParams);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
// UPDATE
|
||||
const updateColumns = Object.keys(mainSubItem)
|
||||
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
|
||||
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||
.join(", ");
|
||||
|
||||
const updateValues = Object.keys(mainSubItem)
|
||||
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
|
||||
.map(col => mainSubItem[col]);
|
||||
|
||||
if (updateColumns) {
|
||||
const updateQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${updateColumns}
|
||||
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
|
||||
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
|
||||
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
|
||||
RETURNING *
|
||||
`;
|
||||
const updateParams = [...updateValues, savedPkValue];
|
||||
if (options.mainMarkerColumn) {
|
||||
updateParams.push(options.mainMarkerValue ?? true);
|
||||
}
|
||||
if (companyCode !== "*") {
|
||||
updateParams.push(companyCode);
|
||||
}
|
||||
|
||||
const updateResult = await client.query(updateQuery, updateParams);
|
||||
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
|
||||
} else {
|
||||
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
|
||||
}
|
||||
} else {
|
||||
// INSERT
|
||||
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
|
||||
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||
const mainSubValues = Object.values(mainSubItem);
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${mainSubColumns})
|
||||
VALUES (${mainSubPlaceholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const insertResult = await client.query(insertQuery, mainSubValues);
|
||||
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
|
||||
}
|
||||
}
|
||||
|
||||
// 서브 아이템들 저장
|
||||
for (const item of items) {
|
||||
// 연결 컬럼 값 설정
|
||||
if (linkColumn?.subColumn) {
|
||||
item[linkColumn.subColumn] = savedPkValue;
|
||||
}
|
||||
|
||||
// company_code 추가
|
||||
if (companyCode !== "*" && !item.company_code) {
|
||||
item.company_code = companyCode;
|
||||
}
|
||||
|
||||
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
|
||||
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||
const subValues = Object.values(item);
|
||||
|
||||
const subInsertQuery = `
|
||||
INSERT INTO "${tableName}" (${subColumns})
|
||||
VALUES (${subPlaceholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
|
||||
const subResult = await client.query(subInsertQuery, subValues);
|
||||
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
|
||||
}
|
||||
|
||||
logger.info(`서브 테이블 ${tableName} 저장 완료`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("=== 다중 테이블 저장 완료 ===", {
|
||||
mainTable: mainTableName,
|
||||
mainPk: savedPkValue,
|
||||
subTableResultsCount: subTableResults.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "다중 테이블 저장이 완료되었습니다.",
|
||||
data: {
|
||||
main: savedMainData,
|
||||
subTables: subTableResults,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
|
||||
logger.error("다중 테이블 저장 실패:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "다중 테이블 저장에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,16 +54,17 @@ export const authenticateToken = (
|
|||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||
);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||
|
||||
// 토큰 만료 에러인지 확인
|
||||
const isTokenExpired = errorMessage.includes("만료");
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
details:
|
||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
||||
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
deleteColumnMapping,
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
getCategoryLabelsByCodes,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
|||
// 카테고리 값 순서 변경
|
||||
router.post("/values/reorder", reorderCategoryValues);
|
||||
|
||||
// 카테고리 코드로 라벨 조회
|
||||
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -907,8 +907,27 @@ class DataService {
|
|||
return validation.error!;
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
|
||||
|
||||
const invalidColumns: string[] = [];
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([key]) => {
|
||||
if (validColumnNames.has(key)) {
|
||||
return true;
|
||||
}
|
||||
invalidColumns.push(key);
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
if (invalidColumns.length > 0) {
|
||||
console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
|
||||
}
|
||||
|
||||
const columns = Object.keys(filteredData);
|
||||
const values = Object.values(filteredData);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||
|
||||
|
|
@ -951,9 +970,28 @@ class DataService {
|
|||
|
||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||
const relationInfo = data._relationInfo;
|
||||
const cleanData = { ...data };
|
||||
let cleanData = { ...data };
|
||||
delete cleanData._relationInfo;
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
|
||||
|
||||
const invalidColumns: string[] = [];
|
||||
cleanData = Object.fromEntries(
|
||||
Object.entries(cleanData).filter(([key]) => {
|
||||
if (validColumnNames.has(key)) {
|
||||
return true;
|
||||
}
|
||||
invalidColumns.push(key);
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
if (invalidColumns.length > 0) {
|
||||
console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
|
|
|
|||
|
|
@ -506,6 +506,24 @@ export class DynamicFormService {
|
|||
// 헤더 + 품목을 병합
|
||||
const rawMergedData = { ...dataToInsert, ...item };
|
||||
|
||||
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
||||
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
||||
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
|
||||
const isExistingRecord = rawMergedData._existingRecord === true;
|
||||
|
||||
if (!isExistingRecord) {
|
||||
// 새 레코드: id 제거하여 새 UUID 자동 생성
|
||||
const oldId = rawMergedData.id;
|
||||
delete rawMergedData.id;
|
||||
console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`);
|
||||
} else {
|
||||
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
|
||||
}
|
||||
|
||||
// 메타 플래그 제거
|
||||
delete rawMergedData._isNewItem;
|
||||
delete rawMergedData._existingRecord;
|
||||
|
||||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||||
const mergedData: Record<string, any> = {};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type NodeType =
|
|||
| "restAPISource"
|
||||
| "condition"
|
||||
| "dataTransform"
|
||||
| "aggregate"
|
||||
| "insertAction"
|
||||
| "updateAction"
|
||||
| "deleteAction"
|
||||
|
|
@ -528,6 +529,9 @@ export class NodeFlowExecutionService {
|
|||
case "dataTransform":
|
||||
return this.executeDataTransform(node, inputData, context);
|
||||
|
||||
case "aggregate":
|
||||
return this.executeAggregate(node, inputData, context);
|
||||
|
||||
case "insertAction":
|
||||
return this.executeInsertAction(node, inputData, context, client);
|
||||
|
||||
|
|
@ -830,12 +834,19 @@ export class NodeFlowExecutionService {
|
|||
|
||||
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
|
||||
|
||||
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
|
||||
|
||||
const result = await query(sql, whereResult.values);
|
||||
|
||||
logger.info(
|
||||
`📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건`
|
||||
);
|
||||
|
||||
// 디버깅: 조회된 데이터 샘플 출력
|
||||
if (result.length > 0) {
|
||||
logger.info(`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -1355,57 +1366,64 @@ export class NodeFlowExecutionService {
|
|||
let updatedCount = 0;
|
||||
const updatedDataArray: any[] = [];
|
||||
|
||||
// 🆕 table-all 모드: 단일 SQL로 일괄 업데이트
|
||||
// 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영)
|
||||
if (context.currentNodeDataSourceType === "table-all") {
|
||||
console.log("🚀 table-all 모드: 단일 SQL로 일괄 업데이트 시작");
|
||||
console.log("🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + dataArray.length + "개 그룹)");
|
||||
|
||||
// 첫 번째 데이터를 참조하여 SET 절 생성
|
||||
const firstData = dataArray[0];
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
// 🔥 각 그룹(데이터)별로 UPDATE 실행
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const data = dataArray[i];
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: firstData[mapping.sourceField];
|
||||
console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`);
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
|
||||
const whereResult = this.buildWhereClause(
|
||||
whereConditions,
|
||||
data,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
values.push(...whereResult.values);
|
||||
|
||||
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
|
||||
const whereResult = this.buildWhereClause(
|
||||
whereConditions,
|
||||
firstData,
|
||||
paramIndex
|
||||
);
|
||||
const sql = `
|
||||
UPDATE ${targetTable}
|
||||
SET ${setClauses.join(", ")}
|
||||
${whereResult.clause}
|
||||
`;
|
||||
|
||||
values.push(...whereResult.values);
|
||||
console.log("📝 실행할 SQL:", sql);
|
||||
console.log("📊 바인딩 값:", values);
|
||||
|
||||
const sql = `
|
||||
UPDATE ${targetTable}
|
||||
SET ${setClauses.join(", ")}
|
||||
${whereResult.clause}
|
||||
`;
|
||||
const result = await txClient.query(sql, values);
|
||||
const rowCount = result.rowCount || 0;
|
||||
updatedCount += rowCount;
|
||||
|
||||
console.log("📝 실행할 SQL (일괄 처리):", sql);
|
||||
console.log("📊 바인딩 값:", values);
|
||||
|
||||
const result = await txClient.query(sql, values);
|
||||
updatedCount = result.rowCount || 0;
|
||||
console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}건`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}건`
|
||||
`✅ UPDATE 완료 (내부 DB, 그룹별 처리): ${targetTable}, 총 ${updatedCount}건`
|
||||
);
|
||||
|
||||
// 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음)
|
||||
|
|
@ -3197,4 +3215,168 @@ export class NodeFlowExecutionService {
|
|||
"upsertAction",
|
||||
].includes(nodeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등)
|
||||
*/
|
||||
private static async executeAggregate(
|
||||
node: FlowNode,
|
||||
inputData: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data;
|
||||
|
||||
logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`);
|
||||
|
||||
// 입력 데이터가 없으면 빈 배열 반환
|
||||
if (!inputData || !Array.isArray(inputData) || inputData.length === 0) {
|
||||
logger.warn("⚠️ 집계할 입력 데이터가 없습니다.");
|
||||
logger.warn(`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(`📥 입력 데이터: ${inputData.length}건`);
|
||||
logger.info(`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`);
|
||||
logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`);
|
||||
logger.info(`📊 집계 연산: ${aggregations.length}개`);
|
||||
|
||||
// 그룹화 수행
|
||||
const groups = new Map<string, any[]>();
|
||||
|
||||
for (const row of inputData) {
|
||||
// 그룹 키 생성
|
||||
const groupKey = groupByFields.length > 0
|
||||
? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||")
|
||||
: "__ALL__";
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`📊 그룹 수: ${groups.size}개`);
|
||||
|
||||
// 디버깅: 각 그룹의 데이터 출력
|
||||
for (const [groupKey, groupRows] of groups) {
|
||||
logger.info(`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`);
|
||||
}
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
const results: any[] = [];
|
||||
|
||||
for (const [groupKey, groupRows] of groups) {
|
||||
const resultRow: any = {};
|
||||
|
||||
// 그룹 기준 필드값 추가
|
||||
if (groupByFields.length > 0) {
|
||||
const keyValues = groupKey.split("|||");
|
||||
groupByFields.forEach((field: any, idx: number) => {
|
||||
resultRow[field.field] = keyValues[idx];
|
||||
});
|
||||
}
|
||||
|
||||
// 각 집계 연산 수행
|
||||
for (const agg of aggregations) {
|
||||
const { sourceField, function: aggFunc, outputField } = agg;
|
||||
|
||||
if (!outputField) continue;
|
||||
|
||||
let aggregatedValue: any;
|
||||
|
||||
switch (aggFunc) {
|
||||
case "SUM":
|
||||
aggregatedValue = groupRows.reduce((sum: number, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
break;
|
||||
|
||||
case "COUNT":
|
||||
aggregatedValue = groupRows.length;
|
||||
break;
|
||||
|
||||
case "AVG":
|
||||
const sum = groupRows.reduce((acc: number, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
return acc + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0;
|
||||
break;
|
||||
|
||||
case "MIN":
|
||||
aggregatedValue = groupRows.reduce((min: number | null, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
if (isNaN(val)) return min;
|
||||
return min === null ? val : Math.min(min, val);
|
||||
}, null);
|
||||
break;
|
||||
|
||||
case "MAX":
|
||||
aggregatedValue = groupRows.reduce((max: number | null, row: any) => {
|
||||
const val = parseFloat(row[sourceField]);
|
||||
if (isNaN(val)) return max;
|
||||
return max === null ? val : Math.max(max, val);
|
||||
}, null);
|
||||
break;
|
||||
|
||||
case "FIRST":
|
||||
aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null;
|
||||
break;
|
||||
|
||||
case "LAST":
|
||||
aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null;
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`);
|
||||
aggregatedValue = null;
|
||||
}
|
||||
|
||||
resultRow[outputField] = aggregatedValue;
|
||||
logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`);
|
||||
}
|
||||
|
||||
results.push(resultRow);
|
||||
}
|
||||
|
||||
// HAVING 조건 적용 (집계 후 필터링)
|
||||
let filteredResults = results;
|
||||
if (havingConditions && havingConditions.length > 0) {
|
||||
filteredResults = results.filter((row) => {
|
||||
return havingConditions.every((condition: any) => {
|
||||
const fieldValue = row[condition.field];
|
||||
const compareValue = parseFloat(condition.value);
|
||||
|
||||
switch (condition.operator) {
|
||||
case "=":
|
||||
return fieldValue === compareValue;
|
||||
case "!=":
|
||||
return fieldValue !== compareValue;
|
||||
case ">":
|
||||
return fieldValue > compareValue;
|
||||
case ">=":
|
||||
return fieldValue >= compareValue;
|
||||
case "<":
|
||||
return fieldValue < compareValue;
|
||||
case "<=":
|
||||
return fieldValue <= compareValue;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`);
|
||||
}
|
||||
|
||||
logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`);
|
||||
|
||||
// 결과 샘플 출력
|
||||
if (filteredResults.length > 0) {
|
||||
logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2));
|
||||
}
|
||||
|
||||
return filteredResults;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,70 @@ class TableCategoryValueService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* @param valueCodes - 카테고리 코드 배열
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
async getCategoryLabelsByCodes(
|
||||
valueCodes: string[],
|
||||
companyCode: string
|
||||
): Promise<Record<string, string>> {
|
||||
try {
|
||||
if (!valueCodes || valueCodes.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 동적으로 파라미터 플레이스홀더 생성
|
||||
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
`;
|
||||
params = valueCodes;
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
|
||||
`;
|
||||
params = [...valueCodes, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// { [code]: label } 형태로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
|
||||
|
||||
return labels;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,583 @@
|
|||
# 노드 플로우 기능 개선 사항
|
||||
|
||||
> 작성일: 2024-12-08
|
||||
> 상태: 분석 완료, 개선 대기
|
||||
|
||||
## 현재 구현 상태
|
||||
|
||||
### 잘 구현된 기능
|
||||
|
||||
| 기능 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| 위상 정렬 실행 | 완료 | DAG 기반 레벨별 실행 |
|
||||
| 트랜잭션 관리 | 완료 | 전체 플로우 단일 트랜잭션, 실패 시 자동 롤백 |
|
||||
| 병렬 실행 | 완료 | 같은 레벨 노드 `Promise.allSettled`로 병렬 처리 |
|
||||
| CRUD 액션 | 완료 | INSERT, UPDATE, DELETE, UPSERT 지원 |
|
||||
| 외부 DB 연동 | 완료 | PostgreSQL, MySQL, MSSQL, Oracle 지원 |
|
||||
| REST API 연동 | 완료 | GET, POST, PUT, DELETE 지원 |
|
||||
| 조건 분기 | 완료 | 다양한 연산자 지원 |
|
||||
| 데이터 변환 | 부분 완료 | UPPERCASE, TRIM, EXPLODE 등 기본 변환 |
|
||||
| 집계 함수 | 완료 | SUM, COUNT, AVG, MIN, MAX, FIRST, LAST |
|
||||
|
||||
### 관련 파일
|
||||
|
||||
- **백엔드 실행 엔진**: `backend-node/src/services/nodeFlowExecutionService.ts`
|
||||
- **백엔드 라우트**: `backend-node/src/routes/dataflow/node-flows.ts`
|
||||
- **프론트엔드 API**: `frontend/lib/api/nodeFlows.ts`
|
||||
- **프론트엔드 에디터**: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||
- **타입 정의**: `backend-node/src/types/flow.ts`
|
||||
|
||||
---
|
||||
|
||||
## 개선 필요 사항
|
||||
|
||||
### 1. [우선순위 높음] 실행 이력 로깅
|
||||
|
||||
**현재 상태**: 플로우 실행 이력이 저장되지 않음
|
||||
|
||||
**문제점**:
|
||||
- 언제, 누가, 어떤 플로우를 실행했는지 추적 불가
|
||||
- 실패 원인 분석 어려움
|
||||
- 감사(Audit) 요구사항 충족 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- db/migrations/XXX_add_node_flow_execution_log.sql
|
||||
CREATE TABLE node_flow_execution_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||
execution_status VARCHAR(20) NOT NULL, -- 'success', 'failed', 'partial'
|
||||
execution_time_ms INTEGER,
|
||||
total_nodes INTEGER,
|
||||
success_nodes INTEGER,
|
||||
failed_nodes INTEGER,
|
||||
skipped_nodes INTEGER,
|
||||
executed_by VARCHAR(50),
|
||||
company_code VARCHAR(20),
|
||||
context_data JSONB,
|
||||
result_summary JSONB,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_flow_execution_log_flow_id ON node_flow_execution_log(flow_id);
|
||||
CREATE INDEX idx_flow_execution_log_created_at ON node_flow_execution_log(created_at DESC);
|
||||
CREATE INDEX idx_flow_execution_log_company_code ON node_flow_execution_log(company_code);
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 마이그레이션 파일 생성
|
||||
- [ ] `nodeFlowExecutionService.ts`에 로그 저장 로직 추가
|
||||
- [ ] 실행 이력 조회 API 추가 (`GET /api/dataflow/node-flows/:flowId/executions`)
|
||||
- [ ] 프론트엔드 실행 이력 UI 추가
|
||||
|
||||
---
|
||||
|
||||
### 2. [우선순위 높음] 드라이런(Dry Run) 모드
|
||||
|
||||
**현재 상태**: 실제 데이터를 변경하지 않고 테스트할 방법 없음
|
||||
|
||||
**문제점**:
|
||||
- 프로덕션 데이터에 직접 영향
|
||||
- 플로우 디버깅 어려움
|
||||
- 신규 플로우 검증 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts
|
||||
static async executeFlow(
|
||||
flowId: number,
|
||||
contextData: Record<string, any>,
|
||||
options: { dryRun?: boolean } = {}
|
||||
): Promise<ExecutionResult> {
|
||||
if (options.dryRun) {
|
||||
// 트랜잭션 시작 후 항상 롤백
|
||||
return transaction(async (client) => {
|
||||
const result = await this.executeFlowInternal(flowId, contextData, client);
|
||||
// 롤백을 위해 의도적으로 에러 발생
|
||||
throw new DryRunComplete(result);
|
||||
}).catch((e) => {
|
||||
if (e instanceof DryRunComplete) {
|
||||
return { ...e.result, dryRun: true };
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
// 기존 로직...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// node-flows.ts 라우트 수정
|
||||
router.post("/:flowId/execute", async (req, res) => {
|
||||
const dryRun = req.query.dryRun === 'true';
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
enrichedContextData,
|
||||
{ dryRun }
|
||||
);
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `DryRunComplete` 예외 클래스 생성
|
||||
- [ ] `executeFlow` 메서드에 `dryRun` 옵션 추가
|
||||
- [ ] 라우트에 쿼리 파라미터 처리 추가
|
||||
- [ ] 프론트엔드 "테스트 실행" 버튼 추가
|
||||
|
||||
---
|
||||
|
||||
### 3. [우선순위 높음] 재시도 메커니즘
|
||||
|
||||
**현재 상태**: 외부 API/DB 호출 실패 시 재시도 없음
|
||||
|
||||
**문제점**:
|
||||
- 일시적 네트워크 오류로 전체 플로우 실패
|
||||
- 외부 서비스 불안정 시 신뢰성 저하
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// utils/retry.ts
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
delay?: number;
|
||||
backoffMultiplier?: number;
|
||||
retryOn?: (error: any) => boolean;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
delay = 1000,
|
||||
backoffMultiplier = 2,
|
||||
retryOn = () => true
|
||||
} = options;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1 || !retryOn(error)) {
|
||||
throw error;
|
||||
}
|
||||
const waitTime = delay * Math.pow(backoffMultiplier, attempt);
|
||||
logger.warn(`재시도 ${attempt + 1}/${maxRetries}, ${waitTime}ms 후...`);
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
}
|
||||
}
|
||||
throw new Error('재시도 횟수 초과');
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts에서 사용
|
||||
const response = await withRetry(
|
||||
() => axios({ method, url, headers, data, timeout }),
|
||||
{
|
||||
maxRetries: 3,
|
||||
delay: 1000,
|
||||
retryOn: (err) => err.code === 'ECONNRESET' || err.response?.status >= 500
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `withRetry` 유틸리티 함수 생성
|
||||
- [ ] REST API 호출 부분에 재시도 로직 적용
|
||||
- [ ] 외부 DB 연결 부분에 재시도 로직 적용
|
||||
- [ ] 노드별 재시도 설정 UI 추가 (선택사항)
|
||||
|
||||
---
|
||||
|
||||
### 4. [우선순위 높음] 미완성 데이터 변환 함수
|
||||
|
||||
**현재 상태**: FORMAT, CALCULATE, JSON_EXTRACT, CUSTOM 변환이 미구현
|
||||
|
||||
**문제점**:
|
||||
- 날짜/숫자 포맷팅 불가
|
||||
- 계산식 처리 불가
|
||||
- JSON 데이터 파싱 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts - applyTransformation 메서드 수정
|
||||
|
||||
case "FORMAT":
|
||||
return rows.map((row) => {
|
||||
const value = row[sourceField];
|
||||
let formatted = value;
|
||||
|
||||
if (transform.formatType === 'date') {
|
||||
// dayjs 사용
|
||||
formatted = dayjs(value).format(transform.formatPattern || 'YYYY-MM-DD');
|
||||
} else if (transform.formatType === 'number') {
|
||||
// 숫자 포맷팅
|
||||
const num = parseFloat(value);
|
||||
if (transform.formatPattern === 'currency') {
|
||||
formatted = num.toLocaleString('ko-KR', { style: 'currency', currency: 'KRW' });
|
||||
} else if (transform.formatPattern === 'percent') {
|
||||
formatted = (num * 100).toFixed(transform.decimals || 0) + '%';
|
||||
} else {
|
||||
formatted = num.toLocaleString('ko-KR', { maximumFractionDigits: transform.decimals || 2 });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...row, [actualTargetField]: formatted };
|
||||
});
|
||||
|
||||
case "CALCULATE":
|
||||
return rows.map((row) => {
|
||||
// 간단한 수식 평가 (보안 주의!)
|
||||
const expression = transform.expression; // 예: "price * quantity"
|
||||
const result = evaluateExpression(expression, row);
|
||||
return { ...row, [actualTargetField]: result };
|
||||
});
|
||||
|
||||
case "JSON_EXTRACT":
|
||||
return rows.map((row) => {
|
||||
const jsonValue = typeof row[sourceField] === 'string'
|
||||
? JSON.parse(row[sourceField])
|
||||
: row[sourceField];
|
||||
const extracted = jsonPath.query(jsonValue, transform.jsonPath); // JSONPath 라이브러리 사용
|
||||
return { ...row, [actualTargetField]: extracted[0] || null };
|
||||
});
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `dayjs` 라이브러리 추가 (날짜 포맷팅)
|
||||
- [ ] `jsonpath` 라이브러리 추가 (JSON 추출)
|
||||
- [ ] 안전한 수식 평가 함수 구현 (eval 대신)
|
||||
- [ ] 각 변환 타입별 UI 설정 패널 추가
|
||||
|
||||
---
|
||||
|
||||
### 5. [우선순위 중간] 플로우 버전 관리
|
||||
|
||||
**현재 상태**: 플로우 수정 시 이전 버전 덮어씀
|
||||
|
||||
**문제점**:
|
||||
- 실수로 수정한 플로우 복구 불가
|
||||
- 변경 이력 추적 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- db/migrations/XXX_add_node_flow_versions.sql
|
||||
CREATE TABLE node_flow_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
flow_data JSONB NOT NULL,
|
||||
change_description TEXT,
|
||||
created_by VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(flow_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_flow_versions_flow_id ON node_flow_versions(flow_id);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 플로우 수정 시 버전 저장
|
||||
async function updateNodeFlow(flowId, flowData, changeDescription, userId) {
|
||||
// 현재 버전 조회
|
||||
const currentVersion = await queryOne(
|
||||
'SELECT COALESCE(MAX(version), 0) as max_version FROM node_flow_versions WHERE flow_id = $1',
|
||||
[flowId]
|
||||
);
|
||||
|
||||
// 새 버전 저장
|
||||
await query(
|
||||
'INSERT INTO node_flow_versions (flow_id, version, flow_data, change_description, created_by) VALUES ($1, $2, $3, $4, $5)',
|
||||
[flowId, currentVersion.max_version + 1, flowData, changeDescription, userId]
|
||||
);
|
||||
|
||||
// 기존 업데이트 로직...
|
||||
}
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 버전 테이블 마이그레이션 생성
|
||||
- [ ] 플로우 수정 시 버전 자동 저장
|
||||
- [ ] 버전 목록 조회 API (`GET /api/dataflow/node-flows/:flowId/versions`)
|
||||
- [ ] 특정 버전으로 롤백 API (`POST /api/dataflow/node-flows/:flowId/rollback/:version`)
|
||||
- [ ] 프론트엔드 버전 히스토리 UI
|
||||
|
||||
---
|
||||
|
||||
### 6. [우선순위 중간] 복합 조건 지원
|
||||
|
||||
**현재 상태**: 조건 노드에서 단일 조건만 지원
|
||||
|
||||
**문제점**:
|
||||
- 복잡한 비즈니스 로직 표현 불가
|
||||
- 여러 조건을 AND/OR로 조합 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// 복합 조건 타입 정의
|
||||
interface ConditionGroup {
|
||||
type: 'AND' | 'OR';
|
||||
conditions: (Condition | ConditionGroup)[];
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// 조건 평가 함수 수정
|
||||
function evaluateConditionGroup(group: ConditionGroup, data: any): boolean {
|
||||
const results = group.conditions.map(condition => {
|
||||
if ('type' in condition) {
|
||||
// 중첩된 그룹
|
||||
return evaluateConditionGroup(condition, data);
|
||||
} else {
|
||||
// 단일 조건
|
||||
return evaluateCondition(data[condition.field], condition.operator, condition.value);
|
||||
}
|
||||
});
|
||||
|
||||
return group.type === 'AND'
|
||||
? results.every(r => r)
|
||||
: results.some(r => r);
|
||||
}
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 복합 조건 타입 정의
|
||||
- [ ] `evaluateConditionGroup` 함수 구현
|
||||
- [ ] 조건 노드 속성 패널 UI 수정 (AND/OR 그룹 빌더)
|
||||
|
||||
---
|
||||
|
||||
### 7. [우선순위 중간] 비동기 실행
|
||||
|
||||
**현재 상태**: 동기 실행만 가능 (HTTP 요청 타임아웃 제한)
|
||||
|
||||
**문제점**:
|
||||
- 대용량 데이터 처리 시 타임아웃
|
||||
- 장시간 실행 플로우 처리 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- 실행 큐 테이블
|
||||
CREATE TABLE node_flow_execution_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id),
|
||||
execution_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed
|
||||
context_data JSONB,
|
||||
callback_url TEXT,
|
||||
result JSONB,
|
||||
error_message TEXT,
|
||||
queued_by VARCHAR(50),
|
||||
company_code VARCHAR(20),
|
||||
queued_at TIMESTAMP DEFAULT NOW(),
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 비동기 실행 API
|
||||
router.post("/:flowId/execute-async", async (req, res) => {
|
||||
const { callbackUrl, contextData } = req.body;
|
||||
|
||||
// 큐에 추가
|
||||
const execution = await queryOne(
|
||||
`INSERT INTO node_flow_execution_queue (flow_id, context_data, callback_url, queued_by, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING execution_id`,
|
||||
[flowId, contextData, callbackUrl, req.user?.userId, req.user?.companyCode]
|
||||
);
|
||||
|
||||
// 백그라운드 워커가 처리
|
||||
return res.json({
|
||||
success: true,
|
||||
executionId: execution.execution_id,
|
||||
status: 'queued'
|
||||
});
|
||||
});
|
||||
|
||||
// 상태 조회 API
|
||||
router.get("/executions/:executionId", async (req, res) => {
|
||||
const execution = await queryOne(
|
||||
'SELECT * FROM node_flow_execution_queue WHERE execution_id = $1',
|
||||
[req.params.executionId]
|
||||
);
|
||||
return res.json({ success: true, data: execution });
|
||||
});
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 실행 큐 테이블 마이그레이션
|
||||
- [ ] 비동기 실행 API 추가
|
||||
- [ ] 백그라운드 워커 프로세스 구현 (별도 프로세스 또는 Bull 큐)
|
||||
- [ ] 웹훅 콜백 기능 구현
|
||||
- [ ] 프론트엔드 비동기 실행 상태 폴링 UI
|
||||
|
||||
---
|
||||
|
||||
### 8. [우선순위 낮음] 플로우 스케줄링
|
||||
|
||||
**현재 상태**: 수동 실행만 가능
|
||||
|
||||
**문제점**:
|
||||
- 정기적인 배치 작업 자동화 불가
|
||||
- 특정 시간 예약 실행 불가
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```sql
|
||||
-- 스케줄 테이블
|
||||
CREATE TABLE node_flow_schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||
schedule_name VARCHAR(100),
|
||||
cron_expression VARCHAR(50) NOT NULL, -- '0 9 * * 1-5' (평일 9시)
|
||||
context_data JSONB,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_run_at TIMESTAMP,
|
||||
next_run_at TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
company_code VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] 스케줄 테이블 마이그레이션
|
||||
- [ ] 스케줄 CRUD API
|
||||
- [ ] node-cron 또는 Bull 스케줄러 통합
|
||||
- [ ] 스케줄 관리 UI
|
||||
|
||||
---
|
||||
|
||||
### 9. [우선순위 낮음] 플러그인 아키텍처
|
||||
|
||||
**현재 상태**: 새 노드 타입 추가 시 `nodeFlowExecutionService.ts` 직접 수정 필요
|
||||
|
||||
**문제점**:
|
||||
- 코드 복잡도 증가
|
||||
- 확장성 제한
|
||||
|
||||
**개선 방안**:
|
||||
|
||||
```typescript
|
||||
// interfaces/NodeHandler.ts
|
||||
export interface NodeHandler {
|
||||
type: string;
|
||||
execute(node: FlowNode, inputData: any, context: ExecutionContext, client?: any): Promise<any>;
|
||||
validate?(node: FlowNode): { valid: boolean; errors: string[] };
|
||||
}
|
||||
|
||||
// handlers/InsertActionHandler.ts
|
||||
export class InsertActionHandler implements NodeHandler {
|
||||
type = 'insertAction';
|
||||
|
||||
async execute(node, inputData, context, client) {
|
||||
// 기존 executeInsertAction 로직
|
||||
}
|
||||
}
|
||||
|
||||
// NodeHandlerRegistry.ts
|
||||
class NodeHandlerRegistry {
|
||||
private handlers = new Map<string, NodeHandler>();
|
||||
|
||||
register(handler: NodeHandler) {
|
||||
this.handlers.set(handler.type, handler);
|
||||
}
|
||||
|
||||
get(type: string): NodeHandler | undefined {
|
||||
return this.handlers.get(type);
|
||||
}
|
||||
}
|
||||
|
||||
// 사용
|
||||
const registry = new NodeHandlerRegistry();
|
||||
registry.register(new InsertActionHandler());
|
||||
registry.register(new UpdateActionHandler());
|
||||
// ...
|
||||
|
||||
// executeNodeByType에서
|
||||
const handler = registry.get(node.type);
|
||||
if (handler) {
|
||||
return handler.execute(node, inputData, context, client);
|
||||
}
|
||||
```
|
||||
|
||||
**필요 작업**:
|
||||
- [ ] `NodeHandler` 인터페이스 정의
|
||||
- [ ] 기존 노드 타입별 핸들러 클래스 분리
|
||||
- [ ] `NodeHandlerRegistry` 구현
|
||||
- [ ] 커스텀 노드 핸들러 등록 메커니즘
|
||||
|
||||
---
|
||||
|
||||
### 10. [우선순위 낮음] 프론트엔드 연동 강화
|
||||
|
||||
**현재 상태**: 기본 에디터 구현됨
|
||||
|
||||
**개선 필요 항목**:
|
||||
- [ ] 실행 결과 시각화 (노드별 성공/실패 표시)
|
||||
- [ ] 실시간 실행 진행률 표시
|
||||
- [ ] 드라이런 모드 UI
|
||||
- [ ] 실행 이력 조회 UI
|
||||
- [ ] 버전 히스토리 UI
|
||||
- [ ] 노드 검증 결과 표시
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 컴포넌트 CRUD 로직 이전 계획
|
||||
|
||||
현재 프론트엔드 컴포넌트에서 직접 CRUD를 수행하는 코드들을 노드 플로우로 이전해야 합니다.
|
||||
|
||||
### 이전 대상 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 위치 | 현재 로직 | 이전 우선순위 |
|
||||
|----------|----------|----------|--------------|
|
||||
| SplitPanelLayoutComponent | `frontend/lib/registry/components/split-panel-layout/` | createRecord, updateRecord, deleteRecord | 높음 |
|
||||
| RepeatScreenModalComponent | `frontend/lib/registry/components/repeat-screen-modal/` | 다중 테이블 INSERT/UPDATE/DELETE | 높음 |
|
||||
| UniversalFormModalComponent | `frontend/lib/registry/components/universal-form-modal/` | 다중 행 저장 | 높음 |
|
||||
| SelectedItemsDetailInputComponent | `frontend/lib/registry/components/selected-items-detail-input/` | upsertGroupedRecords | 높음 |
|
||||
| ButtonPrimaryComponent | `frontend/lib/registry/components/button-primary/` | 상태 변경 POST | 중간 |
|
||||
| SimpleRepeaterTableComponent | `frontend/lib/registry/components/simple-repeater-table/` | 데이터 저장 POST | 중간 |
|
||||
|
||||
### 이전 방식
|
||||
|
||||
1. **플로우 생성**: 각 컴포넌트의 저장 로직을 노드 플로우로 구현
|
||||
2. **프론트엔드 수정**: 직접 API 호출 대신 `executeNodeFlow(flowId, contextData)` 호출
|
||||
3. **화면 설정에 플로우 연결**: 버튼 액션에 실행할 플로우 ID 설정
|
||||
|
||||
```typescript
|
||||
// 현재 (프론트엔드에서 직접 호출)
|
||||
const result = await dataApi.createRecord(tableName, data);
|
||||
|
||||
// 개선 후 (플로우 실행)
|
||||
const result = await executeNodeFlow(flowId, {
|
||||
formData: data,
|
||||
tableName: tableName,
|
||||
action: 'create'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- 노드 플로우 실행 엔진: `backend-node/src/services/nodeFlowExecutionService.ts`
|
||||
- 플로우 타입 정의: `backend-node/src/types/flow.ts`
|
||||
- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
||||
|
||||
|
||||
|
|
@ -216,7 +216,8 @@ function ScreenViewPage() {
|
|||
initAutoFill();
|
||||
}, [layout, user]);
|
||||
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||
useEffect(() => {
|
||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||
if (isMobile) {
|
||||
|
|
@ -262,13 +263,12 @@ function ScreenViewPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 초기 측정
|
||||
// 초기 측정 (한 번만 실행)
|
||||
const timer = setTimeout(updateScale, 100);
|
||||
|
||||
window.addEventListener("resize", updateScale);
|
||||
// resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", updateScale);
|
||||
};
|
||||
}, [layout, isMobile]);
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ function ScreenViewPage() {
|
|||
<TableOptionsProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-background flex h-full w-full items-center justify-center overflow-auto"
|
||||
className="bg-background h-full w-full overflow-auto p-3"
|
||||
>
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
|
|
@ -334,7 +334,7 @@ function ScreenViewPage() {
|
|||
maxHeight: `${screenHeight}px`,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
transformOrigin: "top left",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -197,14 +197,14 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
컬럼 추가 - {tableName}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 검증 오류 표시 */}
|
||||
|
|
@ -346,7 +346,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
</Alert>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -365,8 +365,8 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
"컬럼 추가"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ export default function AdvancedBatchModal({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogHeader,
|
||||
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -169,13 +169,13 @@ export default function BatchJobModal({
|
|||
// 상태 제거 - 필요없음
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{job ? "배치 작업 수정" : "새 배치 작업"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -344,7 +344,7 @@ export default function BatchJobModal({
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -360,9 +360,9 @@ export default function BatchJobModal({
|
|||
>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -164,11 +164,11 @@ export function CodeCategoryFormModal({
|
|||
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 카테고리 코드 */}
|
||||
|
|
@ -383,7 +383,7 @@ export function CodeCategoryFormModal({
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -153,11 +153,11 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* 코드값 */}
|
||||
|
|
@ -328,7 +328,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogHeader,
|
||||
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -164,13 +164,13 @@ export default function CollectionConfigModal({
|
|||
];
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{config ? "수집 설정 수정" : "새 수집 설정"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -331,16 +331,16 @@ export default function CollectionConfigModal({
|
|||
<Label htmlFor="is_active">활성화</Label>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
|
||||
|
||||
|
|
@ -111,8 +111,8 @@ export function CompanyFormModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleCancel}>
|
||||
<ResizableDialogContent
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultWidth={500}
|
||||
|
|
@ -124,9 +124,9 @@ export function CompanyFormModal({
|
|||
modalId="company-form"
|
||||
userId={modalState.companyCode}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 회사명 입력 (필수) */}
|
||||
|
|
@ -255,7 +255,7 @@ export function CompanyFormModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading || isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -273,8 +273,8 @@ export function CompanyFormModal({
|
|||
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||
{isEditMode ? "수정" : "등록"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -321,20 +321,20 @@ export function CreateTableModal({
|
|||
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isDuplicateMode
|
||||
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
||||
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
||||
}
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 테이블 기본 정보 */}
|
||||
|
|
@ -452,7 +452,7 @@ export function CreateTableModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2">
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -482,8 +482,8 @@ export function CreateTableModal({
|
|||
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -148,14 +148,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-7xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
DDL 실행 로그 및 통계
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="logs" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
|
|
@ -407,7 +407,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -266,13 +266,13 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto sm:space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -564,7 +564,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -580,8 +580,8 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
|||
>
|
||||
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
ExternalDbConnectionAPI,
|
||||
|
|
@ -311,13 +311,13 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -623,8 +623,8 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
>
|
||||
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -66,11 +66,11 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{keyData ? "언어 키 수정" : "새 언어 키 추가"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{keyData ? "언어 키 수정" : "새 언어 키 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="companyCode">회사</Label>
|
||||
|
|
@ -131,7 +131,7 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
|
|||
<Button type="submit">{keyData ? "수정" : "추가"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -68,11 +68,11 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{languageData ? "언어 수정" : "새 언어 추가"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{languageData ? "언어 수정" : "새 언어 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
|
@ -141,8 +141,8 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
|||
<Button type="submit">{languageData ? "수정" : "추가"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
|
||||
DialogHeader,
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -225,14 +225,14 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
<DialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 단계 표시기 */}
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
|
|
@ -499,7 +499,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2">
|
||||
<DialogFooter className="gap-2">
|
||||
{step !== "basic" && !generationResult && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
이전
|
||||
|
|
@ -527,8 +527,8 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<Button variant="outline" onClick={handleClose}>
|
||||
{generationResult?.success ? "완료" : "취소"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -684,15 +684,15 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[600px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit
|
||||
? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
|
||||
: getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -1067,7 +1067,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
ExternalRestApiConnectionAPI,
|
||||
|
|
@ -275,11 +275,11 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
취소
|
||||
|
|
@ -597,8 +597,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? "저장 중..." : connection ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
|
@ -71,11 +71,11 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
|||
if (!role) return null;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">권한 그룹 삭제</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">권한 그룹 삭제</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 경고 메시지 */}
|
||||
|
|
@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -150,8 +150,8 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
|||
>
|
||||
{isLoading ? "삭제중..." : "삭제"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -184,11 +184,11 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
|||
);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 권한 그룹명 */}
|
||||
|
|
@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
|
@ -375,8 +375,8 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
|||
>
|
||||
{isLoading ? "처리중..." : isEditMode ? "수정" : "생성"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -179,14 +179,14 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto" aria-describedby="modal-description">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{connectionName} - SQL 쿼리 실행</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-5xl overflow-hidden" aria-describedby="modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{connectionName} - SQL 쿼리 실행</DialogTitle>
|
||||
<DialogDescription>
|
||||
데이터베이스에 대해 SQL SELECT 쿼리를 실행하고 결과를 확인할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 쿼리 입력 영역 */}
|
||||
<div className="space-y-4">
|
||||
|
|
@ -228,7 +228,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
<div className="bg-muted/50 space-y-4 rounded-md border p-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">사용 가능한 테이블</h3>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="max-h-[200px] overflow-hidden">
|
||||
<div className="space-y-2 pr-2">
|
||||
{tables.map((table) => (
|
||||
<div key={table.table_name} className="bg-card rounded-lg border p-3 shadow-sm">
|
||||
|
|
@ -263,7 +263,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
{loadingColumns ? (
|
||||
<div className="text-muted-foreground text-sm">컬럼 정보 로딩 중...</div>
|
||||
) : selectedTableColumns.length > 0 ? (
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="max-h-[200px] overflow-hidden">
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -332,7 +332,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
|
||||
{/* 결과 그리드 */}
|
||||
<div className="bg-card rounded-md border">
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-hidden">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
|
|
@ -378,7 +378,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -126,14 +126,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
{tableName} - 변경 이력
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
|
|
@ -261,7 +261,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Upload, Download, FileText, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTemplates } from "@/hooks/admin/useTemplates";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -32,11 +32,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className={getTypeColor()}>{title}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-muted-foreground text-sm">{message}</p>
|
||||
</div>
|
||||
|
|
@ -45,8 +45,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -441,11 +441,11 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -684,8 +684,8 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
{isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -152,17 +152,17 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col">
|
||||
<ResizableDialogHeader className="flex-shrink-0">
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
사용자 관리 이력
|
||||
</ResizableDialogTitle>
|
||||
</DialogTitle>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{userName} ({userId})의 변경이력을 조회합니다.
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 로딩 상태 */}
|
||||
|
|
@ -254,7 +254,7 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
|
|||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -127,11 +127,11 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
if (!userId) return null;
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>비밀번호 초기화</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>비밀번호 초기화</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4" onKeyDown={handleKeyDown}>
|
||||
{/* 대상 사용자 정보 */}
|
||||
|
|
@ -215,7 +215,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
{isLoading ? "처리중..." : "초기화"}
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</DialogContent>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
|
|
@ -225,6 +225,6 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
/>
|
||||
</ResizableDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import { DashboardProvider } from "@/contexts/DashboardContext";
|
|||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -639,23 +639,23 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
/>
|
||||
|
||||
{/* 저장 성공 모달 */}
|
||||
<ResizableDialog
|
||||
<Dialog
|
||||
open={successModalOpen}
|
||||
onOpenChange={() => {
|
||||
setSuccessModalOpen(false);
|
||||
router.push("/admin/dashboard");
|
||||
}}
|
||||
>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="bg-success/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<CheckCircle2 className="text-success h-6 w-6" />
|
||||
</div>
|
||||
<ResizableDialogTitle className="text-center">저장 완료</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-center">
|
||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
대시보드가 성공적으로 저장되었습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
|
@ -666,8 +666,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 초기화 확인 모달 */}
|
||||
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -174,11 +174,11 @@ export function DashboardSaveModal({
|
|||
const flatMenus = flattenMenus(currentMenus);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 대시보드 이름 */}
|
||||
|
|
@ -312,7 +312,7 @@ export function DashboardSaveModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -329,8 +329,8 @@ export function DashboardSaveModal({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -116,14 +116,14 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle>대시보드 저장 완료</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</ResizableDialogDescription>
|
||||
<DialogTitle>대시보드 저장 완료</DialogTitle>
|
||||
<DialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</DialogDescription>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-3">
|
||||
|
|
@ -200,13 +200,13 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>{assignToMenu ? "메뉴에 할당하고 완료" : "완료"}</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -233,7 +233,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 6. 자동 새로고침 간격 */}
|
||||
{/* 6. 소수점 자릿수 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">소수점 자릿수</Label>
|
||||
<Select
|
||||
value={(config.decimals ?? 0).toString()}
|
||||
onValueChange={(value) => onConfigChange({ decimals: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="자릿수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">정수 (0자리)</SelectItem>
|
||||
<SelectItem value="1" className="text-xs">소수점 1자리</SelectItem>
|
||||
<SelectItem value="2" className="text-xs">소수점 2자리</SelectItem>
|
||||
<SelectItem value="3" className="text-xs">소수점 3자리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
표시할 소수점 자릿수 (평균, 비율 등에 유용)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 7. 자동 새로고침 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">자동 새로고침</Label>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
|
@ -94,10 +94,10 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>자재 배치 설정</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>자재 배치 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 자재 정보 */}
|
||||
|
|
@ -233,7 +233,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isAdding}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -247,8 +247,8 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
"배치"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { materialApi } from "@/lib/api/yardLayoutApi";
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -64,14 +64,14 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle>새로운 3D필드 생성</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>필드 이름을 입력하세요</ResizableDialogDescription>
|
||||
<DialogTitle>새로운 3D필드 생성</DialogTitle>
|
||||
<DialogDescription>필드 이름을 입력하세요</DialogDescription>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -100,7 +100,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -114,8 +114,8 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
||||
|
|
@ -179,26 +179,15 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent
|
||||
className="max-w-[95vw] sm:max-w-[600px]"
|
||||
defaultWidth={600}
|
||||
defaultHeight={700}
|
||||
minWidth={400}
|
||||
minHeight={500}
|
||||
maxWidth={900}
|
||||
maxHeight={900}
|
||||
modalId="barcode-scan"
|
||||
userId={userId}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">바코드 스캔</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
카메라로 바코드를 스캔하세요.
|
||||
{targetField && ` (대상 필드: ${targetField})`}
|
||||
모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 카메라 권한 요청 대기 중 */}
|
||||
|
|
@ -337,7 +326,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
|
|
@ -376,9 +365,9 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|||
확인
|
||||
</Button>
|
||||
)}
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -385,27 +385,27 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}, [open]);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||
defaultWidth={1000}
|
||||
defaultHeight={700}
|
||||
minWidth={700}
|
||||
minHeight={500}
|
||||
maxWidth={1400}
|
||||
maxHeight={900}
|
||||
modalId={`excel-upload-${tableName}`}
|
||||
userId={userId || "guest"}
|
||||
style={{
|
||||
width: "1000px",
|
||||
height: "700px",
|
||||
minWidth: "700px",
|
||||
minHeight: "500px",
|
||||
maxWidth: "1400px",
|
||||
maxHeight: "900px",
|
||||
}}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
엑셀 데이터 업로드
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 스텝 인디케이터 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -863,7 +863,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
||||
|
|
@ -889,8 +889,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
{isUploading ? "업로드 중..." : "다음"}
|
||||
</Button>
|
||||
)}
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
|
@ -514,16 +514,18 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
|
|
@ -593,36 +595,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })} // undefined일 때는 prop 자체를 전달하지 않음
|
||||
defaultWidth={600}
|
||||
defaultHeight={800}
|
||||
minWidth={500}
|
||||
minHeight={400}
|
||||
maxWidth={1600}
|
||||
maxHeight={1200}
|
||||
modalId={persistedModalId}
|
||||
userId={userId || "guest"}
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })}
|
||||
>
|
||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
|
||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||
{modalState.description && !loading && (
|
||||
<ResizableDialogDescription className="text-muted-foreground text-xs">
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{modalState.description}
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
)}
|
||||
{loading && (
|
||||
<ResizableDialogDescription className="text-xs">
|
||||
<DialogDescription className="text-xs">
|
||||
{loading ? "화면을 불러오는 중입니다..." : ""}
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -728,8 +722,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,18 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -216,7 +205,7 @@ export function TableHistoryModal({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] sm:max-w-[900px]">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Clock className="h-5 w-5" />
|
||||
변경 이력{" "}
|
||||
{!recordId && (
|
||||
|
|
@ -224,12 +213,12 @@ export function TableHistoryModal({
|
|||
전체
|
||||
</Badge>
|
||||
)}
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{recordId
|
||||
? `${recordDisplayValue || recordLabel || "-"} - ${tableName} 테이블`
|
||||
: `${tableName} 테이블 전체 이력`}
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -150,23 +150,14 @@ export function TableOptionsModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent
|
||||
defaultWidth={700}
|
||||
defaultHeight={600}
|
||||
minWidth={500}
|
||||
minHeight={400}
|
||||
maxWidth={1200}
|
||||
maxHeight={900}
|
||||
modalId={`table-options-${tableName}`}
|
||||
userId={userId}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-base sm:text-lg">테이블 옵션</ResizableDialogTitle>
|
||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
||||
컬럼 표시/숨기기, 순서 변경, 틀고정 등을 설정할 수 있습니다. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">테이블 옵션</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
컬럼 표시/숨기기, 순서 변경, 틀고정 등을 설정할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="columns" className="flex flex-col flex-1 overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-3 flex-shrink-0">
|
||||
|
|
@ -303,7 +294,7 @@ export function TableOptionsModal({
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ResizableDialogFooter className="gap-2 sm:gap-0 mt-4">
|
||||
<DialogFooter className="gap-2 sm:gap-0 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
|
|
@ -324,9 +315,9 @@ export function TableOptionsModal({
|
|||
>
|
||||
저장
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
||||
|
||||
// 지역 필터 상태
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>("all");
|
||||
|
||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
|
@ -1165,6 +1170,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 지역 필터 */}
|
||||
<Select value={selectedRegion} onValueChange={setSelectedRegion}>
|
||||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||||
<SelectValue placeholder="지역 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{regionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 이동경로 날짜 선택 */}
|
||||
{selectedUserId && (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
||||
|
|
@ -1442,8 +1461,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";
|
||||
|
|
@ -1771,7 +1790,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
{/* 하단 정보 */}
|
||||
{(markers.length > 0 || polygons.length > 0) && (
|
||||
<div className="text-muted-foreground border-t p-2 text-xs">
|
||||
{markers.length > 0 && `마커 ${markers.length}개`}
|
||||
{markers.length > 0 && (
|
||||
<>
|
||||
마커 {filterVehiclesByRegion(markers, selectedRegion).length}개
|
||||
{selectedRegion !== "all" && ` (전체 ${markers.length}개)`}
|
||||
</>
|
||||
)}
|
||||
{markers.length > 0 && polygons.length > 0 && " · "}
|
||||
{polygons.length > 0 && `영역 ${polygons.length}개`}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -673,14 +673,14 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<ResizableDialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Dialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent className="max-h-[80vh] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Link className="h-4 w-4" />
|
||||
필드 연결 설정
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기본 연결 설정 */}
|
||||
|
|
@ -719,16 +719,16 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
{renderConnectionTypeSettings()}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isButtonDisabled()}>
|
||||
연결 생성
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -133,11 +133,11 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-lg font-semibold">📊 관계도 저장</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">📊 관계도 저장</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 관계도 이름 입력 */}
|
||||
|
|
@ -203,7 +203,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
<CardTitle className="text-sm">관계 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-60 space-y-3 overflow-y-auto">
|
||||
<div className="max-h-60 space-y-3 overflow-hidden">
|
||||
{relationships.map((relationship, index) => (
|
||||
<div
|
||||
key={relationship.id || index}
|
||||
|
|
@ -242,7 +242,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -260,9 +260,9 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
"저장하기"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 저장 성공 알림 모달 */}
|
||||
<AlertDialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
|||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { AggregateNode } from "./nodes/AggregateNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
|
|
@ -41,6 +42,7 @@ const nodeTypes = {
|
|||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
aggregate: AggregateNode,
|
||||
// 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 (Aggregate Node)
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Calculator, Layers } from "lucide-react";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
// 집계 함수별 아이콘/라벨
|
||||
const AGGREGATE_FUNCTION_LABELS: Record<AggregateFunction, string> = {
|
||||
SUM: "합계",
|
||||
COUNT: "개수",
|
||||
AVG: "평균",
|
||||
MIN: "최소",
|
||||
MAX: "최대",
|
||||
FIRST: "첫번째",
|
||||
LAST: "마지막",
|
||||
};
|
||||
|
||||
export const AggregateNode = memo(({ data, selected }: NodeProps<AggregateNodeData>) => {
|
||||
const groupByCount = data.groupByFields?.length || 0;
|
||||
const aggregationCount = data.aggregations?.length || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<Calculator className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "집계"}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3 space-y-3">
|
||||
{/* 그룹 기준 */}
|
||||
{groupByCount > 0 && (
|
||||
<div className="rounded bg-purple-50 p-2">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Layers className="h-3 w-3 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-700">그룹 기준</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.groupByFields.slice(0, 3).map((field, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
{data.groupByFields.length > 3 && (
|
||||
<span className="text-xs text-purple-500">+{data.groupByFields.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 집계 연산 */}
|
||||
{aggregationCount > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.aggregations.slice(0, 4).map((agg, idx) => (
|
||||
<div key={agg.id || idx} className="rounded bg-gray-50 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="rounded bg-purple-600 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{agg.outputFieldLabel || agg.outputField}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{agg.sourceFieldLabel || agg.sourceField}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.aggregations.length > 4 && (
|
||||
<div className="text-xs text-gray-400 text-center">
|
||||
... 외 {data.aggregations.length - 4}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">집계 연산 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AggregateNode.displayName = "AggregateNode";
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
|||
import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties";
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { AggregateProperties } from "./properties/AggregateProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
|
|
@ -122,6 +123,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
|||
case "dataTransform":
|
||||
return <DataTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "aggregate":
|
||||
return <AggregateProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
|
|
@ -157,9 +161,11 @@ function getNodeTypeLabel(type: NodeType): string {
|
|||
tableSource: "테이블 소스",
|
||||
externalDBSource: "외부 DB 소스",
|
||||
restAPISource: "REST API 소스",
|
||||
referenceLookup: "참조 조회",
|
||||
condition: "조건 분기",
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
aggregate: "집계",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 집계 노드 속성 편집 패널
|
||||
* SUM, COUNT, AVG, MIN, MAX 등 집계 연산 설정
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Calculator, Layers, Filter } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor";
|
||||
|
||||
interface AggregatePropertiesProps {
|
||||
nodeId: string;
|
||||
data: AggregateNodeData;
|
||||
}
|
||||
|
||||
// 집계 함수 옵션
|
||||
const AGGREGATE_FUNCTIONS: Array<{ value: AggregateFunction; label: string; description: string }> = [
|
||||
{ value: "SUM", label: "합계 (SUM)", description: "숫자 필드의 합계를 계산합니다" },
|
||||
{ value: "COUNT", label: "개수 (COUNT)", description: "레코드 개수를 계산합니다" },
|
||||
{ value: "AVG", label: "평균 (AVG)", description: "숫자 필드의 평균을 계산합니다" },
|
||||
{ value: "MIN", label: "최소 (MIN)", description: "최소값을 찾습니다" },
|
||||
{ value: "MAX", label: "최대 (MAX)", description: "최대값을 찾습니다" },
|
||||
{ value: "FIRST", label: "첫번째 (FIRST)", description: "그룹의 첫 번째 값을 가져옵니다" },
|
||||
{ value: "LAST", label: "마지막 (LAST)", description: "그룹의 마지막 값을 가져옵니다" },
|
||||
];
|
||||
|
||||
// 비교 연산자 옵션
|
||||
const OPERATORS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "보다 큼 (>)" },
|
||||
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||
{ value: "<", label: "보다 작음 (<)" },
|
||||
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||
];
|
||||
|
||||
export function AggregateProperties({ nodeId, data }: AggregatePropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
// 로컬 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "집계");
|
||||
const [groupByFields, setGroupByFields] = useState(data.groupByFields || []);
|
||||
const [aggregations, setAggregations] = useState(data.aggregations || []);
|
||||
const [havingConditions, setHavingConditions] = useState(data.havingConditions || []);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string; type?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "집계");
|
||||
setGroupByFields(data.groupByFields || []);
|
||||
setAggregations(data.aggregations || []);
|
||||
setHavingConditions(data.havingConditions || []);
|
||||
}, [data]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기
|
||||
useEffect(() => {
|
||||
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string; type?: string }> = [];
|
||||
sourceNodes.forEach((node) => {
|
||||
if (node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
type: field.type,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 저장 함수
|
||||
const saveToNode = useCallback(
|
||||
(updates: Partial<AggregateNodeData>) => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
groupByFields,
|
||||
aggregations,
|
||||
havingConditions,
|
||||
...updates,
|
||||
});
|
||||
},
|
||||
[nodeId, updateNode, displayName, groupByFields, aggregations, havingConditions]
|
||||
);
|
||||
|
||||
// 그룹 기준 필드 토글
|
||||
const handleGroupByToggle = (fieldName: string, checked: boolean) => {
|
||||
let newGroupByFields;
|
||||
if (checked) {
|
||||
const field = sourceFields.find((f) => f.name === fieldName);
|
||||
newGroupByFields = [...groupByFields, { field: fieldName, fieldLabel: field?.label }];
|
||||
} else {
|
||||
newGroupByFields = groupByFields.filter((f) => f.field !== fieldName);
|
||||
}
|
||||
setGroupByFields(newGroupByFields);
|
||||
saveToNode({ groupByFields: newGroupByFields });
|
||||
};
|
||||
|
||||
// 집계 연산 추가
|
||||
const handleAddAggregation = () => {
|
||||
const newAggregation = {
|
||||
id: `agg_${Date.now()}`,
|
||||
sourceField: "",
|
||||
sourceFieldLabel: "",
|
||||
function: "SUM" as AggregateFunction,
|
||||
outputField: "",
|
||||
outputFieldLabel: "",
|
||||
};
|
||||
const newAggregations = [...aggregations, newAggregation];
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// 집계 연산 삭제
|
||||
const handleRemoveAggregation = (index: number) => {
|
||||
const newAggregations = aggregations.filter((_, i) => i !== index);
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// 집계 연산 변경
|
||||
const handleAggregationChange = (index: number, field: string, value: any) => {
|
||||
const newAggregations = [...aggregations];
|
||||
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newAggregations[index] = {
|
||||
...newAggregations[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
// 출력 필드명 자동 생성 (예: sum_amount)
|
||||
outputField:
|
||||
newAggregations[index].outputField ||
|
||||
`${newAggregations[index].function.toLowerCase()}_${value}`,
|
||||
};
|
||||
} else if (field === "function") {
|
||||
newAggregations[index] = {
|
||||
...newAggregations[index],
|
||||
function: value,
|
||||
// 출력 필드명 업데이트
|
||||
outputField: newAggregations[index].sourceField
|
||||
? `${value.toLowerCase()}_${newAggregations[index].sourceField}`
|
||||
: newAggregations[index].outputField,
|
||||
};
|
||||
} else {
|
||||
newAggregations[index] = { ...newAggregations[index], [field]: value };
|
||||
}
|
||||
|
||||
setAggregations(newAggregations);
|
||||
saveToNode({ aggregations: newAggregations });
|
||||
};
|
||||
|
||||
// HAVING 조건 추가
|
||||
const handleAddHavingCondition = () => {
|
||||
const newCondition = {
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
};
|
||||
const newConditions = [...havingConditions, newCondition];
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// HAVING 조건 삭제
|
||||
const handleRemoveHavingCondition = (index: number) => {
|
||||
const newConditions = havingConditions.filter((_, i) => i !== index);
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// HAVING 조건 변경
|
||||
const handleHavingConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...havingConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setHavingConditions(newConditions);
|
||||
saveToNode({ havingConditions: newConditions });
|
||||
};
|
||||
|
||||
// 집계 결과 필드 목록 (HAVING 조건에서 선택용)
|
||||
const aggregatedFields = aggregations
|
||||
.filter((agg) => agg.outputField)
|
||||
.map((agg) => ({
|
||||
name: agg.outputField,
|
||||
label: agg.outputFieldLabel || agg.outputField,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-purple-50 p-2">
|
||||
<Calculator className="h-4 w-4 text-purple-600" />
|
||||
<span className="font-semibold text-purple-600">집계 노드</span>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
saveToNode({ displayName: e.target.value });
|
||||
}}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 기준 필드 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">그룹 기준 필드</h3>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">
|
||||
선택한 필드를 기준으로 데이터를 그룹화합니다. 선택하지 않으면 전체 데이터를 하나의 그룹으로 처리합니다.
|
||||
</p>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
<div className="space-y-1">
|
||||
{sourceFields.map((field) => {
|
||||
const isChecked = groupByFields.some((f) => f.field === field.name);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-purple-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`groupby_${field.name}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => handleGroupByToggle(field.name, checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`groupby_${field.name}`}
|
||||
className="flex-1 cursor-pointer text-xs"
|
||||
>
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="ml-1 text-gray-400">({field.name})</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByFields.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{groupByFields.map((field) => (
|
||||
<span
|
||||
key={field.field}
|
||||
className="inline-flex items-center rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700"
|
||||
>
|
||||
{field.fieldLabel || field.field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 집계 연산 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">집계 연산</h3>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddAggregation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연산 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">SUM, COUNT, AVG 등 집계 연산을 설정합니다.</p>
|
||||
|
||||
{aggregations.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||
집계 연산을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{aggregations.map((agg, index) => (
|
||||
<div key={agg.id || index} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">집계 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveAggregation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 집계 함수 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">집계 함수</Label>
|
||||
<Select
|
||||
value={agg.function}
|
||||
onValueChange={(value) =>
|
||||
handleAggregationChange(index, "function", value as AggregateFunction)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGGREGATE_FUNCTIONS.map((func) => (
|
||||
<SelectItem key={func.value} value={func.value} className="text-xs">
|
||||
<div>
|
||||
<div className="font-medium">{func.label}</div>
|
||||
<div className="text-gray-400">{func.description}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 소스 필드 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={agg.sourceField || ""}
|
||||
onValueChange={(value) => handleAggregationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="집계할 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드명 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드명</Label>
|
||||
<Input
|
||||
value={agg.outputField || ""}
|
||||
onChange={(e) => handleAggregationChange(index, "outputField", e.target.value)}
|
||||
placeholder="예: total_amount"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
집계 결과가 저장될 필드명입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">출력 필드 라벨 (선택)</Label>
|
||||
<Input
|
||||
value={agg.outputFieldLabel || ""}
|
||||
onChange={(e) => handleAggregationChange(index, "outputFieldLabel", e.target.value)}
|
||||
placeholder="예: 총 금액"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* HAVING 조건 (선택) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-semibold">집계 후 필터 (HAVING)</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddHavingCondition}
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={aggregations.length === 0}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-gray-500">집계 결과에 대한 필터링 조건을 설정합니다 (선택 사항).</p>
|
||||
|
||||
{havingConditions.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-400">
|
||||
집계 후 필터링이 필요하면 조건을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{havingConditions.map((condition, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
|
||||
{/* 집계 결과 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => handleHavingConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aggregatedFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value) => handleHavingConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 비교값 */}
|
||||
<Input
|
||||
value={condition.value || ""}
|
||||
onChange={(e) => handleHavingConditionChange(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveHavingCondition(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{(groupByFields.length > 0 || aggregations.length > 0) && (
|
||||
<div className="rounded border bg-gray-50 p-3">
|
||||
<h4 className="mb-2 text-xs font-semibold text-gray-700">집계 결과 미리보기</h4>
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">그룹 기준:</span>{" "}
|
||||
{groupByFields.length > 0
|
||||
? groupByFields.map((f) => f.fieldLabel || f.field).join(", ")
|
||||
: "전체 (그룹 없음)"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">집계 컬럼:</span>{" "}
|
||||
{aggregations.length > 0
|
||||
? aggregations
|
||||
.filter((a) => a.outputField)
|
||||
.map((a) => `${a.function}(${a.sourceFieldLabel || a.sourceField}) → ${a.outputFieldLabel || a.outputField}`)
|
||||
.join(", ")
|
||||
: "없음"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +236,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
console.log("✅ 집계 노드 발견");
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
console.log(` 📊 ${nodeData.groupByFields.length}개 그룹 필드 발견`);
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
console.log(` 📊 ${aggregations.length}개 집계 함수 발견`);
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
sourcePath: currentPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id;
|
||||
|
|
@ -266,7 +307,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,43 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 2️⃣ REST API 소스 노드
|
||||
// 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 3️⃣ REST API 소스 노드
|
||||
else if (node.type === "restAPISource") {
|
||||
foundRestAPI = true;
|
||||
const responseFields = (node.data as any).responseFields;
|
||||
|
|
@ -229,7 +265,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
|
||||
|
|
@ -251,7 +287,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,43 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
});
|
||||
}
|
||||
}
|
||||
// 3️⃣ 테이블/외부DB 소스 노드
|
||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||
else if (node.type === "aggregate") {
|
||||
const nodeData = node.data as any;
|
||||
|
||||
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
|
||||
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
|
||||
nodeData.groupByFields.forEach((groupField: any) => {
|
||||
const fieldName = groupField.field || groupField.fieldName;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: groupField.fieldLabel || fieldName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
|
||||
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
|
||||
if (Array.isArray(aggregations)) {
|
||||
aggregations.forEach((aggFunc: any) => {
|
||||
// outputField 또는 targetField 둘 다 지원
|
||||
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||
// function 또는 aggregateType 둘 다 지원
|
||||
const funcType = aggFunc.function || aggFunc.aggregateType;
|
||||
if (outputFieldName) {
|
||||
fields.push({
|
||||
name: outputFieldName,
|
||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||
}
|
||||
// 4️⃣ 테이블/외부DB 소스 노드
|
||||
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||
|
||||
|
|
@ -234,7 +270,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||
}
|
||||
}
|
||||
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||
else {
|
||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||
fields.push(...upperResult.fields);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
},
|
||||
{
|
||||
type: "aggregate",
|
||||
label: "집계",
|
||||
icon: "",
|
||||
description: "SUM, COUNT, AVG 등 집계 연산을 수행합니다",
|
||||
category: "transform",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
|
@ -130,11 +130,11 @@ export function FlowDataListModal({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{stepName}
|
||||
<Badge variant="secondary">{data.length}건</Badge>
|
||||
</ResizableDialogTitle>
|
||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -48,11 +48,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className={getTypeColor()}>{title}</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
|
|
@ -61,8 +61,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -165,11 +165,11 @@ export function ProfileModal({
|
|||
};
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>프로필 수정</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>프로필 수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* 프로필 사진 섹션 */}
|
||||
|
|
@ -449,16 +449,16 @@ export function ProfileModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
|
|
@ -471,14 +471,14 @@ export function ProfileModal({
|
|||
|
||||
{/* 새 차량 등록 모달 */}
|
||||
{isVehicleRegisterModalOpen && newVehicleData && onNewVehicleDataChange && onRegisterVehicle && onCloseVehicleRegisterModal && (
|
||||
<ResizableDialog open={isVehicleRegisterModalOpen} onOpenChange={onCloseVehicleRegisterModal}>
|
||||
<ResizableDialogContent className="sm:max-w-[400px]">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>새 차량 등록</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
<Dialog open={isVehicleRegisterModalOpen} onOpenChange={onCloseVehicleRegisterModal}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 차량 등록</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 차량 정보를 입력해주세요.
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -501,16 +501,16 @@ export function ProfileModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onCloseVehicleRegisterModal}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={onRegisterVehicle}>
|
||||
등록
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
DialogContent,
|
||||
DialogHeader,
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
|
|
@ -186,13 +186,13 @@ export default function MailDetailModal({
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="text-xl font-bold truncate">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold truncate">
|
||||
메일 상세
|
||||
</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
|
|
@ -375,8 +375,8 @@ export default function MailDetailModal({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -141,9 +141,9 @@ export function LangKeyModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle>{langKey ? "다국어 키 수정" : "새 다국어 키 추가"}</ResizableDialogTitle>
|
||||
<DialogTitle>{langKey ? "다국어 키 수정" : "새 다국어 키 추가"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -48,7 +48,20 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
<Label className="text-xs font-medium sm:text-sm">구분 유형</Label>
|
||||
<Select
|
||||
value={part.partType}
|
||||
onValueChange={(value) => onUpdate({ partType: value as CodePartType })}
|
||||
onValueChange={(value) => {
|
||||
const newPartType = value as CodePartType;
|
||||
// 타입 변경 시 해당 타입의 기본 autoConfig 설정
|
||||
const defaultAutoConfig: Record<string, any> = {
|
||||
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||
number: { numberLength: 4, numberValue: 1 },
|
||||
date: { dateFormat: "YYYYMMDD" },
|
||||
text: { textValue: "CODE" },
|
||||
};
|
||||
onUpdate({
|
||||
partType: newPartType,
|
||||
autoConfig: defaultAutoConfig[newPartType] || {}
|
||||
});
|
||||
}}
|
||||
disabled={isPreview}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -196,10 +196,31 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
try {
|
||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||
|
||||
// 파트별 기본 autoConfig 정의
|
||||
const defaultAutoConfigs: Record<string, any> = {
|
||||
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||
number: { numberLength: 4, numberValue: 1 },
|
||||
date: { dateFormat: "YYYYMMDD" },
|
||||
text: { textValue: "" },
|
||||
};
|
||||
|
||||
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
|
||||
const partsWithDefaults = currentRule.parts.map((part) => {
|
||||
if (part.generationMethod === "auto") {
|
||||
const defaults = defaultAutoConfigs[part.partType] || {};
|
||||
return {
|
||||
...part,
|
||||
autoConfig: { ...defaults, ...part.autoConfig },
|
||||
};
|
||||
}
|
||||
return part;
|
||||
});
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export function OrderRegistrationModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">수주 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import { useState, useEffect } from "react";
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
|
||||
DialogHeader,
|
||||
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -120,8 +119,8 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
|||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<ResizableDialogTitle>새 리포트 생성</ResizableDialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</ResizableDialogDescription>
|
||||
<DialogTitle>새 리포트 생성</DialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
|
|
@ -207,7 +206,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -221,7 +220,7 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
|||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,10 +92,17 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
}, [initialFormData]);
|
||||
|
||||
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
useEffect(() => {
|
||||
// 우측 화면인 경우에만 적용
|
||||
if (position !== "right" || !splitPanelContext) return;
|
||||
|
||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||
if (splitPanelContext.disableAutoDataTransfer) {
|
||||
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedData = splitPanelContext.getMappedParentData();
|
||||
if (Object.keys(mappedData).length > 0) {
|
||||
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
|||
rightScreenId={config?.rightScreenId || null}
|
||||
parentDataMapping={config?.parentDataMapping || []}
|
||||
linkedFilters={config?.linkedFilters || []}
|
||||
disableAutoDataTransfer={config?.disableAutoDataTransfer ?? false}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ export default function CopyScreenModal({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -271,21 +271,11 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
};
|
||||
|
||||
return (
|
||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResizableDialogContent
|
||||
className="sm:max-w-lg"
|
||||
defaultWidth={600}
|
||||
defaultHeight={700}
|
||||
minWidth={500}
|
||||
minHeight={600}
|
||||
maxWidth={900}
|
||||
maxHeight={900}
|
||||
modalId="create-screen"
|
||||
userId={user?.userId}
|
||||
>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle>새 화면 생성</ResizableDialogTitle>
|
||||
</ResizableDialogHeader>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 화면 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -603,15 +593,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="mt-4">
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || submitting} variant="default">
|
||||
생성
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
|
@ -678,14 +678,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
|
|
@ -696,32 +699,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const modalStyle = getModalStyle();
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
style={modalStyle.style}
|
||||
defaultWidth={800}
|
||||
defaultHeight={600}
|
||||
minWidth={600}
|
||||
minHeight={400}
|
||||
maxWidth={1400}
|
||||
maxHeight={1000}
|
||||
modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined}
|
||||
userId={user?.userId}
|
||||
>
|
||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
|
||||
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
||||
{modalState.description && !loading && (
|
||||
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
|
||||
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
|
||||
)}
|
||||
{loading && (
|
||||
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
|
||||
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -812,8 +807,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -352,9 +352,9 @@ export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps>
|
|||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<ResizableDialogTitle className="text-xl font-semibold">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
파일 첨부 관리 - {component.label || component.id}
|
||||
</ResizableDialogTitle>
|
||||
</DialogTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -2471,7 +2471,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 기존 데이터 추가 모달 (제거 예정 - SaveModal로 대체됨) */}
|
||||
<Dialog open={false} onOpenChange={() => {}}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{component.addModalConfig?.title || "새 데이터 추가"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -2517,7 +2517,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 기존 데이터 수정 모달 (제거 예정 - SaveModal로 대체됨) */}
|
||||
<Dialog open={false} onOpenChange={() => {}}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-hidden ${getModalSizeClass()}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>데이터 수정</DialogTitle>
|
||||
<DialogDescription>선택된 데이터를 수정합니다.</DialogDescription>
|
||||
|
|
@ -2773,7 +2773,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 파일 관리 모달 */}
|
||||
<Dialog open={showFileManagementModal} onOpenChange={setShowFileManagementModal}>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Folder className="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
|
@ -441,6 +441,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 렉 구조 컴포넌트 처리
|
||||
if (comp.type === "component" && componentType === "rack-structure") {
|
||||
const { RackStructureComponent } = require("@/lib/registry/components/rack-structure/RackStructureComponent");
|
||||
const componentConfig = (comp as any).componentConfig || {};
|
||||
// config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접
|
||||
const rackConfig = componentConfig.config || componentConfig;
|
||||
|
||||
console.log("🏗️ 렉 구조 컴포넌트 렌더링:", {
|
||||
componentType,
|
||||
componentConfig,
|
||||
rackConfig,
|
||||
fieldMapping: rackConfig.fieldMapping,
|
||||
formData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<RackStructureComponent
|
||||
config={rackConfig}
|
||||
formData={formData}
|
||||
tableName={tableName}
|
||||
onChange={(locations: any[]) => {
|
||||
console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개");
|
||||
// 컴포넌트의 columnName을 키로 사용
|
||||
const fieldKey = (comp as any).columnName || "_rackStructureLocations";
|
||||
updateFormData(fieldKey, locations);
|
||||
}}
|
||||
isPreview={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/resizable-dialog";
|
||||
import { DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -119,17 +118,19 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 분할 패널에서 매핑된 부모 데이터 가져오기
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
const splitPanelMappedData = React.useMemo(() => {
|
||||
if (splitPanelContext) {
|
||||
if (splitPanelContext && !splitPanelContext.disableAutoDataTransfer) {
|
||||
return splitPanelContext.getMappedParentData();
|
||||
}
|
||||
return {};
|
||||
}, [splitPanelContext, splitPanelContext?.selectedLeftData]);
|
||||
}, [splitPanelContext, splitPanelContext?.selectedLeftData, splitPanelContext?.disableAutoDataTransfer]);
|
||||
|
||||
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합)
|
||||
const formData = React.useMemo(() => {
|
||||
const baseData = externalFormData || localFormData;
|
||||
// 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만)
|
||||
// disableAutoDataTransfer가 true이면 자동 병합 안함
|
||||
if (Object.keys(splitPanelMappedData).length > 0) {
|
||||
const merged = { ...baseData };
|
||||
for (const [key, value] of Object.entries(splitPanelMappedData)) {
|
||||
|
|
@ -776,17 +777,15 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
{popupScreen && (
|
||||
<ResizableDialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||
<ResizableDialogContent
|
||||
className="overflow-hidden p-0"
|
||||
defaultWidth={popupScreen.size === "small" ? 600 : popupScreen.size === "large" ? 1400 : 1000}
|
||||
defaultHeight={800}
|
||||
minWidth={500}
|
||||
minHeight={400}
|
||||
maxWidth={1600}
|
||||
maxHeight={1200}
|
||||
modalId={`popup-screen-${popupScreen.screenId}`}
|
||||
userId={user?.userId || "guest"}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 max-w-none"
|
||||
style={{
|
||||
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
||||
height: "800px",
|
||||
maxWidth: "95vw",
|
||||
maxHeight: "90vh",
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{popupScreen.title}</DialogTitle>
|
||||
|
|
@ -820,8 +819,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
ResizableDialog,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -345,26 +345,26 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||
<ResizableDialogContent className="max-w-2xl">
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{assignmentSuccess ? (
|
||||
// 성공 화면
|
||||
<>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{assignmentMessage.includes("나중에")
|
||||
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
|
||||
: "화면이 성공적으로 메뉴에 할당되었습니다."}
|
||||
</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-green-50 p-4">
|
||||
|
|
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 타이머 정리
|
||||
|
|
@ -407,19 +407,19 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
<Monitor className="mr-2 h-4 w-4" />
|
||||
화면 목록으로 이동
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
// 기본 할당 화면
|
||||
<>
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
메뉴에 화면 할당
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
||||
</ResizableDialogDescription>
|
||||
</DialogDescription>
|
||||
{screenInfo && (
|
||||
<div className="bg-accent mt-2 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -432,7 +432,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 메뉴 선택 (검색 기능 포함) */}
|
||||
|
|
@ -550,7 +550,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleAssignLater} disabled={assigning}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
나중에 할당
|
||||
|
|
@ -572,22 +572,22 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 화면 교체 확인 대화상자 */}
|
||||
<ResizableDialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||
<ResizableDialogContent className="max-w-md">
|
||||
<ResizableDialogHeader>
|
||||
<ResizableDialogTitle className="flex items-center gap-2">
|
||||
<Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-orange-600" />
|
||||
화면 교체 확인
|
||||
</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</ResizableDialogDescription>
|
||||
</ResizableDialogHeader>
|
||||
</DialogTitle>
|
||||
<DialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기존 화면 목록 */}
|
||||
|
|
@ -628,7 +628,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="flex gap-2">
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowReplaceDialog(false)} disabled={assigning}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
@ -652,9 +652,9 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
</ResizableDialogFooter>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, createContext, useContext } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Monitor, Tablet, Smartphone } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
|
@ -76,7 +76,7 @@ export const ResponsivePreviewModal: React.FC<ResponsivePreviewModalProps> = ({
|
|||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[95vh] max-w-[95vw] p-0">
|
||||
<DialogHeader className="border-b px-6 pt-6 pb-4">
|
||||
<ResizableDialogTitle>반응형 미리보기</ResizableDialogTitle>
|
||||
<DialogTitle>반응형 미리보기</DialogTitle>
|
||||
|
||||
{/* 디바이스 선택 버튼들 */}
|
||||
<div className="mt-4 flex gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Save, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -232,22 +232,19 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const dynamicSize = calculateDynamicSize();
|
||||
|
||||
return (
|
||||
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<ResizableDialogContent
|
||||
modalId={`save-modal-${screenId}`}
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<DialogContent
|
||||
style={{
|
||||
width: `${dynamicSize.width}px`,
|
||||
height: `${dynamicSize.height}px`, // 화면관리 설정 크기 그대로 사용
|
||||
minWidth: "400px",
|
||||
minHeight: "300px",
|
||||
}}
|
||||
defaultWidth={600} // 폴백용 기본값
|
||||
defaultHeight={400} // 폴백용 기본값
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
className="gap-0 p-0"
|
||||
className="gap-0 p-0 max-w-none"
|
||||
>
|
||||
<ResizableDialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<DialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
|
||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
||||
{isSaving ? (
|
||||
|
|
@ -267,7 +264,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-auto p-6 flex-1">
|
||||
{loading ? (
|
||||
|
|
@ -376,7 +373,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizableDialogContent>
|
||||
</ResizableDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2239,10 +2239,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`,
|
||||
});
|
||||
|
||||
// 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스)
|
||||
// 예: "창고코드" → "warehouse_code" 또는 그대로 유지
|
||||
const generateDefaultColumnName = (label: string): string => {
|
||||
// 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능)
|
||||
// 영문의 경우 스네이크 케이스로 변환
|
||||
if (/[가-힣]/.test(label)) {
|
||||
// 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환
|
||||
return label.replace(/\s+/g, "_").toLowerCase();
|
||||
}
|
||||
// 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환
|
||||
return label
|
||||
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||
.replace(/\s+/g, "_")
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const newComponent: ComponentData = {
|
||||
id: generateComponentId(),
|
||||
type: "component", // ✅ 새 컴포넌트 시스템 사용
|
||||
label: component.name,
|
||||
columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성
|
||||
widgetType: component.webType,
|
||||
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
||||
position: snappedPosition,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🆕 openModalWithData 전용 필드 매핑 상태
|
||||
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
|
||||
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
|
|
@ -318,6 +326,88 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
loadColumns();
|
||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||
|
||||
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const actionType = config.action?.type;
|
||||
if (actionType !== "openModalWithData") return;
|
||||
|
||||
const loadModalMappingColumns = async () => {
|
||||
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
||||
// allComponents에서 split-panel-layout 또는 table-list 찾기
|
||||
let sourceTableName: string | null = null;
|
||||
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
// 분할 패널의 좌측 테이블명
|
||||
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
|
||||
(comp as any).componentConfig?.leftTableName;
|
||||
break;
|
||||
}
|
||||
if (compType === "table-list") {
|
||||
sourceTableName = (comp as any).componentConfig?.tableName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setModalSourceColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 타겟 화면의 테이블 컬럼 로드
|
||||
const targetScreenId = config.action?.targetScreenId;
|
||||
if (targetScreenId) {
|
||||
try {
|
||||
// 타겟 화면 정보 가져오기
|
||||
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
||||
if (screenResponse.data.success && screenResponse.data.data) {
|
||||
const targetTableName = screenResponse.data.data.tableName;
|
||||
if (targetTableName) {
|
||||
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
||||
if (columnResponse.data.success) {
|
||||
let columnData = columnResponse.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setModalTargetColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadModalMappingColumns();
|
||||
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
|
|
@ -1024,6 +1114,194 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑 (선택사항)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentMappings = config.action?.fieldMappings || [];
|
||||
const newMapping = { sourceField: "", targetField: "" };
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", [...currentMappings, newMapping]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
소스 테이블의 컬럼명이 타겟 화면의 입력 필드 컬럼명과 다를 때 매핑을 설정하세요.
|
||||
<br />
|
||||
예: warehouse_code → warehouse_id (분할 패널의 창고코드를 모달의 창고ID에 매핑)
|
||||
</p>
|
||||
|
||||
{/* 컬럼 로드 상태 표시 */}
|
||||
{modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
소스 컬럼: {modalSourceColumns.length}개 / 타겟 컬럼: {modalTargetColumns.length}개
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-amber-600 bg-amber-50 p-2 rounded dark:bg-amber-950/20">
|
||||
분할 패널 또는 테이블 컴포넌트와 대상 화면을 설정하면 컬럼 목록이 로드됩니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.action?.fieldMappings || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
매핑이 없으면 같은 이름의 컬럼끼리 자동으로 매핑됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={modalSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={modalSourceSearch[index] || ""}
|
||||
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], sourceField: col.name };
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={modalTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.targetField
|
||||
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "타겟 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={modalTargetSearch[index] || ""}
|
||||
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], targetField: col.name };
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -584,20 +584,23 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedComponent.type === "widget" && (
|
||||
{(selectedComponent.type === "widget" || selectedComponent.type === "component") && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="columnName" className="text-xs font-medium">
|
||||
컬럼명 (읽기 전용)
|
||||
컬럼명 (필드명)
|
||||
</Label>
|
||||
<Input
|
||||
id="columnName"
|
||||
value={selectedComponent.columnName || ""}
|
||||
readOnly
|
||||
placeholder="데이터베이스 컬럼명"
|
||||
className="bg-muted/50 text-muted-foreground h-8"
|
||||
title="컬럼명은 변경할 수 없습니다"
|
||||
onChange={(e) => onUpdateProperty("columnName", e.target.value)}
|
||||
placeholder="formData에서 사용할 필드명"
|
||||
className="h-8"
|
||||
title="분할 패널에서 데이터를 전달받을 때 사용되는 필드명입니다"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
분할 패널에서 데이터를 전달받을 때 매핑되는 필드명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, DialogFooter, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -54,12 +54,12 @@ const DialogContent = React.forwardRef<
|
|||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left shrink-0", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,601 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 🆕 Context를 사용하여 open 상태 공유
|
||||
const ResizableDialogContext = React.createContext<{ open: boolean }>({ open: false });
|
||||
|
||||
// 🆕 ResizableDialog를 래핑하여 Context 제공
|
||||
const ResizableDialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
||||
children,
|
||||
open = false,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ResizableDialogContext.Provider value={{ open }}>
|
||||
<DialogPrimitive.Root open={open} {...props}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</ResizableDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ResizableDialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const ResizableDialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const ResizableDialogClose = DialogPrimitive.Close;
|
||||
|
||||
const ResizableDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ResizableDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface ResizableDialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
modalId?: string; // localStorage 저장용 고유 ID
|
||||
userId?: string; // 사용자별 저장용
|
||||
open?: boolean; // 🆕 모달 열림/닫힘 상태 (외부에서 전달)
|
||||
disableFlexLayout?: boolean; // 🆕 flex 레이아웃 비활성화 (absolute 레이아웃용)
|
||||
}
|
||||
|
||||
const ResizableDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
ResizableDialogContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
minWidth = 400,
|
||||
minHeight = 300,
|
||||
maxWidth = 1600,
|
||||
maxHeight = 1200,
|
||||
defaultWidth = 600,
|
||||
defaultHeight = 500,
|
||||
modalId,
|
||||
userId = "guest",
|
||||
open: externalOpen, // 🆕 외부에서 전달받은 open 상태
|
||||
disableFlexLayout = false, // 🆕 flex 레이아웃 비활성화
|
||||
style: userStyle,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 고정된 ID 생성 (한번 생성되면 컴포넌트 생명주기 동안 유지)
|
||||
const stableIdRef = React.useRef<string | null>(null);
|
||||
|
||||
if (!stableIdRef.current) {
|
||||
if (modalId) {
|
||||
stableIdRef.current = modalId;
|
||||
// // console.log("✅ ResizableDialog - 명시적 modalId 사용:", modalId);
|
||||
} else {
|
||||
// className 기반 ID 생성
|
||||
if (className) {
|
||||
const hash = className.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0);
|
||||
}, 0);
|
||||
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
||||
// console.log("🔄 ResizableDialog - className 기반 ID 생성:", { className, generatedId: stableIdRef.current });
|
||||
} else if (userStyle) {
|
||||
// userStyle 기반 ID 생성
|
||||
const styleStr = JSON.stringify(userStyle);
|
||||
const hash = styleStr.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0);
|
||||
}, 0);
|
||||
stableIdRef.current = `modal-${Math.abs(hash).toString(36)}`;
|
||||
// console.log("🔄 ResizableDialog - userStyle 기반 ID 생성:", { userStyle, generatedId: stableIdRef.current });
|
||||
} else {
|
||||
// 기본 ID
|
||||
stableIdRef.current = 'modal-default';
|
||||
// console.log("⚠️ ResizableDialog - 기본 ID 사용 (모든 모달이 같은 크기 공유)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveModalId = stableIdRef.current;
|
||||
|
||||
// 실제 렌더링된 크기를 감지하여 초기 크기로 사용
|
||||
const getInitialSize = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') return { width: defaultWidth, height: defaultHeight };
|
||||
|
||||
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
|
||||
if (userStyle) {
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
const styleHeight = typeof userStyle.height === 'string'
|
||||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const finalSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
return finalSize;
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
|
||||
// if (contentRef.current) {
|
||||
// const rect = contentRef.current.getBoundingClientRect();
|
||||
// if (rect.width > 0 && rect.height > 0) {
|
||||
// return {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
// 3순위: defaultWidth/defaultHeight 사용
|
||||
return { width: defaultWidth, height: defaultHeight };
|
||||
}, [defaultWidth, defaultHeight, minWidth, minHeight, maxWidth, maxHeight, userStyle]);
|
||||
|
||||
const [size, setSize] = React.useState(getInitialSize);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const [resizeDirection, setResizeDirection] = React.useState<string>("");
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
|
||||
// userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시)
|
||||
React.useEffect(() => {
|
||||
// 1. localStorage에서 사용자가 리사이징한 크기 확인
|
||||
let savedSize: { width: number; height: number; userResized: boolean } | null = null;
|
||||
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.userResized) {
|
||||
savedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
userResized: true,
|
||||
};
|
||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 모달 크기 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 우선순위: 사용자 리사이징 > userStyle > 기본값
|
||||
if (savedSize && savedSize.userResized) {
|
||||
// 사용자가 리사이징한 크기 우선
|
||||
setSize({ width: savedSize.width, height: savedSize.height });
|
||||
setUserResized(true);
|
||||
} else if (userStyle && userStyle.width && userStyle.height) {
|
||||
// 화면관리에서 설정한 크기
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
const styleHeight = typeof userStyle.height === 'string'
|
||||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const newSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
}, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]);
|
||||
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
|
||||
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
|
||||
|
||||
// 🆕 Context에서 open 상태 가져오기 (우선순위: externalOpen > context.open)
|
||||
const context = React.useContext(ResizableDialogContext);
|
||||
const actualOpen = externalOpen !== undefined ? externalOpen : context.open;
|
||||
|
||||
// 🆕 모달이 닫혔다가 다시 열릴 때 초기화 리셋
|
||||
const [wasOpen, setWasOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// console.log("🔍 모달 상태 변화 감지:", { actualOpen, wasOpen, externalOpen, contextOpen: context.open, effectiveModalId });
|
||||
|
||||
if (actualOpen && !wasOpen) {
|
||||
// 모달이 방금 열림
|
||||
// console.log("🔓 모달 열림 감지, 초기화 리셋:", { effectiveModalId });
|
||||
setIsInitialized(false);
|
||||
setWasOpen(true);
|
||||
} else if (!actualOpen && wasOpen) {
|
||||
// 모달이 방금 닫힘
|
||||
// console.log("🔒 모달 닫힘 감지:", { effectiveModalId });
|
||||
setWasOpen(false);
|
||||
}
|
||||
}, [actualOpen, wasOpen, effectiveModalId, externalOpen, context.open]);
|
||||
|
||||
// modalId가 변경되면 초기화 리셋 (다른 모달이 열린 경우)
|
||||
React.useEffect(() => {
|
||||
if (effectiveModalId !== lastModalId) {
|
||||
// console.log("🔄 모달 ID 변경 감지, 초기화 리셋:", { 이전: lastModalId, 현재: effectiveModalId, isInitialized });
|
||||
setIsInitialized(false);
|
||||
setUserResized(false); // 사용자 리사이징 플래그도 리셋
|
||||
setLastModalId(effectiveModalId);
|
||||
}
|
||||
}, [effectiveModalId, lastModalId, isInitialized]);
|
||||
|
||||
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
|
||||
// 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경
|
||||
// React.useEffect(() => {
|
||||
// // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
||||
//
|
||||
// if (!isInitialized) {
|
||||
// // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
||||
// // 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
||||
// let attempts = 0;
|
||||
// const maxAttempts = 10;
|
||||
//
|
||||
// const measureContent = () => {
|
||||
// attempts++;
|
||||
//
|
||||
// // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
||||
// let contentWidth = defaultWidth;
|
||||
// let contentHeight = defaultHeight;
|
||||
//
|
||||
// // if (contentRef.current) {
|
||||
// // // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
||||
// // contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
||||
// // contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
||||
// //
|
||||
// // // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
||||
// // } else {
|
||||
// // // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
||||
// //
|
||||
// // // contentRef가 아직 없으면 재시도
|
||||
// // if (attempts < maxAttempts) {
|
||||
// // setTimeout(measureContent, 100);
|
||||
// // return;
|
||||
// // }
|
||||
// // }
|
||||
//
|
||||
// // 패딩 추가 (p-6 * 2 = 48px)
|
||||
// const paddingAndMargin = 48;
|
||||
// const initialSize = getInitialSize();
|
||||
//
|
||||
// // 내용 크기 기반 최소 크기 계산
|
||||
// const contentBasedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
||||
// };
|
||||
//
|
||||
// // console.log("📐 내용 기반 크기:", contentBasedSize);
|
||||
//
|
||||
// // localStorage에서 저장된 크기 확인
|
||||
// let finalSize = contentBasedSize;
|
||||
//
|
||||
// if (effectiveModalId && typeof window !== 'undefined') {
|
||||
// try {
|
||||
// const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
// const saved = localStorage.getItem(storageKey);
|
||||
//
|
||||
// // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
||||
//
|
||||
// if (saved) {
|
||||
// const parsed = JSON.parse(saved);
|
||||
//
|
||||
// // userResized 플래그 확인
|
||||
// if (parsed.userResized) {
|
||||
// const savedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
// };
|
||||
//
|
||||
// // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
//
|
||||
// // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
||||
// // (사용자가 의도적으로 작게 만든 것을 존중)
|
||||
// finalSize = savedSize;
|
||||
// setUserResized(true);
|
||||
//
|
||||
// // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
||||
// } else {
|
||||
// // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
||||
// }
|
||||
// } else {
|
||||
// // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // console.error("❌ 모달 크기 복원 실패:", error);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// setSize(finalSize);
|
||||
// setIsInitialized(true);
|
||||
// };
|
||||
//
|
||||
// // 첫 시도는 300ms 후에 시작
|
||||
// setTimeout(measureContent, 300);
|
||||
// }
|
||||
// }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
||||
|
||||
const startResize = (direction: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startWidth = size.width;
|
||||
const startHeight = size.height;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
|
||||
if (direction.includes("e")) {
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + deltaX));
|
||||
}
|
||||
if (direction.includes("w")) {
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth - deltaX));
|
||||
}
|
||||
if (direction.includes("s")) {
|
||||
newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight + deltaY));
|
||||
}
|
||||
if (direction.includes("n")) {
|
||||
newHeight = Math.max(minHeight, Math.min(maxHeight, startHeight - deltaY));
|
||||
}
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
setResizeDirection("");
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
// 사용자가 리사이징했음을 표시
|
||||
setUserResized(true);
|
||||
|
||||
// ✅ 중요: 현재 실제 DOM 크기를 저장 (state가 아닌 실제 크기)
|
||||
if (effectiveModalId && typeof window !== 'undefined' && contentRef.current) {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
|
||||
// contentRef의 부모 요소(모달 컨테이너)의 실제 크기 사용
|
||||
const modalElement = contentRef.current.parentElement;
|
||||
const actualWidth = modalElement?.offsetWidth || size.width;
|
||||
const actualHeight = modalElement?.offsetHeight || size.height;
|
||||
|
||||
const currentSize = {
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
userResized: true, // 사용자가 직접 리사이징했음을 표시
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(currentSize));
|
||||
// console.log("💾 localStorage에 크기 저장 (사용자 리사이징):", { effectiveModalId, userId, storageKey, size: currentSize, stateSize: { width: size.width, height: size.height } });
|
||||
} catch (error) {
|
||||
// console.error("❌ 모달 크기 저장 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<ResizableDialogPortal>
|
||||
<ResizableDialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
isResizing && "select-none",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...userStyle,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
maxWidth: "95vw",
|
||||
maxHeight: "95vh",
|
||||
minWidth: `${minWidth}px`,
|
||||
minHeight: `${minHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full relative"
|
||||
style={{ display: 'block', overflow: 'auto', pointerEvents: 'auto', zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 리사이즈 핸들 */}
|
||||
{/* 오른쪽 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("e")}
|
||||
/>
|
||||
{/* 아래 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("s")}
|
||||
/>
|
||||
{/* 오른쪽 아래 */}
|
||||
<div
|
||||
className="absolute right-0 bottom-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("se")}
|
||||
/>
|
||||
{/* 왼쪽 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("w")}
|
||||
/>
|
||||
{/* 위 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("n")}
|
||||
/>
|
||||
{/* 왼쪽 아래 */}
|
||||
<div
|
||||
className="absolute left-0 bottom-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("sw")}
|
||||
/>
|
||||
{/* 오른쪽 위 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("ne")}
|
||||
/>
|
||||
{/* 왼쪽 위 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("nw")}
|
||||
/>
|
||||
|
||||
{/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */}
|
||||
{userResized && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// localStorage에서 저장된 크기 삭제
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
console.log("🗑️ 저장된 모달 크기 삭제:", storageKey);
|
||||
}
|
||||
|
||||
// 화면관리 설정 크기로 복원
|
||||
const initialSize = getInitialSize();
|
||||
setSize(initialSize);
|
||||
setUserResized(false);
|
||||
console.log("🔄 기본 크기로 리셋:", initialSize);
|
||||
}}
|
||||
className="absolute right-12 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
style={{ zIndex: 20 }}
|
||||
title="기본 크기로 리셋"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M3 21v-5h5"/>
|
||||
</svg>
|
||||
<span className="sr-only">기본 크기로 리셋</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
style={{ zIndex: 20 }}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</ResizableDialogPortal>
|
||||
);
|
||||
}
|
||||
);
|
||||
ResizableDialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const ResizableDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
ResizableDialogHeader.displayName = "ResizableDialogHeader";
|
||||
|
||||
const ResizableDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
ResizableDialogFooter.displayName = "ResizableDialogFooter";
|
||||
|
||||
const ResizableDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ResizableDialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const ResizableDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ResizableDialogDescription.displayName =
|
||||
DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
ResizableDialog,
|
||||
ResizableDialogPortal,
|
||||
ResizableDialogOverlay,
|
||||
ResizableDialogClose,
|
||||
ResizableDialogTrigger,
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogFooter,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
};
|
||||
|
||||
|
|
@ -91,6 +91,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
fields.forEach((field) => {
|
||||
item[field.name] = "";
|
||||
});
|
||||
// 🆕 새 항목임을 표시하는 플래그 추가 (백엔드에서 새 레코드로 처리)
|
||||
item._isNewItem = true;
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +115,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||
if (updatedItem.id) {
|
||||
updatedItem._existingRecord = true;
|
||||
}
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
|
|
@ -125,7 +132,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
: updatedValue;
|
||||
onChange?.(dataWithMeta);
|
||||
} else {
|
||||
setItems(value);
|
||||
// 🆕 기존 레코드 플래그 추가
|
||||
const valueWithFlag = value.map(item => ({
|
||||
...item,
|
||||
_existingRecord: !!item.id,
|
||||
}));
|
||||
setItems(valueWithFlag);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
|
@ -428,6 +440,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
return <span className="text-sm">{option?.label || value}</span>;
|
||||
}
|
||||
|
||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||
const mapping = categoryMappings[field.name];
|
||||
if (mapping && value) {
|
||||
const valueStr = String(value);
|
||||
const categoryData = mapping[valueStr];
|
||||
if (categoryData) {
|
||||
// 색상이 있으면 배지로 표시
|
||||
if (categoryData.color && categoryData.color !== "none" && categoryData.color !== "#64748b") {
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: categoryData.color,
|
||||
borderColor: categoryData.color,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{categoryData.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// 색상이 없으면 텍스트로 표시
|
||||
return <span className="text-sm text-foreground">{categoryData.label}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 텍스트
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
|
|
@ -556,44 +593,40 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드)
|
||||
// 카테고리 매핑 로드 (카테고리 필드 + readonly 필드에 대해 자동 로드)
|
||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
if (categoryFields.length === 0) return;
|
||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
// 1. 카테고리 타입 필드 매핑 로드
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name; // 실제 컬럼명
|
||||
const categoryCode = field.categoryCode || columnName;
|
||||
const columnName = field.name;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
// config에서 targetTable 가져오기, 없으면 스킵
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) {
|
||||
console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`);
|
||||
continue;
|
||||
}
|
||||
if (!tableName) continue;
|
||||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
// 테이블 리스트와 동일한 API 사용
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일)
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel || key,
|
||||
color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일)
|
||||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -608,6 +641,50 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||
// material, division 등 조인된 테이블의 카테고리 필드
|
||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
||||
|
||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||
// item_info 테이블에서 카테고리 매핑 로드
|
||||
const joinedTableName = 'item_info';
|
||||
|
||||
for (const field of fieldsToLoadFromJoinedTable) {
|
||||
const columnName = field.name;
|
||||
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel || key,
|
||||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
// 카테고리가 없는 필드는 무시
|
||||
console.log(`ℹ️ [RepeaterInput] 조인 테이블 카테고리 없음 (${columnName})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryMappings();
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ interface SplitPanelContextValue {
|
|||
|
||||
// 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용)
|
||||
getLinkedFilterValues: () => Record<string, any>;
|
||||
|
||||
// 🆕 자동 데이터 전달 비활성화 여부 (버튼 클릭 시에만 데이터 전달)
|
||||
disableAutoDataTransfer: boolean;
|
||||
}
|
||||
|
||||
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||
|
|
@ -98,6 +101,7 @@ interface SplitPanelProviderProps {
|
|||
rightScreenId: number | null;
|
||||
parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정
|
||||
linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정
|
||||
disableAutoDataTransfer?: boolean; // 🆕 자동 데이터 전달 비활성화 (버튼 클릭 시에만 전달)
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +114,7 @@ export function SplitPanelProvider({
|
|||
rightScreenId,
|
||||
parentDataMapping = [],
|
||||
linkedFilters = [],
|
||||
disableAutoDataTransfer = false,
|
||||
children,
|
||||
}: SplitPanelProviderProps) {
|
||||
// 좌측/우측 화면의 데이터 수신자 맵
|
||||
|
|
@ -372,6 +377,8 @@ export function SplitPanelProvider({
|
|||
// 🆕 연결 필터 관련
|
||||
linkedFilters,
|
||||
getLinkedFilterValues,
|
||||
// 🆕 자동 데이터 전달 비활성화 여부
|
||||
disableAutoDataTransfer,
|
||||
}), [
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
|
|
@ -391,6 +398,7 @@ export function SplitPanelProvider({
|
|||
getMappedParentData,
|
||||
linkedFilters,
|
||||
getLinkedFilterValues,
|
||||
disableAutoDataTransfer,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -58,6 +58,18 @@ const TokenManager = {
|
|||
return null;
|
||||
},
|
||||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("authToken", token);
|
||||
}
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
}
|
||||
},
|
||||
|
||||
isTokenExpired: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
|
|
@ -66,8 +78,147 @@ const TokenManager = {
|
|||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰이 곧 만료되는지 확인 (30분 이내)
|
||||
isTokenExpiringSoon: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
const thirtyMinutes = 30 * 60 * 1000; // 30분
|
||||
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 토큰 만료까지 남은 시간 (밀리초)
|
||||
getTimeUntilExpiry: (token: string): number => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 - Date.now();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 토큰 갱신 중복 방지 플래그
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
// 토큰 갱신 함수
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
// 이미 갱신 중이면 기존 Promise 반환
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 토큰 갱신 성공");
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[TokenManager] 토큰 갱신 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
// 자동 토큰 갱신 타이머
|
||||
let tokenRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// 자동 토큰 갱신 시작
|
||||
const startAutoRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (tokenRefreshTimer) {
|
||||
clearInterval(tokenRefreshTimer);
|
||||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
tokenRefreshTimer = setInterval(async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
}, 10 * 60 * 1000); // 10분
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 활동 감지 및 토큰 갱신
|
||||
const setupActivityBasedRefresh = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let lastActivity = Date.now();
|
||||
const activityThreshold = 5 * 60 * 1000; // 5분
|
||||
|
||||
const handleActivity = (): void => {
|
||||
const now = Date.now();
|
||||
// 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인
|
||||
if (now - lastActivity > activityThreshold) {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
}
|
||||
lastActivity = now;
|
||||
};
|
||||
|
||||
// 사용자 활동 이벤트 감지
|
||||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||
let throttleTimer: NodeJS.Timeout | null = null;
|
||||
window.addEventListener(event, () => {
|
||||
if (!throttleTimer) {
|
||||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
};
|
||||
|
||||
// 클라이언트 사이드에서 자동 갱신 시작
|
||||
if (typeof window !== "undefined") {
|
||||
startAutoRefresh();
|
||||
setupActivityBasedRefresh();
|
||||
}
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
|
|
@ -138,9 +289,15 @@ apiClient.interceptors.request.use(
|
|||
// 응답 인터셉터
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 백엔드에서 보내주는 새로운 토큰 처리
|
||||
const newToken = response.headers["x-new-token"];
|
||||
if (newToken) {
|
||||
TokenManager.setToken(newToken);
|
||||
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
|
||||
|
|
@ -153,7 +310,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
// 일반 409 에러는 간단한 로그만 출력
|
||||
console.warn("⚠️ 데이터 중복:", {
|
||||
console.warn("데이터 중복:", {
|
||||
url: url,
|
||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||
});
|
||||
|
|
@ -161,7 +318,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||
console.error("❌ API 응답 오류:", {
|
||||
console.error("API 응답 오류:", {
|
||||
status: status,
|
||||
statusText: error.response?.statusText,
|
||||
url: url,
|
||||
|
|
@ -170,24 +327,40 @@ apiClient.interceptors.response.use(
|
|||
headers: error.config?.headers,
|
||||
});
|
||||
|
||||
// 401 에러 시 상세 정보 출력
|
||||
if (status === 401) {
|
||||
console.error("🚨 401 Unauthorized 오류 상세 정보:", {
|
||||
// 401 에러 처리
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const errorData = error.response?.data as { error?: { code?: string } };
|
||||
const errorCode = errorData?.error?.code;
|
||||
|
||||
console.warn("[Auth] 401 오류 발생:", {
|
||||
url: url,
|
||||
method: error.config?.method,
|
||||
headers: error.config?.headers,
|
||||
requestData: error.config?.data,
|
||||
responseData: error.response?.data,
|
||||
errorCode: errorCode,
|
||||
token: TokenManager.getToken() ? "존재" : "없음",
|
||||
});
|
||||
}
|
||||
|
||||
// 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
// 토큰 만료 에러인 경우 갱신 시도
|
||||
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
||||
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) {
|
||||
console.log("[Auth] 토큰 만료, 갱신 시도...");
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken && originalRequest) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[Auth] 토큰 갱신 실패:", refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
if (window.location.pathname !== "/login") {
|
||||
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,29 @@ export async function reorderCategoryValues(orderedValueIds: number[]) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* @param valueCodes - 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||
* @returns { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
export async function getCategoryLabelsByCodes(valueCodes: string[]) {
|
||||
try {
|
||||
if (!valueCodes || valueCodes.length === 0) {
|
||||
return { success: true, data: {} };
|
||||
}
|
||||
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: Record<string, string>;
|
||||
}>("/table-categories/labels-by-codes", { valueCodes });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
return { success: false, error: error.message, data: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* 전국 시/도별 좌표 범위 (경계 좌표)
|
||||
* 차량 위치 필터링에 사용
|
||||
*/
|
||||
|
||||
export interface RegionBounds {
|
||||
south: number; // 최남단 위도
|
||||
north: number; // 최북단 위도
|
||||
west: number; // 최서단 경도
|
||||
east: number; // 최동단 경도
|
||||
}
|
||||
|
||||
export interface RegionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
bounds?: RegionBounds;
|
||||
}
|
||||
|
||||
// 전국 시/도별 좌표 범위
|
||||
export const regionBounds: Record<string, RegionBounds> = {
|
||||
// 서울특별시
|
||||
seoul: {
|
||||
south: 37.413,
|
||||
north: 37.715,
|
||||
west: 126.734,
|
||||
east: 127.183,
|
||||
},
|
||||
// 부산광역시
|
||||
busan: {
|
||||
south: 34.879,
|
||||
north: 35.389,
|
||||
west: 128.758,
|
||||
east: 129.314,
|
||||
},
|
||||
// 대구광역시
|
||||
daegu: {
|
||||
south: 35.601,
|
||||
north: 36.059,
|
||||
west: 128.349,
|
||||
east: 128.761,
|
||||
},
|
||||
// 인천광역시
|
||||
incheon: {
|
||||
south: 37.166,
|
||||
north: 37.592,
|
||||
west: 126.349,
|
||||
east: 126.775,
|
||||
},
|
||||
// 광주광역시
|
||||
gwangju: {
|
||||
south: 35.053,
|
||||
north: 35.267,
|
||||
west: 126.652,
|
||||
east: 127.013,
|
||||
},
|
||||
// 대전광역시
|
||||
daejeon: {
|
||||
south: 36.197,
|
||||
north: 36.488,
|
||||
west: 127.246,
|
||||
east: 127.538,
|
||||
},
|
||||
// 울산광역시
|
||||
ulsan: {
|
||||
south: 35.360,
|
||||
north: 35.710,
|
||||
west: 128.958,
|
||||
east: 129.464,
|
||||
},
|
||||
// 세종특별자치시
|
||||
sejong: {
|
||||
south: 36.432,
|
||||
north: 36.687,
|
||||
west: 127.044,
|
||||
east: 127.364,
|
||||
},
|
||||
// 경기도
|
||||
gyeonggi: {
|
||||
south: 36.893,
|
||||
north: 38.284,
|
||||
west: 126.387,
|
||||
east: 127.839,
|
||||
},
|
||||
// 강원도 (강원특별자치도)
|
||||
gangwon: {
|
||||
south: 37.017,
|
||||
north: 38.613,
|
||||
west: 127.085,
|
||||
east: 129.359,
|
||||
},
|
||||
// 충청북도
|
||||
chungbuk: {
|
||||
south: 36.012,
|
||||
north: 37.261,
|
||||
west: 127.282,
|
||||
east: 128.657,
|
||||
},
|
||||
// 충청남도
|
||||
chungnam: {
|
||||
south: 35.972,
|
||||
north: 37.029,
|
||||
west: 125.927,
|
||||
east: 127.380,
|
||||
},
|
||||
// 전라북도 (전북특별자치도)
|
||||
jeonbuk: {
|
||||
south: 35.287,
|
||||
north: 36.133,
|
||||
west: 126.392,
|
||||
east: 127.923,
|
||||
},
|
||||
// 전라남도
|
||||
jeonnam: {
|
||||
south: 33.959,
|
||||
north: 35.507,
|
||||
west: 125.979,
|
||||
east: 127.921,
|
||||
},
|
||||
// 경상북도
|
||||
gyeongbuk: {
|
||||
south: 35.571,
|
||||
north: 37.144,
|
||||
west: 128.113,
|
||||
east: 130.922,
|
||||
},
|
||||
// 경상남도
|
||||
gyeongnam: {
|
||||
south: 34.599,
|
||||
north: 35.906,
|
||||
west: 127.555,
|
||||
east: 129.224,
|
||||
},
|
||||
// 제주특별자치도
|
||||
jeju: {
|
||||
south: 33.106,
|
||||
north: 33.959,
|
||||
west: 126.117,
|
||||
east: 126.978,
|
||||
},
|
||||
};
|
||||
|
||||
// 지역 선택 옵션 (드롭다운용)
|
||||
export const regionOptions: RegionOption[] = [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "seoul", label: "서울특별시", bounds: regionBounds.seoul },
|
||||
{ value: "busan", label: "부산광역시", bounds: regionBounds.busan },
|
||||
{ value: "daegu", label: "대구광역시", bounds: regionBounds.daegu },
|
||||
{ value: "incheon", label: "인천광역시", bounds: regionBounds.incheon },
|
||||
{ value: "gwangju", label: "광주광역시", bounds: regionBounds.gwangju },
|
||||
{ value: "daejeon", label: "대전광역시", bounds: regionBounds.daejeon },
|
||||
{ value: "ulsan", label: "울산광역시", bounds: regionBounds.ulsan },
|
||||
{ value: "sejong", label: "세종특별자치시", bounds: regionBounds.sejong },
|
||||
{ value: "gyeonggi", label: "경기도", bounds: regionBounds.gyeonggi },
|
||||
{ value: "gangwon", label: "강원특별자치도", bounds: regionBounds.gangwon },
|
||||
{ value: "chungbuk", label: "충청북도", bounds: regionBounds.chungbuk },
|
||||
{ value: "chungnam", label: "충청남도", bounds: regionBounds.chungnam },
|
||||
{ value: "jeonbuk", label: "전북특별자치도", bounds: regionBounds.jeonbuk },
|
||||
{ value: "jeonnam", label: "전라남도", bounds: regionBounds.jeonnam },
|
||||
{ value: "gyeongbuk", label: "경상북도", bounds: regionBounds.gyeongbuk },
|
||||
{ value: "gyeongnam", label: "경상남도", bounds: regionBounds.gyeongnam },
|
||||
{ value: "jeju", label: "제주특별자치도", bounds: regionBounds.jeju },
|
||||
];
|
||||
|
||||
/**
|
||||
* 좌표가 특정 지역 범위 내에 있는지 확인
|
||||
*/
|
||||
export function isInRegion(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
region: string
|
||||
): boolean {
|
||||
if (region === "all") return true;
|
||||
|
||||
const bounds = regionBounds[region];
|
||||
if (!bounds) return false;
|
||||
|
||||
return (
|
||||
latitude >= bounds.south &&
|
||||
latitude <= bounds.north &&
|
||||
longitude >= bounds.west &&
|
||||
longitude <= bounds.east
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표로 지역 찾기 (해당하는 첫 번째 지역 반환)
|
||||
*/
|
||||
export function findRegionByCoords(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): string | null {
|
||||
for (const [region, bounds] of Object.entries(regionBounds)) {
|
||||
if (
|
||||
latitude >= bounds.south &&
|
||||
latitude <= bounds.north &&
|
||||
longitude >= bounds.west &&
|
||||
longitude <= bounds.east
|
||||
) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차량 목록을 지역별로 필터링
|
||||
*/
|
||||
export function filterVehiclesByRegion<
|
||||
T extends { latitude?: number; longitude?: number; lat?: number; lng?: number }
|
||||
>(vehicles: T[], region: string): T[] {
|
||||
if (region === "all") return vehicles;
|
||||
|
||||
const bounds = regionBounds[region];
|
||||
if (!bounds) return vehicles;
|
||||
|
||||
return vehicles.filter((v) => {
|
||||
const lat = v.latitude ?? v.lat;
|
||||
const lng = v.longitude ?? v.lng;
|
||||
|
||||
if (lat === undefined || lng === undefined) return false;
|
||||
|
||||
return (
|
||||
lat >= bounds.south &&
|
||||
lat <= bounds.north &&
|
||||
lng >= bounds.west &&
|
||||
lng <= bounds.east
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 지역명(한글) 가져오기
|
||||
*/
|
||||
export function getRegionLabel(regionValue: string): string {
|
||||
const option = regionOptions.find((opt) => opt.value === regionValue);
|
||||
return option?.label ?? regionValue;
|
||||
}
|
||||
|
||||
|
|
@ -448,6 +448,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
|
||||
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
|
||||
groupedData: props.groupedData,
|
||||
// 🆕 UniversalFormModal용 initialData 전달
|
||||
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
||||
initialData: originalData || formData,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
|
|
|
|||
|
|
@ -345,7 +345,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
}
|
||||
|
||||
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||
if (splitPanelContext && splitPanelPosition === "left") {
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (checked) {
|
||||
splitPanelContext.setSelectedLeftData(data);
|
||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
||||
|
|
@ -968,7 +969,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
{/* 상세보기 모달 */}
|
||||
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="text-lg">📋</span>
|
||||
|
|
@ -1041,7 +1042,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
{/* 편집 모달 */}
|
||||
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="text-lg">✏️</span>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function EntitySearchModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen,
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto [&>button]:hidden">
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
|
|
@ -506,7 +506,7 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen,
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">{renderPreview()}</div>
|
||||
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
|
||||
|
||||
{/* 파일 정보 및 액션 버튼 */}
|
||||
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
|
|||
|
||||
// 🆕 섹션 그룹화 레이아웃 컴포넌트
|
||||
import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식
|
||||
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
|
||||
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
|
||||
|
||||
// 🆕 탭 컴포넌트
|
||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
|
@ -77,6 +77,9 @@ import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널
|
|||
// 🆕 범용 폼 모달 컴포넌트
|
||||
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
|
||||
|
||||
// 🆕 렉 구조 설정 컴포넌트
|
||||
import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생성
|
||||
|
||||
// 🆕 세금계산서 관리 컴포넌트
|
||||
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export function ItemSelectionModal({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
|
|
@ -222,8 +222,8 @@ export function ItemSelectionModal({
|
|||
)}
|
||||
|
||||
{/* 검색 결과 테이블 */}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="border rounded-md overflow-hidden flex-1 min-h-0">
|
||||
<div className="overflow-auto max-h-[50vh]">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
# 렉 구조 설정 컴포넌트 (Rack Structure Config)
|
||||
|
||||
창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트입니다.
|
||||
|
||||
## 핵심 개념
|
||||
|
||||
이 컴포넌트는 **상위 폼의 필드 값을 읽어서** 위치 코드를 생성합니다.
|
||||
|
||||
### 작동 방식
|
||||
|
||||
1. 사용자가 화면관리에서 테이블 컬럼(창고코드, 층, 구역 등)을 드래그하여 폼에 배치
|
||||
2. 렉 구조 컴포넌트 설정에서 **필드 매핑** 설정 (어떤 폼 필드가 창고/층/구역인지)
|
||||
3. 런타임에 사용자가 폼 필드에 값을 입력하면, 렉 구조 컴포넌트가 해당 값을 읽어서 사용
|
||||
|
||||
## 기능
|
||||
|
||||
### 1. 렉 라인 구조 설정
|
||||
|
||||
- 조건 추가/삭제
|
||||
- 각 조건: 열 범위(시작~종료) + 단 수
|
||||
- 자동 위치 수 계산 (예: 1열~3열 x 3단 = 9개)
|
||||
- 템플릿 저장/불러오기
|
||||
|
||||
### 2. 등록 미리보기
|
||||
|
||||
- 통계 카드 (총 위치, 열 수, 최대 단)
|
||||
- 미리보기 생성 버튼
|
||||
- 생성될 위치 목록 테이블
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1. 화면관리에서 배치
|
||||
|
||||
1. 상위에 테이블 컬럼들을 배치 (창고코드, 층, 구역, 위치유형, 사용여부)
|
||||
2. 컴포넌트 팔레트에서 "렉 구조 설정" 선택
|
||||
3. 캔버스에 드래그하여 배치
|
||||
|
||||
### 2. 필드 매핑 설정
|
||||
|
||||
설정 패널에서 상위 폼의 어떤 필드를 사용할지 매핑합니다:
|
||||
|
||||
| 매핑 항목 | 설명 |
|
||||
| -------------- | ------------------------------------- |
|
||||
| 창고 코드 필드 | 위치 코드 생성에 사용할 창고 코드 |
|
||||
| 층 필드 | 위치 코드 생성에 사용할 층 |
|
||||
| 구역 필드 | 위치 코드 생성에 사용할 구역 |
|
||||
| 위치 유형 필드 | 미리보기 테이블에 표시할 위치 유형 |
|
||||
| 사용 여부 필드 | 미리보기 테이블에 표시할 사용 여부 |
|
||||
|
||||
### 예시
|
||||
|
||||
상위 폼에 다음 필드가 배치되어 있다면:
|
||||
- `창고코드(조인)` → 필드명: `warehouse_code`
|
||||
- `층` → 필드명: `floor`
|
||||
- `구역` → 필드명: `zone`
|
||||
|
||||
설정 패널에서:
|
||||
- 창고 코드 필드: `warehouse_code` 선택
|
||||
- 층 필드: `floor` 선택
|
||||
- 구역 필드: `zone` 선택
|
||||
|
||||
## 위치 코드 생성 규칙
|
||||
|
||||
기본 패턴: `{창고코드}-{층}{구역}-{열:2자리}-{단}`
|
||||
|
||||
예시 (창고: WH001, 층: 1, 구역: A):
|
||||
|
||||
- WH001-1A-01-1 (01열, 1단)
|
||||
- WH001-1A-01-2 (01열, 2단)
|
||||
- WH001-1A-02-1 (02열, 1단)
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
| -------------- | ------- | ------ | ---------------- |
|
||||
| maxConditions | number | 10 | 최대 조건 수 |
|
||||
| maxRows | number | 99 | 최대 열 수 |
|
||||
| maxLevels | number | 20 | 최대 단 수 |
|
||||
| showTemplates | boolean | true | 템플릿 기능 표시 |
|
||||
| showPreview | boolean | true | 미리보기 표시 |
|
||||
| showStatistics | boolean | true | 통계 카드 표시 |
|
||||
| readonly | boolean | false | 읽기 전용 |
|
||||
|
||||
## 출력 데이터
|
||||
|
||||
`onChange` 콜백으로 생성된 위치 데이터 배열을 반환합니다:
|
||||
|
||||
```typescript
|
||||
interface GeneratedLocation {
|
||||
rowNum: number; // 열 번호
|
||||
levelNum: number; // 단 번호
|
||||
locationCode: string; // 위치 코드
|
||||
locationName: string; // 위치명
|
||||
locationType?: string; // 위치 유형
|
||||
status?: string; // 사용 여부
|
||||
warehouseCode?: string; // 창고 코드 (매핑된 값)
|
||||
floor?: string; // 층 (매핑된 값)
|
||||
zone?: string; // 구역 (매핑된 값)
|
||||
}
|
||||
```
|
||||
|
||||
## 연동 테이블
|
||||
|
||||
`warehouse_location` 테이블과 연동됩니다:
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
| ------------- | --------- |
|
||||
| warehouse_id | 창고 ID |
|
||||
| floor | 층 |
|
||||
| zone | 구역 |
|
||||
| row_num | 열 번호 |
|
||||
| level_num | 단 번호 |
|
||||
| location_code | 위치 코드 |
|
||||
| location_name | 위치명 |
|
||||
| location_type | 위치 유형 |
|
||||
| status | 사용 여부 |
|
||||
|
||||
## 예시 시나리오
|
||||
|
||||
### 시나리오: A구역에 1~3열은 3단, 4~6열은 5단 렉 생성
|
||||
|
||||
1. **상위 폼에서 기본 정보 입력**
|
||||
- 창고: 제1창고 (WH001) - 드래그해서 배치한 필드
|
||||
- 층: 1 - 드래그해서 배치한 필드
|
||||
- 구역: A - 드래그해서 배치한 필드
|
||||
- 위치 유형: 선반 - 드래그해서 배치한 필드
|
||||
- 사용 여부: 사용 - 드래그해서 배치한 필드
|
||||
|
||||
2. **렉 구조 컴포넌트에서 조건 추가**
|
||||
- 조건 1: 1~3열, 3단 → 9개
|
||||
- 조건 2: 4~6열, 5단 → 15개
|
||||
|
||||
3. **미리보기 생성**
|
||||
- 총 위치: 24개
|
||||
- 열 수: 6개
|
||||
- 최대 단: 5단
|
||||
|
||||
4. **저장**
|
||||
- 24개의 위치 데이터가 warehouse_location 테이블에 저장됨
|
||||
|
||||
## 필수 필드 검증
|
||||
|
||||
미리보기 생성 시 다음 필드가 입력되어 있어야 합니다:
|
||||
- 창고 코드
|
||||
- 층
|
||||
- 구역
|
||||
|
||||
필드가 비어있으면 경고 메시지가 표시됩니다.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue