Merge pull request 'feature/screen-management' (#254) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/254
This commit is contained in:
kjs 2025-12-08 17:59:31 +09:00
commit 33e7767f75
105 changed files with 5642 additions and 1707 deletions

View File

@ -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({

View File

@ -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
*

View File

@ -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 || "토큰 검증에 실패했습니다.",
},
});
}

View File

@ -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);
// ================================================
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
// ================================================

View File

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

View File

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

View File

@ -528,6 +528,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,11 +833,18 @@ 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 +1365,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("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
const value =
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
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}
`;
console.log("📝 실행할 SQL (일괄 처리):", sql);
console.log("📊 바인딩 값:", values);
const result = await txClient.query(sql, values);
updatedCount = result.rowCount || 0;
const result = await txClient.query(sql, values);
const rowCount = result.rowCount || 0;
updatedCount += rowCount;
console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}`);
}
logger.info(
`✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}`
`✅ UPDATE 완료 (내부 DB, 그룹별 처리): ${targetTable}, 총 ${updatedCount}`
);
// 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음)
@ -3197,4 +3214,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;
}
}

View File

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

View File

@ -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`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 액션",

View File

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

View File

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

View File

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

View File

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

View File

@ -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", // 보라색
},
// ========================================================================
// 액션

View File

@ -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">

View File

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

View File

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

View File

@ -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">

View File

@ -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">

View File

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

View File

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

View File

@ -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">
{/* 좌측 패널 */}

View File

@ -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" />

View File

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

View File

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

View File

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

View File

@ -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" />

View File

@ -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] || "";

View File

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

View File

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

View File

@ -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">

View File

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

View File

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

View File

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

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (

View File

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

View File

@ -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 (논리명 ↔ 물리명)
// ================================================

View File

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

View File

@ -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">

View File

@ -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">

View File

@ -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"; // 세금계산서 목록, 작성, 발행, 취소

View File

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

View File

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

View File

@ -0,0 +1,287 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
onChange: (config: RackStructureComponentConfig) => void;
// 화면관리에서 전달하는 테이블 컬럼 정보
tables?: Array<{
tableName: string;
tableLabel?: string;
columns: Array<{
columnName: string;
columnLabel?: string;
dataType?: string;
}>;
}>;
}
export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> = ({
config,
onChange,
tables = [],
}) => {
// 사용 가능한 컬럼 목록 추출
const [availableColumns, setAvailableColumns] = useState<
Array<{ value: string; label: string }>
>([]);
useEffect(() => {
// 모든 테이블의 컬럼을 플랫하게 추출
const columns: Array<{ value: string; label: string }> = [];
tables.forEach((table) => {
table.columns.forEach((col) => {
columns.push({
value: col.columnName,
label: col.columnLabel || col.columnName,
});
});
});
setAvailableColumns(columns);
}, [tables]);
const handleChange = (key: keyof RackStructureComponentConfig, value: any) => {
onChange({ ...config, [key]: value });
};
const handleFieldMappingChange = (field: keyof FieldMapping, value: string) => {
const currentMapping = config.fieldMapping || {};
onChange({
...config,
fieldMapping: {
...currentMapping,
[field]: value === "__none__" ? undefined : value,
},
});
};
const fieldMapping = config.fieldMapping || {};
return (
<div className="space-y-4">
{/* 필드 매핑 섹션 */}
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700"> </div>
<p className="text-xs text-gray-500">
</p>
{/* 창고 코드 필드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={fieldMapping.warehouseCodeField || "__none__"}
onValueChange={(v) => handleFieldMappingChange("warehouseCodeField", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 창고명 필드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={fieldMapping.warehouseNameField || "__none__"}
onValueChange={(v) => handleFieldMappingChange("warehouseNameField", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 층 필드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={fieldMapping.floorField || "__none__"}
onValueChange={(v) => handleFieldMappingChange("floorField", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 구역 필드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={fieldMapping.zoneField || "__none__"}
onValueChange={(v) => handleFieldMappingChange("zoneField", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 위치 유형 필드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={fieldMapping.locationTypeField || "__none__"}
onValueChange={(v) => handleFieldMappingChange("locationTypeField", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 사용 여부 필드 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={fieldMapping.statusField || "__none__"}
onValueChange={(v) => handleFieldMappingChange("statusField", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col.value} value={col.value}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700"> </div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={20}
value={config.maxConditions || 10}
onChange={(e) => handleChange("maxConditions", parseInt(e.target.value) || 10)}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={999}
value={config.maxRows || 99}
onChange={(e) => handleChange("maxRows", parseInt(e.target.value) || 99)}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={99}
value={config.maxLevels || 20}
onChange={(e) => handleChange("maxLevels", parseInt(e.target.value) || 20)}
className="h-8"
/>
</div>
</div>
{/* UI 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700">UI </div>
<div className="flex items-center justify-between">
<Label className="text-xs">릿 </Label>
<Switch
checked={config.showTemplates ?? true}
onCheckedChange={(checked) => handleChange("showTemplates", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showPreview ?? true}
onCheckedChange={(checked) => handleChange("showPreview", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showStatistics ?? true}
onCheckedChange={(checked) => handleChange("showStatistics", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.readonly ?? false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,60 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { RackStructureDefinition } from "./index";
import { RackStructureComponent } from "./RackStructureComponent";
import { GeneratedLocation } from "./types";
/**
*
*
*/
export class RackStructureRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = RackStructureDefinition;
render(): React.ReactElement {
const { formData, isPreview, config, tableName, onFormDataChange } = this.props as Record<string, unknown>;
return (
<RackStructureComponent
config={(config as object) || {}}
formData={formData as Record<string, unknown>}
tableName={tableName as string}
onChange={(locations) =>
this.handleLocationsChange(
locations,
onFormDataChange as ((fieldName: string, value: unknown) => void) | undefined,
)
}
isPreview={isPreview as boolean}
/>
);
}
/**
*
* formData에 _rackStructureLocations
*/
protected handleLocationsChange = (
locations: GeneratedLocation[],
onFormDataChange?: (fieldName: string, value: unknown) => void,
) => {
// 생성된 위치 데이터를 컴포넌트에 저장
this.updateComponent({ generatedLocations: locations });
// formData에도 저장하여 저장 액션에서 감지할 수 있도록 함
if (onFormDataChange) {
console.log("📦 [RackStructure] 미리보기 데이터를 formData에 저장:", locations.length, "개");
onFormDataChange("_rackStructureLocations", locations);
}
};
}
// 자동 등록 실행
RackStructureRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
RackStructureRenderer.enableHotReload();
}

View File

@ -0,0 +1,27 @@
/**
*
*/
import { RackStructureComponentConfig } from "./types";
export const defaultConfig: RackStructureComponentConfig = {
// 기본 제한
maxConditions: 10,
maxRows: 99,
maxLevels: 20,
// 기본 코드 패턴
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
namePattern: "{zone}구역-{row:02d}열-{level}단",
// UI 설정
showTemplates: true,
showPreview: true,
showStatistics: true,
readonly: false,
// 초기 조건 없음
initialConditions: [],
};

View File

@ -0,0 +1,74 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RackStructureWrapper } from "./RackStructureComponent";
import { RackStructureConfigPanel } from "./RackStructureConfigPanel";
import { defaultConfig } from "./config";
/**
*
*
*/
export const RackStructureDefinition = createComponentDefinition({
id: "rack-structure",
name: "렉 구조 설정",
nameEng: "Rack Structure Config",
description: "창고 렉 위치를 열 범위와 단 수로 일괄 생성하는 컴포넌트",
category: ComponentCategory.INPUT,
webType: "component",
component: RackStructureWrapper,
defaultConfig: defaultConfig,
defaultSize: {
width: 1200,
height: 800,
gridColumnSpan: "12",
},
configPanel: RackStructureConfigPanel,
icon: "LayoutGrid",
tags: ["창고", "렉", "위치", "구조", "일괄생성", "WMS"],
version: "1.0.0",
author: "개발팀",
documentation: `
.
##
-
- /
-
- 릿 /
##
1. , ,
2.
3.
4.
5.
##
formData에서 :
- warehouse_id / warehouseId: 창고 ID
- warehouse_code / warehouseCode: 창고
- floor:
- zone: 구역
- location_type / locationType: 위치
- status: 사용
`,
});
// 타입 내보내기
export type {
RackStructureComponentConfig,
RackStructureContext,
RackLineCondition,
RackStructureTemplate,
GeneratedLocation,
} from "./types";
// 컴포넌트 내보내기
export { RackStructureComponent, RackStructureWrapper } from "./RackStructureComponent";
export { RackStructureRenderer } from "./RackStructureRenderer";
export { RackStructureConfigPanel } from "./RackStructureConfigPanel";

View File

@ -0,0 +1,92 @@
/**
*
*/
// 렉 라인 조건 (열 범위 + 단 수)
export interface RackLineCondition {
id: string;
startRow: number; // 시작 열
endRow: number; // 종료 열
levels: number; // 단 수
}
// 렉 구조 템플릿
export interface RackStructureTemplate {
id: string;
name: string;
conditions: RackLineCondition[];
createdAt?: string;
}
// 생성될 위치 데이터 (테이블 컬럼명과 동일하게 매핑)
export interface GeneratedLocation {
row_num: string; // 열 번호 (varchar)
level_num: string; // 단 번호 (varchar)
location_code: string; // 위치 코드 (예: WH001-1A-01-1)
location_name: string; // 위치명 (예: A구역-01열-1단)
location_type?: string; // 위치 유형
status?: string; // 사용 여부
// 추가 필드 (상위 폼에서 매핑된 값)
warehouse_id?: string; // 창고 ID/코드
warehouse_name?: string; // 창고명
floor?: string; // 층
zone?: string; // 구역
}
// 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지)
export interface FieldMapping {
warehouseCodeField?: string; // 창고 코드로 사용할 폼 필드명
warehouseNameField?: string; // 창고명으로 사용할 폼 필드명
floorField?: string; // 층으로 사용할 폼 필드명
zoneField?: string; // 구역으로 사용할 폼 필드명
locationTypeField?: string; // 위치 유형으로 사용할 폼 필드명
statusField?: string; // 사용 여부로 사용할 폼 필드명
}
// 컴포넌트 설정
export interface RackStructureComponentConfig {
// 기본 설정
maxConditions?: number; // 최대 조건 수 (기본: 10)
maxRows?: number; // 최대 열 수 (기본: 99)
maxLevels?: number; // 최대 단 수 (기본: 20)
// 필드 매핑 (상위 폼의 필드와 연결)
fieldMapping?: FieldMapping;
// 위치 코드 생성 규칙
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
// UI 설정
showTemplates?: boolean; // 템플릿 기능 표시
showPreview?: boolean; // 미리보기 표시
showStatistics?: boolean; // 통계 카드 표시
readonly?: boolean; // 읽기 전용
// 초기값
initialConditions?: RackLineCondition[];
}
// 상위 폼에서 전달받는 컨텍스트 데이터
export interface RackStructureContext {
warehouseId?: string; // 창고 ID
warehouseCode?: string; // 창고 코드 (예: WH001)
warehouseName?: string; // 창고명 (예: 제1창고)
floor?: string; // 층 (예: 1)
zone?: string; // 구역 (예: A)
locationType?: string; // 위치 유형 (예: 선반)
status?: string; // 사용 여부 (예: 사용)
}
// 컴포넌트 Props
export interface RackStructureComponentProps {
config: RackStructureComponentConfig;
context?: RackStructureContext;
formData?: Record<string, any>; // 상위 폼 데이터 (필드 매핑에 사용)
onChange?: (locations: GeneratedLocation[]) => void;
onConditionsChange?: (conditions: RackLineCondition[]) => void;
isPreview?: boolean;
tableName?: string;
}

View File

@ -135,6 +135,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
...item,
_targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
}));
onChange(dataWithMeta);
}
@ -228,17 +229,23 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지
const definedFields = configRef.current.fields || [];
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
// 시스템 필드 및 필수 필드 추가
const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']);
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
Object.keys(item).forEach(key => {
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
if (key === 'id') {
return; // id 필드 제외
}
// 정의된 필드이거나 시스템 필드인 경우만 포함
if (definedFieldNames.has(key) || systemFields.has(key)) {
filteredItem[key] = item[key];
}
});
// 🆕 새 항목임을 표시하는 플래그 추가
filteredItem._isNewItem = true;
return filteredItem;
});
@ -259,16 +266,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
newItems = filteredData;
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외
const existingIds = new Set(
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
const existingItemCodes = new Set(
currentValue
.map((item: any) => item.id || item.po_item_id || item.item_id)
.map((item: any) => item.item_code)
.filter(Boolean)
);
const uniqueNewItems = filteredData.filter((item: any) => {
const itemId = item.id || item.po_item_id || item.item_id;
if (itemId && existingIds.has(itemId)) {
const itemCode = item.item_code;
if (itemCode && existingItemCodes.has(itemCode)) {
duplicateCount++;
return false; // 중복 항목 제외
}
@ -291,11 +298,12 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
setGroupedData(newItems);
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemIds = newItems
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
const newItemCodes = newItems
.map((item: any) => String(item.item_code))
.filter(Boolean);
splitPanelContext.addItemIds(newItemIds);
splitPanelContext.addItemIds(newItemCodes);
}
// JSON 문자열로 변환하여 저장

View File

@ -53,6 +53,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[],
linkedFilters: config.linkedFilters || [] as LinkedFilter[],
disableAutoDataTransfer: config.disableAutoDataTransfer ?? false, // 자동 데이터 전달 비활성화
...config,
});
@ -69,6 +70,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [],
linkedFilters: config.linkedFilters || [],
disableAutoDataTransfer: config.disableAutoDataTransfer ?? false,
...config,
});
}, [config]);
@ -386,6 +388,26 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
onCheckedChange={(checked) => updateConfig("resizable", checked)}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="disableAutoDataTransfer" className="text-xs font-medium">
</Label>
<p className="text-muted-foreground text-xs">
.
<br />
.
</p>
</div>
<Checkbox
id="disableAutoDataTransfer"
checked={localConfig.disableAutoDataTransfer}
onCheckedChange={(checked) => updateConfig("disableAutoDataTransfer", checked)}
/>
</div>
</CardContent>
</Card>
</TabsContent>

View File

@ -534,6 +534,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({});
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
@ -1240,15 +1244,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
const apiClient = (await import("@/lib/api/client")).apiClient;
for (const columnName of categoryColumns) {
try {
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
let targetTable = tableConfig.selectedTable;
let targetColumn = columnName;
if (columnName.includes(".")) {
const parts = columnName.split(".");
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, {
originalColumn: columnName,
targetTable,
targetColumn,
});
}
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`,
url: `/table-categories/${targetTable}/${targetColumn}/values`,
});
const apiClient = (await import("@/lib/api/client")).apiClient;
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
console.log(`📡 [TableList] API 응답 [${columnName}]:`, {
success: response.data.success,
@ -1271,6 +1290,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
if (Object.keys(mapping).length > 0) {
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
mappings[columnName] = mapping;
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
columnName,
@ -1299,6 +1319,120 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}
// 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드
// 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출
const joinedColumns = tableConfig.columns
?.filter((col) => col.columnName?.includes("."))
.map((col) => col.columnName) || [];
// 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material)
const additionalJoinColumns = tableConfig.columns
?.filter((col: any) => col.additionalJoinInfo?.referenceTable)
.map((col: any) => ({
columnName: col.columnName, // 예: item_code_material
referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info
// joinAlias에서 실제 컬럼명 추출 (item_code_material → material)
actualColumn: col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, '') || col.columnName,
})) || [];
console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns);
// 조인 테이블별로 그룹화
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
// "테이블명.컬럼명" 형태 처리
for (const joinedColumn of joinedColumns) {
const parts = joinedColumn.split(".");
if (parts.length !== 2) continue;
const joinedTable = parts[0];
const joinedColumnName = parts[1];
if (!joinedTableColumns[joinedTable]) {
joinedTableColumns[joinedTable] = [];
}
joinedTableColumns[joinedTable].push({
columnName: joinedColumn,
actualColumn: joinedColumnName,
});
}
// additionalJoinInfo 형태 처리
for (const col of additionalJoinColumns) {
if (!joinedTableColumns[col.referenceTable]) {
joinedTableColumns[col.referenceTable] = [];
}
joinedTableColumns[col.referenceTable].push({
columnName: col.columnName, // 예: item_code_material
actualColumn: col.actualColumn, // 예: material
});
}
console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns);
// 조인된 테이블별로 inputType 정보 가져오기
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) {
try {
// 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용)
const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable);
console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes);
for (const col of columns) {
const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn);
// 컬럼명 그대로 저장 (item_code_material 또는 item_info.material)
newJoinedColumnMeta[col.columnName] = {
inputType: inputTypeInfo?.inputType,
};
console.log(` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`);
// inputType이 category인 경우 카테고리 매핑 로드
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
try {
console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, {
url: `/table-categories/${joinedTable}/${col.actualColumn}/values`,
});
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/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,
color: item.color,
};
});
if (Object.keys(mapping).length > 0) {
mappings[col.columnName] = mapping;
console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, {
mappingCount: Object.keys(mapping).length,
});
}
}
} catch (error) {
console.log(` [TableList] 조인 테이블 카테고리 없음 (${col.columnName})`);
}
}
}
} catch (error) {
console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error);
}
}
// 조인 컬럼 메타데이터 상태 업데이트
if (Object.keys(newJoinedColumnMeta).length > 0) {
setJoinedColumnMeta(newJoinedColumnMeta);
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
}
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
mappingsCount: Object.keys(mappings).length,
mappingsKeys: Object.keys(mappings),
@ -1318,7 +1452,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
loadCategoryMappings();
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns)]); // 더 명확한 의존성
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns)]); // 더 명확한 의존성
// ========================================
// 데이터 가져오기
@ -1905,7 +2039,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
handleRowSelection(rowKey, !isCurrentlySelected);
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
if (splitPanelContext && splitPanelPosition === "left") {
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
@ -3813,7 +3948,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return rowData.writer_name;
}
const meta = columnMeta[column.columnName];
// 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기
const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName];
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
@ -3838,7 +3974,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (inputType === "category") {
if (!value) return "";
const mapping = categoryMappings[column.columnName];
// 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기
// 1. 원래 컬럼명 (item_info.material)
// 2. 점(.) 뒤의 컬럼명만 (material)
let mapping = categoryMappings[column.columnName];
if (!mapping && column.columnName.includes(".")) {
const simpleColumnName = column.columnName.split(".").pop();
if (simpleColumnName) {
mapping = categoryMappings[simpleColumnName];
}
}
const { Badge } = require("@/components/ui/badge");
// 다중 값 처리: 콤마로 구분된 값들을 분리
@ -3992,7 +4139,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return String(value);
}
},
[columnMeta, optimizedConvertCode, categoryMappings],
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
);
// ========================================

View File

@ -265,7 +265,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
columnName: col.columnName || col.column_name,
dataType: col.dataType || col.data_type || "text",
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
}))
})),
);
console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개");
}
@ -511,7 +511,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
const configKey = `${column.columnName}`;
// 이미 로드된 경우 스킵
if (entityDisplayConfigs[configKey]) return;
@ -609,7 +609,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 기본 테이블 컬럼 정보는 항상 로드
const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
const sourceColumns = sourceResult.columns || [];
// joinTable이 있으면 조인 테이블 컬럼도 로드
let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
if (joinTable) {
@ -761,9 +761,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
placeholder="테이블 제목 입력..."
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
우선순위: 사용자
</p>
<p className="text-muted-foreground text-[10px]">우선순위: 사용자 </p>
</div>
</div>
@ -782,7 +780,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/>
<Label htmlFor="checkboxEnabled"> </Label>
</div>
{config.checkbox?.enabled && (
<>
<div className="flex items-center space-x-2">
@ -793,7 +791,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/>
<Label htmlFor="checkboxSelectAll"> </Label>
</div>
<div className="space-y-1">
<Label htmlFor="checkboxPosition" className="text-xs">
@ -802,7 +800,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
id="checkboxPosition"
value={config.checkbox?.position || "left"}
onChange={(e) => handleNestedChange("checkbox", "position", e.target.value)}
className="w-full h-8 text-xs border rounded-md px-2"
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="left"></option>
<option value="right"></option>
@ -913,7 +911,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<CommandGroup heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}>
<CommandGroup
heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}
>
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
<CommandItem
key={`source-${col.columnName}`}
@ -966,11 +966,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
{/* 참조 테이블 미설정 안내 */}
{!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
. .
</div>
)}
{!column.entityDisplayConfig?.joinTable &&
entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
.
.
</div>
)}
{/* 선택된 컬럼 미리보기 */}
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
@ -1107,7 +1109,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 해당 컬럼의 input_type 확인
const columnInfo = availableColumns.find((col) => col.columnName === column.columnName);
const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal";
return (
<div
key={column.columnName}
@ -1119,7 +1121,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<span className="truncate text-xs" style={{ fontSize: "12px" }}>
{columnInfo?.label || column.displayName || column.columnName}
</span>
{/* 숫자 타입인 경우 천단위 구분자 설정 */}
{isNumberType && (
<div className="flex items-center gap-1">
@ -1131,9 +1133,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}}
className="h-3 w-3"
/>
<Label
htmlFor={`thousand-sep-${column.columnName}`}
className="text-[10px] text-muted-foreground cursor-pointer"
<Label
htmlFor={`thousand-sep-${column.columnName}`}
className="text-muted-foreground cursor-pointer text-[10px]"
>
</Label>
@ -1167,8 +1169,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
onCheckedChange={(checked) => {
const currentFilters = config.filter?.filters || [];
const columnLabel =
columnInfo?.label || column.displayName || column.columnName;
const columnLabel = columnInfo?.label || column.displayName || column.columnName;
if (checked) {
// 필터 추가
@ -1261,9 +1262,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
placeholder="40"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
기본값: 40px (0-200px , 10px )
</p>
<p className="text-muted-foreground text-[10px]">기본값: 40px (0-200px , 10px )</p>
</div>
</div>
)}
@ -1272,19 +1271,20 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<hr className="border-border" />
<DataFilterConfigPanel
tableName={config.selectedTable || screenTableName}
columns={availableColumns.map((col) => ({
columnName: col.columnName,
columnLabel: col.label || col.columnName,
dataType: col.dataType,
input_type: col.input_type, // 🆕 실제 input_type 전달
} as any))}
columns={availableColumns.map(
(col) =>
({
columnName: col.columnName,
columnLabel: col.label || col.columnName,
dataType: col.dataType,
input_type: col.input_type, // 🆕 실제 input_type 전달
}) as any,
)}
config={config.dataFilter}
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
/>
@ -1294,12 +1294,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-muted-foreground mt-1 text-xs">
</p>
</div>
<hr className="border-border" />
{/* 연결된 필터 목록 */}
<div className="space-y-2">
{(config.linkedFilters || []).map((filter, index) => (
@ -1314,16 +1314,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
newFilters[index] = { ...filter, sourceComponentId: e.target.value };
handleChange("linkedFilters", newFilters);
}}
className="h-7 text-xs flex-1"
className="h-7 flex-1 text-xs"
/>
<span className="text-xs text-muted-foreground"></span>
<span className="text-muted-foreground text-xs"></span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 flex-1 justify-between text-xs"
>
<Button variant="outline" role="combobox" className="h-7 flex-1 justify-between text-xs">
{filter.targetColumn || "필터링할 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
@ -1332,7 +1328,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> </CommandEmpty>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
<CommandGroup>
{availableColumns.map((col) => (
<CommandItem
@ -1348,7 +1344,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Check
className={cn(
"mr-2 h-3 w-3",
filter.targetColumn === col.columnName ? "opacity-100" : "opacity-0"
filter.targetColumn === col.columnName ? "opacity-100" : "opacity-0",
)}
/>
{col.label || col.columnName}
@ -1374,7 +1370,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</Button>
</div>
))}
{/* 연결된 필터 추가 버튼 */}
<Button
variant="outline"
@ -1382,7 +1378,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
onClick={() => {
const newFilters = [
...(config.linkedFilters || []),
{ sourceComponentId: "", targetColumn: "", operator: "equals" as const, enabled: true }
{ sourceComponentId: "", targetColumn: "", operator: "equals" as const, enabled: true },
];
handleChange("linkedFilters", newFilters);
}}
@ -1391,8 +1387,8 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Plus className="mr-1 h-3 w-3" />
</Button>
<p className="text-[10px] text-muted-foreground">
<p className="text-muted-foreground text-[10px]">
: 셀렉트박스(ID: select-basic-123) inbound_type
</p>
</div>
@ -1402,12 +1398,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-muted-foreground mt-1 text-xs">
</p>
</div>
<hr className="border-border" />
{/* 제외 필터 활성화 */}
<div className="flex items-center gap-2">
<Checkbox
@ -1424,7 +1420,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</Label>
</div>
{config.excludeFilter?.enabled && (
<div className="space-y-3 rounded border p-3">
{/* 참조 테이블 선택 */}
@ -1432,11 +1428,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Label className="text-xs font-medium"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.excludeFilter?.referenceTable || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
@ -1445,7 +1437,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> </CommandEmpty>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
@ -1466,7 +1458,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.referenceTable === table.tableName ? "opacity-100" : "opacity-0"
config.excludeFilter?.referenceTable === table.tableName
? "opacity-100"
: "opacity-0",
)}
/>
{table.displayName || table.tableName}
@ -1478,7 +1472,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</PopoverContent>
</Popover>
</div>
{config.excludeFilter?.referenceTable && (
<>
{/* 비교 컬럼 설정 - 한 줄에 두 개 */}
@ -1494,9 +1488,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
disabled={loadingReferenceColumns}
className="h-8 w-full justify-between text-xs"
>
{loadingReferenceColumns
? "..."
: config.excludeFilter?.referenceColumn || "선택"}
{loadingReferenceColumns ? "..." : config.excludeFilter?.referenceColumn || "선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -1504,7 +1496,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandEmpty className="py-2 text-xs"></CommandEmpty>
<CommandGroup>
{referenceTableColumns.map((col) => (
<CommandItem
@ -1521,7 +1513,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.referenceColumn === col.columnName ? "opacity-100" : "opacity-0"
config.excludeFilter?.referenceColumn === col.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{col.label || col.columnName}
@ -1533,17 +1527,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</PopoverContent>
</Popover>
</div>
{/* 소스 컬럼 (현재 테이블) */}
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.excludeFilter?.sourceColumn || "선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
@ -1552,7 +1542,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandEmpty className="py-2 text-xs"></CommandEmpty>
<CommandGroup>
{availableColumns.map((col) => (
<CommandItem
@ -1569,7 +1559,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.sourceColumn === col.columnName ? "opacity-100" : "opacity-0"
config.excludeFilter?.sourceColumn === col.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{col.label || col.columnName}
@ -1582,11 +1574,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</Popover>
</div>
</div>
{/* 조건 필터 - 특정 조건의 데이터만 제외 */}
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<p className="text-[10px] text-muted-foreground mb-1">
<p className="text-muted-foreground mb-1 text-[10px]">
(: 특정 )
</p>
<div className="grid grid-cols-2 gap-2">
@ -1599,9 +1591,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
disabled={loadingReferenceColumns}
className="h-8 w-full justify-between text-xs"
>
{loadingReferenceColumns
? "..."
: config.excludeFilter?.filterColumn
{loadingReferenceColumns
? "..."
: config.excludeFilter?.filterColumn
? `매핑: ${config.excludeFilter.filterColumn}`
: "매핑 테이블 컬럼"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
@ -1611,7 +1603,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandEmpty className="py-2 text-xs"></CommandEmpty>
<CommandGroup>
<CommandItem
value=""
@ -1622,9 +1614,14 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
filterValueField: undefined,
});
}}
className="text-xs text-muted-foreground"
className="text-muted-foreground text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !config.excludeFilter?.filterColumn ? "opacity-100" : "opacity-0")} />
<Check
className={cn(
"mr-2 h-3 w-3",
!config.excludeFilter?.filterColumn ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
{referenceTableColumns.map((col) => (
@ -1645,7 +1642,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.filterColumn === col.columnName ? "opacity-100" : "opacity-0"
config.excludeFilter?.filterColumn === col.columnName
? "opacity-100"
: "opacity-0",
)}
/>
{col.label || col.columnName}
@ -1656,7 +1655,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</Command>
</PopoverContent>
</Popover>
{/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */}
<Input
placeholder="예: customer_code"
@ -1674,18 +1673,24 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
</>
)}
{/* 설정 요약 */}
{config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && (
<div className="rounded bg-muted/50 p-2 text-[10px] text-muted-foreground">
<strong> :</strong> {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn}
{" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn}
{config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && (
<> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField} )</>
)}
{" "}
</div>
)}
{config.excludeFilter?.referenceTable &&
config.excludeFilter?.referenceColumn &&
config.excludeFilter?.sourceColumn && (
<div className="bg-muted/50 text-muted-foreground rounded p-2 text-[10px]">
<strong> :</strong> {config.selectedTable || screenTableName}.
{config.excludeFilter.sourceColumn} {config.excludeFilter.referenceTable}.
{config.excludeFilter.referenceColumn}
{config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && (
<>
{" "}
({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField} )
</>
)}{" "}
</div>
)}
</div>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More