Compare commits
55 Commits
4787a8b177
...
bc34cded95
| Author | SHA1 | Date |
|---|---|---|
|
|
bc34cded95 | |
|
|
a42db5f15a | |
|
|
30e6595bf3 | |
|
|
2c447fd325 | |
|
|
650c5ef722 | |
|
|
0789eb2e20 | |
|
|
8c83db596d | |
|
|
fb068284db | |
|
|
18521339bb | |
|
|
7242f08224 | |
|
|
fbeb3ec2c9 | |
|
|
15d5708b5d | |
|
|
7263c9c3ff | |
|
|
6545410d49 | |
|
|
36132bf07c | |
|
|
aca00b8704 | |
|
|
617655a42a | |
|
|
b1b9e4ad93 | |
|
|
8d2ec8e737 | |
|
|
b77cc47791 | |
|
|
1823415a5b | |
|
|
da6ac92391 | |
|
|
be2550885a | |
|
|
fd7a1bbf53 | |
|
|
655eead3b6 | |
|
|
848d111975 | |
|
|
75bdc19f25 | |
|
|
93b92960e7 | |
|
|
ad0a84f2c3 | |
|
|
d7ee63a857 | |
|
|
64c11d548c | |
|
|
a3d3db5437 | |
|
|
c657d6f7a0 | |
|
|
53eab6ac9c | |
|
|
9e6fa67215 | |
|
|
142fb15dc0 | |
|
|
e4b1f7e4d8 | |
|
|
1462700c83 | |
|
|
ac01c7586d | |
|
|
1849bf6654 | |
|
|
1503dd87bb | |
|
|
93174db7c8 | |
|
|
9f97a16d6a | |
|
|
84fee9cc38 | |
|
|
bd4e3e507d | |
|
|
627c5a5173 | |
|
|
67e6a8008d | |
|
|
b43bf57ea9 | |
|
|
c78ba865b6 | |
|
|
f15846fd10 | |
|
|
39d327fb45 | |
|
|
30dac204c0 | |
|
|
51c49f7a3d | |
|
|
454f79caec | |
|
|
fb9de05b00 |
|
|
@ -71,6 +71,8 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
|||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -236,6 +238,8 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
|||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -280,7 +284,7 @@ app.listen(PORT, HOST, async () => {
|
|||
|
||||
// 배치 스케줄러 초기화
|
||||
try {
|
||||
await BatchSchedulerService.initialize();
|
||||
await BatchSchedulerService.initializeScheduler();
|
||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { Response } from "express";
|
||||
import https from "https";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { DashboardService } from "../services/DashboardService";
|
||||
import {
|
||||
|
|
@ -7,6 +10,7 @@ import {
|
|||
DashboardListQuery,
|
||||
} from "../types/dashboard";
|
||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
|
||||
|
||||
/**
|
||||
* 대시보드 컨트롤러
|
||||
|
|
@ -415,7 +419,7 @@ export class DashboardController {
|
|||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
createdBy: userId, // 본인이 만든 대시보드만
|
||||
// createdBy 제거 - 회사 대시보드 전체 표시
|
||||
};
|
||||
|
||||
const result = await DashboardService.getDashboards(
|
||||
|
|
@ -590,7 +594,14 @@ export class DashboardController {
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
|
||||
const {
|
||||
url,
|
||||
method = "GET",
|
||||
headers = {},
|
||||
queryParams = {},
|
||||
body,
|
||||
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
|
||||
} = req.body;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
res.status(400).json({
|
||||
|
|
@ -608,85 +619,153 @@ export class DashboardController {
|
|||
}
|
||||
});
|
||||
|
||||
// 외부 API 호출 (타임아웃 30초)
|
||||
// @ts-ignore - node-fetch dynamic import
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
|
||||
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
|
||||
const controller = new (global as any).AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
|
||||
// Axios 요청 설정
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
url: urlObj.toString(),
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...headers,
|
||||
},
|
||||
timeout: 60000, // 60초 타임아웃
|
||||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||
};
|
||||
|
||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||
if (externalConnectionId) {
|
||||
try {
|
||||
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
|
||||
let companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
companyCode = "*";
|
||||
}
|
||||
|
||||
// 커넥션 로드
|
||||
const connectionResult =
|
||||
await ExternalRestApiConnectionService.getConnectionById(
|
||||
Number(externalConnectionId),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (connectionResult.success && connectionResult.data) {
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 인증 헤더 생성 (DB 토큰 등)
|
||||
const authHeaders =
|
||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||
connection.auth_type,
|
||||
connection.auth_config,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 기존 헤더에 인증 헤더 병합
|
||||
requestConfig.headers = {
|
||||
...requestConfig.headers,
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
// API Key가 Query Param인 경우 처리
|
||||
if (
|
||||
connection.auth_type === "api-key" &&
|
||||
connection.auth_config?.keyLocation === "query" &&
|
||||
connection.auth_config?.keyName &&
|
||||
connection.auth_config?.keyValue
|
||||
) {
|
||||
const currentUrl = new URL(requestConfig.url!);
|
||||
currentUrl.searchParams.append(
|
||||
connection.auth_config.keyName,
|
||||
connection.auth_config.keyValue
|
||||
);
|
||||
requestConfig.url = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
} catch (connError) {
|
||||
logger.error(
|
||||
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
|
||||
connError
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Body 처리
|
||||
if (body) {
|
||||
requestConfig.data = body;
|
||||
}
|
||||
|
||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const hostname = urlObj.hostname;
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
hostname.includes(domain)
|
||||
);
|
||||
|
||||
if (shouldBypassTls) {
|
||||
requestConfig.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
||||
if (isKmaApi) {
|
||||
requestConfig.responseType = 'arraybuffer';
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Type에 따라 응답 파싱
|
||||
const contentType = response.headers.get("content-type");
|
||||
let data: any;
|
||||
let data = response.data;
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// 한글 인코딩 처리 (EUC-KR → UTF-8)
|
||||
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
|
||||
urlObj.hostname.includes('data.go.kr');
|
||||
|
||||
if (isKoreanApi) {
|
||||
// 한국 정부 API는 EUC-KR 인코딩 사용
|
||||
const buffer = await response.arrayBuffer();
|
||||
const decoder = new TextDecoder('euc-kr');
|
||||
const text = decoder.decode(buffer);
|
||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||
const iconv = require('iconv-lite');
|
||||
const buffer = Buffer.from(data);
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { text, contentType };
|
||||
}
|
||||
} else if (contentType && contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
} else if (contentType && contentType.includes("text/")) {
|
||||
// 텍스트 응답 (CSV, 일반 텍스트 등)
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
} else {
|
||||
// 기타 응답 (JSON으로 시도)
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
// UTF-8로 정상 디코딩되었는지 확인
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
||||
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
||||
}
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
else if (typeof data === "string") {
|
||||
data = { text: data, contentType };
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || 500;
|
||||
const message = error.response?.statusText || error.message;
|
||||
|
||||
logger.error("외부 API 호출 오류:", {
|
||||
message,
|
||||
status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
? message
|
||||
: "외부 API 호출 오류",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import {
|
||||
BatchConfigFilter,
|
||||
CreateBatchConfigRequest,
|
||||
|
|
@ -63,7 +64,7 @@ export class BatchController {
|
|||
res: Response
|
||||
) {
|
||||
try {
|
||||
const result = await BatchService.getAvailableConnections();
|
||||
const result = await BatchExternalDbService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
|
|
@ -99,8 +100,8 @@ export class BatchController {
|
|||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTablesFromConnection(
|
||||
type,
|
||||
const result = await BatchService.getTables(
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
|
|
@ -142,10 +143,10 @@ export class BatchController {
|
|||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTableColumns(
|
||||
type,
|
||||
connectionId,
|
||||
tableName
|
||||
const result = await BatchService.getColumns(
|
||||
tableName,
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -331,8 +331,11 @@ export class BatchManagementController {
|
|||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// executionLog가 정의되어 있는지 확인
|
||||
if (typeof executionLog !== "undefined") {
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
if (typeof executionLog !== "undefined" && executionLog) {
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
|
|
@ -594,7 +597,7 @@ export class BatchManagementController {
|
|||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
try {
|
||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
||||
await BatchSchedulerService.scheduleBatch(result.data);
|
||||
console.log(
|
||||
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,11 +22,19 @@ export const getLayouts = async (
|
|||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||
WHERE l.company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 최고 관리자는 모든 레이아웃 조회 가능
|
||||
if (companyCode && companyCode !== '*') {
|
||||
query += ` WHERE l.company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
} else {
|
||||
query += ` WHERE 1=1`;
|
||||
}
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||
|
|
@ -75,14 +83,27 @@ export const getLayoutById = async (
|
|||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
// 레이아웃 기본 정보
|
||||
const layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1 AND l.company_code = $2
|
||||
`;
|
||||
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
|
||||
let layoutQuery: string;
|
||||
let layoutParams: any[];
|
||||
|
||||
const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
|
||||
if (companyCode && companyCode !== '*') {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1 AND l.company_code = $2
|
||||
`;
|
||||
layoutParams = [id, companyCode];
|
||||
} else {
|
||||
layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1
|
||||
`;
|
||||
layoutParams = [id];
|
||||
}
|
||||
|
||||
const layoutResult = await pool.query(layoutQuery, layoutParams);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
|
|||
};
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
parseInt(id),
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
@ -419,3 +419,188 @@ export const getTableColumns = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
export const updateFieldValue = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
||||
|
||||
console.log("🔄 [updateFieldValue] 요청:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 업데이트 쿼리 실행
|
||||
const result = await dynamicFormService.updateFieldValue(
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log("✅ [updateFieldValue] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "필드 값이 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [updateFieldValue] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "필드 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 이력 저장 (연속 위치 추적용)
|
||||
* POST /api/dynamic-form/location-history
|
||||
*/
|
||||
export const saveLocationHistory = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
recordedAt,
|
||||
vehicleId,
|
||||
} = req.body;
|
||||
|
||||
console.log("📍 [saveLocationHistory] 요청:", {
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
tripId,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await dynamicFormService.saveLocationHistory({
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
tripId,
|
||||
tripStatus: tripStatus || "active",
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
recordedAt: recordedAt || new Date().toISOString(),
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
console.log("✅ [saveLocationHistory] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "위치 이력이 저장되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [saveLocationHistory] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 이력 저장에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 이력 조회 (경로 조회용)
|
||||
* GET /api/dynamic-form/location-history/:tripId
|
||||
*/
|
||||
export const getLocationHistory = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.params;
|
||||
const { userId, startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📍 [getLocationHistory] 요청:", {
|
||||
tripId,
|
||||
userId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
});
|
||||
|
||||
const result = await dynamicFormService.getLocationHistory({
|
||||
companyCode,
|
||||
tripId,
|
||||
userId: userId as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 1000,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
count: result.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getLocationHistory] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 이력 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,8 +32,17 @@ export class FlowController {
|
|||
*/
|
||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
||||
req.body;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
|
|
@ -43,6 +52,9 @@ export class FlowController {
|
|||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
|
|
@ -54,8 +66,11 @@ export class FlowController {
|
|||
return;
|
||||
}
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인
|
||||
if (tableName) {
|
||||
// REST API인 경우 테이블 존재 확인 스킵
|
||||
const isRestApi = dbSourceType === "restapi";
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외)
|
||||
if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) {
|
||||
const tableExists =
|
||||
await this.flowDefinitionService.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
|
|
@ -68,7 +83,16 @@ export class FlowController {
|
|||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.create(
|
||||
{ name, description, tableName, dbSourceType, dbConnectionId },
|
||||
{
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
},
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,925 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// ============================================
|
||||
// 1. 화면 임베딩 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 화면 임베딩 목록 조회
|
||||
* GET /api/screen-embedding?parentScreenId=1
|
||||
*/
|
||||
export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { parentScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (!parentScreenId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "부모 화면 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
se.*,
|
||||
ps.screen_name as parent_screen_name,
|
||||
cs.screen_name as child_screen_name
|
||||
FROM screen_embedding se
|
||||
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
|
||||
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
|
||||
WHERE se.parent_screen_id = $1
|
||||
AND se.company_code = $2
|
||||
ORDER BY se.position, se.created_at
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [parentScreenId, companyCode]);
|
||||
|
||||
logger.info("화면 임베딩 목록 조회", {
|
||||
companyCode,
|
||||
parentScreenId,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 상세 조회
|
||||
* GET /api/screen-embedding/:id
|
||||
*/
|
||||
export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
se.*,
|
||||
ps.screen_name as parent_screen_name,
|
||||
cs.screen_name as child_screen_name
|
||||
FROM screen_embedding se
|
||||
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
|
||||
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
|
||||
WHERE se.id = $1
|
||||
AND se.company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면 임베딩 상세 조회", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 상세 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 생성
|
||||
* POST /api/screen-embedding
|
||||
*/
|
||||
export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
parentScreenId,
|
||||
childScreenId,
|
||||
position,
|
||||
mode,
|
||||
config = {},
|
||||
} = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!parentScreenId || !childScreenId || !position || !mode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO screen_embedding (
|
||||
parent_screen_id, child_screen_id, position, mode,
|
||||
config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
parentScreenId,
|
||||
childScreenId,
|
||||
position,
|
||||
mode,
|
||||
JSON.stringify(config),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("화면 임베딩 생성", {
|
||||
companyCode,
|
||||
userId,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 생성 실패", error);
|
||||
|
||||
// 유니크 제약조건 위반
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 동일한 임베딩 설정이 존재합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 수정
|
||||
* PUT /api/screen-embedding/:id
|
||||
*/
|
||||
export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { position, mode, config } = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (position) {
|
||||
updates.push(`position = $${paramIndex++}`);
|
||||
values.push(position);
|
||||
}
|
||||
|
||||
if (mode) {
|
||||
updates.push(`mode = $${paramIndex++}`);
|
||||
values.push(mode);
|
||||
}
|
||||
|
||||
if (config) {
|
||||
updates.push(`config = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(config));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
values.push(id, companyCode);
|
||||
|
||||
const query = `
|
||||
UPDATE screen_embedding
|
||||
SET ${updates.join(", ")}
|
||||
WHERE id = $${paramIndex++}
|
||||
AND company_code = $${paramIndex++}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면 임베딩 수정", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 삭제
|
||||
* DELETE /api/screen-embedding/:id
|
||||
*/
|
||||
export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
DELETE FROM screen_embedding
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면 임베딩 삭제", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "화면 임베딩이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. 데이터 전달 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 조회
|
||||
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
|
||||
*/
|
||||
export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { sourceScreenId, targetScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (!sourceScreenId || !targetScreenId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sdt.*,
|
||||
ss.screen_name as source_screen_name,
|
||||
ts.screen_name as target_screen_name
|
||||
FROM screen_data_transfer sdt
|
||||
LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id
|
||||
LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id
|
||||
WHERE sdt.source_screen_id = $1
|
||||
AND sdt.target_screen_id = $2
|
||||
AND sdt.company_code = $3
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 전달 설정 조회", {
|
||||
companyCode,
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 생성
|
||||
* POST /api/screen-data-transfer
|
||||
*/
|
||||
export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
sourceComponentId,
|
||||
sourceComponentType,
|
||||
dataReceivers,
|
||||
buttonConfig,
|
||||
} = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!sourceScreenId || !targetScreenId || !dataReceivers) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO screen_data_transfer (
|
||||
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||||
data_receivers, button_config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
sourceComponentId,
|
||||
sourceComponentType,
|
||||
JSON.stringify(dataReceivers),
|
||||
JSON.stringify(buttonConfig || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("데이터 전달 설정 생성", {
|
||||
companyCode,
|
||||
userId,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 생성 실패", error);
|
||||
|
||||
// 유니크 제약조건 위반
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 동일한 데이터 전달 설정이 존재합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 수정
|
||||
* PUT /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { dataReceivers, buttonConfig } = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (dataReceivers) {
|
||||
updates.push(`data_receivers = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(dataReceivers));
|
||||
}
|
||||
|
||||
if (buttonConfig) {
|
||||
updates.push(`button_config = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(buttonConfig));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
values.push(id, companyCode);
|
||||
|
||||
const query = `
|
||||
UPDATE screen_data_transfer
|
||||
SET ${updates.join(", ")}
|
||||
WHERE id = $${paramIndex++}
|
||||
AND company_code = $${paramIndex++}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 전달 설정 수정", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 삭제
|
||||
* DELETE /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
DELETE FROM screen_data_transfer
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 전달 설정 삭제", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "데이터 전달 설정이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. 분할 패널 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 조회
|
||||
* GET /api/screen-split-panel/:screenId
|
||||
*/
|
||||
export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
ssp.*,
|
||||
le.parent_screen_id as le_parent_screen_id,
|
||||
le.child_screen_id as le_child_screen_id,
|
||||
le.position as le_position,
|
||||
le.mode as le_mode,
|
||||
le.config as le_config,
|
||||
re.parent_screen_id as re_parent_screen_id,
|
||||
re.child_screen_id as re_child_screen_id,
|
||||
re.position as re_position,
|
||||
re.mode as re_mode,
|
||||
re.config as re_config,
|
||||
sdt.source_screen_id,
|
||||
sdt.target_screen_id,
|
||||
sdt.source_component_id,
|
||||
sdt.source_component_type,
|
||||
sdt.data_receivers,
|
||||
sdt.button_config
|
||||
FROM screen_split_panel ssp
|
||||
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
|
||||
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
|
||||
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
|
||||
WHERE ssp.screen_id = $1
|
||||
AND ssp.company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [screenId, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
// 데이터 구조화
|
||||
const data = {
|
||||
id: row.id,
|
||||
screenId: row.screen_id,
|
||||
leftEmbeddingId: row.left_embedding_id,
|
||||
rightEmbeddingId: row.right_embedding_id,
|
||||
dataTransferId: row.data_transfer_id,
|
||||
layoutConfig: row.layout_config,
|
||||
companyCode: row.company_code,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
leftEmbedding: row.le_child_screen_id
|
||||
? {
|
||||
id: row.left_embedding_id,
|
||||
parentScreenId: row.le_parent_screen_id,
|
||||
childScreenId: row.le_child_screen_id,
|
||||
position: row.le_position,
|
||||
mode: row.le_mode,
|
||||
config: row.le_config,
|
||||
}
|
||||
: null,
|
||||
rightEmbedding: row.re_child_screen_id
|
||||
? {
|
||||
id: row.right_embedding_id,
|
||||
parentScreenId: row.re_parent_screen_id,
|
||||
childScreenId: row.re_child_screen_id,
|
||||
position: row.re_position,
|
||||
mode: row.re_mode,
|
||||
config: row.re_config,
|
||||
}
|
||||
: null,
|
||||
dataTransfer: row.source_screen_id
|
||||
? {
|
||||
id: row.data_transfer_id,
|
||||
sourceScreenId: row.source_screen_id,
|
||||
targetScreenId: row.target_screen_id,
|
||||
sourceComponentId: row.source_component_id,
|
||||
sourceComponentType: row.source_component_type,
|
||||
dataReceivers: row.data_receivers,
|
||||
buttonConfig: row.button_config,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
logger.info("분할 패널 설정 조회", { companyCode, screenId });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("분할 패널 설정 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 생성
|
||||
* POST /api/screen-split-panel
|
||||
*/
|
||||
export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const {
|
||||
screenId,
|
||||
leftEmbedding,
|
||||
rightEmbedding,
|
||||
dataTransfer,
|
||||
layoutConfig,
|
||||
} = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 좌측 임베딩 생성
|
||||
const leftEmbeddingQuery = `
|
||||
INSERT INTO screen_embedding (
|
||||
parent_screen_id, child_screen_id, position, mode,
|
||||
config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const leftResult = await client.query(leftEmbeddingQuery, [
|
||||
screenId,
|
||||
leftEmbedding.childScreenId,
|
||||
leftEmbedding.position,
|
||||
leftEmbedding.mode,
|
||||
JSON.stringify(leftEmbedding.config || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const leftEmbeddingId = leftResult.rows[0].id;
|
||||
|
||||
// 2. 우측 임베딩 생성
|
||||
const rightEmbeddingQuery = `
|
||||
INSERT INTO screen_embedding (
|
||||
parent_screen_id, child_screen_id, position, mode,
|
||||
config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const rightResult = await client.query(rightEmbeddingQuery, [
|
||||
screenId,
|
||||
rightEmbedding.childScreenId,
|
||||
rightEmbedding.position,
|
||||
rightEmbedding.mode,
|
||||
JSON.stringify(rightEmbedding.config || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const rightEmbeddingId = rightResult.rows[0].id;
|
||||
|
||||
// 3. 데이터 전달 설정 생성
|
||||
const dataTransferQuery = `
|
||||
INSERT INTO screen_data_transfer (
|
||||
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||||
data_receivers, button_config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const dataTransferResult = await client.query(dataTransferQuery, [
|
||||
dataTransfer.sourceScreenId,
|
||||
dataTransfer.targetScreenId,
|
||||
dataTransfer.sourceComponentId,
|
||||
dataTransfer.sourceComponentType,
|
||||
JSON.stringify(dataTransfer.dataReceivers),
|
||||
JSON.stringify(dataTransfer.buttonConfig || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const dataTransferId = dataTransferResult.rows[0].id;
|
||||
|
||||
// 4. 분할 패널 생성
|
||||
const splitPanelQuery = `
|
||||
INSERT INTO screen_split_panel (
|
||||
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
|
||||
layout_config, company_code, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const splitPanelResult = await client.query(splitPanelQuery, [
|
||||
screenId,
|
||||
leftEmbeddingId,
|
||||
rightEmbeddingId,
|
||||
dataTransferId,
|
||||
JSON.stringify(layoutConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("분할 패널 설정 생성", {
|
||||
companyCode,
|
||||
userId,
|
||||
screenId,
|
||||
id: splitPanelResult.rows[0].id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: splitPanelResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("분할 패널 설정 생성 실패", error);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 수정
|
||||
* PUT /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { layoutConfig } = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (!layoutConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE screen_split_panel
|
||||
SET layout_config = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND company_code = $3
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
JSON.stringify(layoutConfig),
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("분할 패널 설정 수정", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("분할 패널 설정 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 삭제
|
||||
* DELETE /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 분할 패널 조회
|
||||
const selectQuery = `
|
||||
SELECT left_embedding_id, right_embedding_id, data_transfer_id
|
||||
FROM screen_split_panel
|
||||
WHERE id = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
const selectResult = await client.query(selectQuery, [id, companyCode]);
|
||||
|
||||
if (selectResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { left_embedding_id, right_embedding_id, data_transfer_id } =
|
||||
selectResult.rows[0];
|
||||
|
||||
// 2. 분할 패널 삭제
|
||||
await client.query(
|
||||
"DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
// 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
|
||||
if (left_embedding_id) {
|
||||
await client.query(
|
||||
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
|
||||
[left_embedding_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
if (right_embedding_id) {
|
||||
await client.query(
|
||||
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
|
||||
[right_embedding_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
if (data_transfer_id) {
|
||||
await client.query(
|
||||
"DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2",
|
||||
[data_transfer_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("분할 패널 설정 삭제", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "분할 패널 설정이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("분할 패널 설정 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -148,11 +148,42 @@ export const updateScreenInfo = async (
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { screenName, tableName, description, isActive } = req.body;
|
||||
const {
|
||||
screenName,
|
||||
tableName,
|
||||
description,
|
||||
isActive,
|
||||
// REST API 관련 필드 추가
|
||||
dataSourceType,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
|
||||
console.log("화면 정보 수정 요청:", {
|
||||
screenId: id,
|
||||
dataSourceType,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
});
|
||||
|
||||
await screenManagementService.updateScreenInfo(
|
||||
parseInt(id),
|
||||
{ screenName, tableName, description, isActive },
|
||||
{
|
||||
screenName,
|
||||
tableName,
|
||||
description,
|
||||
isActive,
|
||||
dataSourceType,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
},
|
||||
companyCode
|
||||
);
|
||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||
|
|
|
|||
|
|
@ -481,6 +481,52 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* DELETE /api/categories/column-mapping/:tableName/:columnName
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*/
|
||||
export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
if (!tableName || !columnName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 columnName은 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn(
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`,
|
||||
deletedCount,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* 차량 운행 리포트 컨트롤러
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { vehicleReportService } from "../services/vehicleReportService";
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
* GET /api/vehicle/reports/daily
|
||||
*/
|
||||
export const getDailyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getDailyReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getDailyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "일별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
* GET /api/vehicle/reports/weekly
|
||||
*/
|
||||
export const getWeeklyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { year, month, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month });
|
||||
|
||||
const result = await vehicleReportService.getWeeklyReport(companyCode, {
|
||||
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||
month: month ? parseInt(month as string) : new Date().getMonth() + 1,
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getWeeklyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "주별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
* GET /api/vehicle/reports/monthly
|
||||
*/
|
||||
export const getMonthlyReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { year, userId, vehicleId } = req.query;
|
||||
|
||||
console.log("📊 [getMonthlyReport] 요청:", { companyCode, year });
|
||||
|
||||
const result = await vehicleReportService.getMonthlyReport(companyCode, {
|
||||
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getMonthlyReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "월별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
* GET /api/vehicle/reports/summary
|
||||
*/
|
||||
export const getSummaryReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { period } = req.query; // today, week, month, year
|
||||
|
||||
console.log("📊 [getSummaryReport] 요청:", { companyCode, period });
|
||||
|
||||
const result = await vehicleReportService.getSummaryReport(
|
||||
companyCode,
|
||||
(period as string) || "today"
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getSummaryReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "요약 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
* GET /api/vehicle/reports/by-driver
|
||||
*/
|
||||
export const getDriverReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getDriverReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getDriverReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운전자별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
* GET /api/vehicle/reports/by-route
|
||||
*/
|
||||
export const getRouteReport = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { startDate, endDate, limit } = req.query;
|
||||
|
||||
console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate });
|
||||
|
||||
const result = await vehicleReportService.getRouteReport(companyCode, {
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getRouteReport] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "구간별 통계 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
/**
|
||||
* 차량 운행 이력 컨트롤러
|
||||
*/
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { vehicleTripService } from "../services/vehicleTripService";
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
* POST /api/vehicle/trip/start
|
||||
*/
|
||||
export const startTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body;
|
||||
|
||||
console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival });
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.startTrip({
|
||||
userId,
|
||||
companyCode,
|
||||
vehicleId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
console.log("✅ [startTrip] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "운행이 시작되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [startTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 시작에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
* POST /api/vehicle/trip/end
|
||||
*/
|
||||
export const endTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tripId, latitude, longitude } = req.body;
|
||||
|
||||
console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId });
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.endTrip({
|
||||
tripId,
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
console.log("✅ [endTrip] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "운행이 종료되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [endTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 종료에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
* POST /api/vehicle/trip/location
|
||||
*/
|
||||
export const addTripLocation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tripId, latitude, longitude, accuracy, speed } = req.body;
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.addLocation({
|
||||
tripId,
|
||||
userId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
speed,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [addTripLocation] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "위치 기록에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
* GET /api/vehicle/trips
|
||||
*/
|
||||
export const getTripList = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query;
|
||||
|
||||
console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate });
|
||||
|
||||
const result = await vehicleTripService.getTripList(companyCode, {
|
||||
userId: userId as string,
|
||||
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
departure: departure as string,
|
||||
arrival: arrival as string,
|
||||
limit: limit ? parseInt(limit as string) : 50,
|
||||
offset: offset ? parseInt(offset as string) : 0,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getTripList] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 이력 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
* GET /api/vehicle/trips/:tripId
|
||||
*/
|
||||
export const getTripDetail = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.params;
|
||||
|
||||
console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId });
|
||||
|
||||
const result = await vehicleTripService.getTripDetail(tripId, companyCode);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "운행 정보를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getTripDetail] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 상세 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 활성 운행 조회 (현재 진행 중)
|
||||
* GET /api/vehicle/trip/active
|
||||
*/
|
||||
export const getActiveTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
|
||||
const result = await vehicleTripService.getActiveTrip(userId, companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
hasActiveTrip: !!result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [getActiveTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "활성 운행 조회에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
* POST /api/vehicle/trip/cancel
|
||||
*/
|
||||
export const cancelTrip = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { tripId } = req.body;
|
||||
|
||||
if (!tripId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tripId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await vehicleTripService.cancelTrip(tripId, companyCode);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "취소할 운행을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "운행이 취소되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [cancelTrip] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "운행 취소에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -5,12 +5,15 @@ import {
|
|||
saveFormDataEnhanced,
|
||||
updateFormData,
|
||||
updateFormDataPartial,
|
||||
updateFieldValue,
|
||||
deleteFormData,
|
||||
getFormData,
|
||||
getFormDataList,
|
||||
validateFormData,
|
||||
getTableColumns,
|
||||
getTablePrimaryKeys,
|
||||
saveLocationHistory,
|
||||
getLocationHistory,
|
||||
} from "../controllers/dynamicFormController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -21,6 +24,7 @@ router.use(authenticateToken);
|
|||
// 폼 데이터 CRUD
|
||||
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
|
||||
router.put("/:id", updateFormData);
|
||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||
router.delete("/:id", deleteFormData);
|
||||
|
|
@ -38,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns);
|
|||
// 테이블 기본키 조회
|
||||
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
|
||||
|
||||
// 위치 이력 (연속 위치 추적)
|
||||
router.post("/location-history", saveLocationHistory);
|
||||
router.get("/location-history/:tripId", getLocationHistory);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 라우트
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
// 화면 임베딩
|
||||
getScreenEmbeddings,
|
||||
getScreenEmbeddingById,
|
||||
createScreenEmbedding,
|
||||
updateScreenEmbedding,
|
||||
deleteScreenEmbedding,
|
||||
// 데이터 전달
|
||||
getScreenDataTransfer,
|
||||
createScreenDataTransfer,
|
||||
updateScreenDataTransfer,
|
||||
deleteScreenDataTransfer,
|
||||
// 분할 패널
|
||||
getScreenSplitPanel,
|
||||
createScreenSplitPanel,
|
||||
updateScreenSplitPanel,
|
||||
deleteScreenSplitPanel,
|
||||
} from "../controllers/screenEmbeddingController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ============================================
|
||||
// 화면 임베딩 라우트
|
||||
// ============================================
|
||||
|
||||
// 화면 임베딩 목록 조회
|
||||
router.get("/screen-embedding", authenticateToken, getScreenEmbeddings);
|
||||
|
||||
// 화면 임베딩 상세 조회
|
||||
router.get("/screen-embedding/:id", authenticateToken, getScreenEmbeddingById);
|
||||
|
||||
// 화면 임베딩 생성
|
||||
router.post("/screen-embedding", authenticateToken, createScreenEmbedding);
|
||||
|
||||
// 화면 임베딩 수정
|
||||
router.put("/screen-embedding/:id", authenticateToken, updateScreenEmbedding);
|
||||
|
||||
// 화면 임베딩 삭제
|
||||
router.delete("/screen-embedding/:id", authenticateToken, deleteScreenEmbedding);
|
||||
|
||||
// ============================================
|
||||
// 데이터 전달 라우트
|
||||
// ============================================
|
||||
|
||||
// 데이터 전달 설정 조회
|
||||
router.get("/screen-data-transfer", authenticateToken, getScreenDataTransfer);
|
||||
|
||||
// 데이터 전달 설정 생성
|
||||
router.post("/screen-data-transfer", authenticateToken, createScreenDataTransfer);
|
||||
|
||||
// 데이터 전달 설정 수정
|
||||
router.put("/screen-data-transfer/:id", authenticateToken, updateScreenDataTransfer);
|
||||
|
||||
// 데이터 전달 설정 삭제
|
||||
router.delete("/screen-data-transfer/:id", authenticateToken, deleteScreenDataTransfer);
|
||||
|
||||
// ============================================
|
||||
// 분할 패널 라우트
|
||||
// ============================================
|
||||
|
||||
// 분할 패널 설정 조회
|
||||
router.get("/screen-split-panel/:screenId", authenticateToken, getScreenSplitPanel);
|
||||
|
||||
// 분할 패널 설정 생성
|
||||
router.post("/screen-split-panel", authenticateToken, createScreenSplitPanel);
|
||||
|
||||
// 분할 패널 설정 수정
|
||||
router.put("/screen-split-panel/:id", authenticateToken, updateScreenSplitPanel);
|
||||
|
||||
// 분할 패널 설정 삭제
|
||||
router.delete("/screen-split-panel/:id", authenticateToken, deleteScreenSplitPanel);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
createColumnMapping,
|
||||
getLogicalColumns,
|
||||
deleteColumnMapping,
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
|
@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
|
|||
// 컬럼 매핑 생성/수정
|
||||
router.post("/column-mapping", createColumnMapping);
|
||||
|
||||
// 컬럼 매핑 삭제
|
||||
// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
|
||||
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
|
||||
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
|
||||
|
||||
// 컬럼 매핑 삭제 (단일)
|
||||
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* 차량 운행 이력 및 리포트 라우트
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import {
|
||||
startTrip,
|
||||
endTrip,
|
||||
addTripLocation,
|
||||
getTripList,
|
||||
getTripDetail,
|
||||
getActiveTrip,
|
||||
cancelTrip,
|
||||
} from "../controllers/vehicleTripController";
|
||||
import {
|
||||
getDailyReport,
|
||||
getWeeklyReport,
|
||||
getMonthlyReport,
|
||||
getSummaryReport,
|
||||
getDriverReport,
|
||||
getRouteReport,
|
||||
} from "../controllers/vehicleReportController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// === 운행 관리 ===
|
||||
// 운행 시작
|
||||
router.post("/trip/start", startTrip);
|
||||
|
||||
// 운행 종료
|
||||
router.post("/trip/end", endTrip);
|
||||
|
||||
// 위치 기록 추가 (연속 추적)
|
||||
router.post("/trip/location", addTripLocation);
|
||||
|
||||
// 활성 운행 조회 (현재 진행 중)
|
||||
router.get("/trip/active", getActiveTrip);
|
||||
|
||||
// 운행 취소
|
||||
router.post("/trip/cancel", cancelTrip);
|
||||
|
||||
// 운행 이력 목록 조회
|
||||
router.get("/trips", getTripList);
|
||||
|
||||
// 운행 상세 조회 (경로 포함)
|
||||
router.get("/trips/:tripId", getTripDetail);
|
||||
|
||||
// === 리포트 ===
|
||||
// 요약 통계 (대시보드용)
|
||||
router.get("/reports/summary", getSummaryReport);
|
||||
|
||||
// 일별 통계
|
||||
router.get("/reports/daily", getDailyReport);
|
||||
|
||||
// 주별 통계
|
||||
router.get("/reports/weekly", getWeeklyReport);
|
||||
|
||||
// 월별 통계
|
||||
router.get("/reports/monthly", getMonthlyReport);
|
||||
|
||||
// 운전자별 통계
|
||||
router.get("/reports/by-driver", getDriverReport);
|
||||
|
||||
// 구간별 통계
|
||||
router.get("/reports/by-route", getRouteReport);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -178,21 +178,24 @@ export class DashboardService {
|
|||
let params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터링 (최우선)
|
||||
// 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
if (companyCode) {
|
||||
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 권한 필터링
|
||||
if (userId) {
|
||||
if (companyCode === '*') {
|
||||
// 최고 관리자는 모든 대시보드 조회 가능
|
||||
} else {
|
||||
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
whereConditions.push(
|
||||
`(d.created_by = $${paramIndex} OR d.is_public = true)`
|
||||
);
|
||||
params.push(userId);
|
||||
paramIndex++;
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
whereConditions.push("d.is_public = true");
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +231,7 @@ export class DashboardService {
|
|||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 대시보드 목록 조회 (users 테이블 조인 제거)
|
||||
// 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
|
||||
const dashboardQuery = `
|
||||
SELECT
|
||||
d.id,
|
||||
|
|
@ -242,13 +245,16 @@ export class DashboardService {
|
|||
d.tags,
|
||||
d.category,
|
||||
d.view_count,
|
||||
d.company_code,
|
||||
u.user_name as created_by_name,
|
||||
COUNT(de.id) as elements_count
|
||||
FROM dashboards d
|
||||
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
||||
LEFT JOIN user_info u ON d.created_by = u.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
||||
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
||||
d.view_count
|
||||
d.view_count, d.company_code, u.user_name
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
|
@ -277,12 +283,14 @@ export class DashboardService {
|
|||
thumbnailUrl: row.thumbnail_url,
|
||||
isPublic: row.is_public,
|
||||
createdBy: row.created_by,
|
||||
createdByName: row.created_by_name || row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
tags: JSON.parse(row.tags || "[]"),
|
||||
category: row.category,
|
||||
viewCount: parseInt(row.view_count || "0"),
|
||||
elementsCount: parseInt(row.elements_count || "0"),
|
||||
companyCode: row.company_code,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
|
|
@ -299,6 +307,8 @@ export class DashboardService {
|
|||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
* - company_code가 '*'인 경우 최고 관리자만 조회 가능
|
||||
*/
|
||||
static async getDashboardById(
|
||||
dashboardId: string,
|
||||
|
|
@ -310,44 +320,43 @@ export class DashboardService {
|
|||
let dashboardQuery: string;
|
||||
let dashboardParams: any[];
|
||||
|
||||
if (userId) {
|
||||
if (companyCode) {
|
||||
if (companyCode) {
|
||||
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
|
||||
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
|
||||
if (companyCode === '*') {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND (d.created_by = $3 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode, userId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
}
|
||||
} else {
|
||||
if (companyCode) {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
}
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
}
|
||||
|
||||
const dashboardResult = await PostgreSQLService.query(
|
||||
|
|
|
|||
|
|
@ -203,8 +203,7 @@ export class BatchExternalDbService {
|
|||
// 비밀번호 복호화
|
||||
if (connection.password) {
|
||||
try {
|
||||
const passwordEncryption = new PasswordEncryption();
|
||||
connection.password = passwordEncryption.decrypt(connection.password);
|
||||
connection.password = PasswordEncryption.decrypt(connection.password);
|
||||
} catch (error) {
|
||||
console.error("비밀번호 복호화 실패:", error);
|
||||
// 복호화 실패 시 원본 사용 (또는 에러 처리)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import cron from "node-cron";
|
||||
import cron, { ScheduledTask } from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
||||
|
||||
/**
|
||||
* 모든 활성 배치의 스케줄링 초기화
|
||||
|
|
@ -124,6 +124,14 @@ export class BatchSchedulerService {
|
|||
try {
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||
|
||||
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||
if (fullConfig.success && fullConfig.data) {
|
||||
config = fullConfig.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 실행 로그 생성
|
||||
const executionLogResponse =
|
||||
await BatchExecutionLogService.createExecutionLog({
|
||||
|
|
@ -175,7 +183,7 @@ export class BatchSchedulerService {
|
|||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILURE",
|
||||
execution_status: "FAILED",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
|
|
@ -396,4 +404,11 @@ export class BatchSchedulerService {
|
|||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링 (scheduleBatch의 별칭)
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
return this.scheduleBatch(config);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
import { BatchExternalDbService } from "./batchExternalDbService";
|
||||
import { DbConnectionManager } from "./dbConnectionManager";
|
||||
|
||||
export class BatchService {
|
||||
/**
|
||||
|
|
@ -475,7 +474,13 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 테이블 조회
|
||||
const tables = await DbConnectionManager.getInternalTables();
|
||||
const tables = await query<TableInfo>(
|
||||
`SELECT table_name, table_type, table_schema
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
|
|
@ -509,7 +514,13 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 컬럼 조회
|
||||
const columns = await DbConnectionManager.getInternalColumns(tableName);
|
||||
const columns = await query<ColumnInfo>(
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
|
|
@ -543,7 +554,9 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 데이터 조회
|
||||
const data = await DbConnectionManager.getInternalData(tableName, 10);
|
||||
const data = await query<any>(
|
||||
`SELECT * FROM ${tableName} LIMIT 10`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
|
||||
|
|
@ -746,7 +746,7 @@ export class DynamicFormService {
|
|||
* 폼 데이터 부분 업데이트 (변경된 필드만 업데이트)
|
||||
*/
|
||||
async updateFormDataPartial(
|
||||
id: number,
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
tableName: string,
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>
|
||||
|
|
@ -1635,6 +1635,287 @@ export class DynamicFormService {
|
|||
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 필드 값만 업데이트
|
||||
* (다른 테이블의 레코드 업데이트 지원)
|
||||
*/
|
||||
async updateFieldValue(
|
||||
tableName: string,
|
||||
keyField: string,
|
||||
keyValue: any,
|
||||
updateField: string,
|
||||
updateValue: any,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<{ affectedRows: number }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인)
|
||||
const columnQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
||||
`;
|
||||
const columnResult = await client.query(columnQuery, [tableName]);
|
||||
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
|
||||
|
||||
const hasUpdatedBy = existingColumns.includes('updated_by');
|
||||
const hasUpdatedAt = existingColumns.includes('updated_at');
|
||||
const hasCompanyCode = existingColumns.includes('company_code');
|
||||
|
||||
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
||||
hasUpdatedBy,
|
||||
hasUpdatedAt,
|
||||
hasCompanyCode,
|
||||
});
|
||||
|
||||
// 동적 SET 절 구성
|
||||
let setClause = `"${updateField}" = $1`;
|
||||
const params: any[] = [updateValue];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (hasUpdatedBy) {
|
||||
setClause += `, updated_by = $${paramIndex}`;
|
||||
params.push(userId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (hasUpdatedAt) {
|
||||
setClause += `, updated_at = NOW()`;
|
||||
}
|
||||
|
||||
// WHERE 절 구성
|
||||
let whereClause = `"${keyField}" = $${paramIndex}`;
|
||||
params.push(keyValue);
|
||||
paramIndex++;
|
||||
|
||||
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만)
|
||||
if (hasCompanyCode && companyCode && companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const sqlQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${setClause}
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
|
||||
console.log("🔍 [updateFieldValue] 파라미터:", params);
|
||||
|
||||
const result = await client.query(sqlQuery, params);
|
||||
|
||||
console.log("✅ [updateFieldValue] 결과:", {
|
||||
affectedRows: result.rowCount,
|
||||
});
|
||||
|
||||
return { affectedRows: result.rowCount || 0 };
|
||||
} catch (error) {
|
||||
console.error("❌ [updateFieldValue] 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 이력 저장 (연속 위치 추적용)
|
||||
*/
|
||||
async saveLocationHistory(data: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
heading?: number;
|
||||
tripId?: string;
|
||||
tripStatus?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departureName?: string;
|
||||
destinationName?: string;
|
||||
recordedAt?: string;
|
||||
vehicleId?: number;
|
||||
}): Promise<{ id: number }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("📍 [saveLocationHistory] 저장 시작:", data);
|
||||
|
||||
const sqlQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
user_id,
|
||||
company_code,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
trip_id,
|
||||
trip_status,
|
||||
departure,
|
||||
arrival,
|
||||
departure_name,
|
||||
destination_name,
|
||||
recorded_at,
|
||||
vehicle_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const params = [
|
||||
data.userId,
|
||||
data.companyCode,
|
||||
data.latitude,
|
||||
data.longitude,
|
||||
data.accuracy || null,
|
||||
data.altitude || null,
|
||||
data.speed || null,
|
||||
data.heading || null,
|
||||
data.tripId || null,
|
||||
data.tripStatus || "active",
|
||||
data.departure || null,
|
||||
data.arrival || null,
|
||||
data.departureName || null,
|
||||
data.destinationName || null,
|
||||
data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
||||
data.vehicleId || null,
|
||||
];
|
||||
|
||||
const result = await client.query(sqlQuery, params);
|
||||
|
||||
console.log("✅ [saveLocationHistory] 저장 완료:", {
|
||||
id: result.rows[0]?.id,
|
||||
});
|
||||
|
||||
return { id: result.rows[0]?.id };
|
||||
} catch (error) {
|
||||
console.error("❌ [saveLocationHistory] 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 이력 조회 (경로 조회용)
|
||||
*/
|
||||
async getLocationHistory(params: {
|
||||
companyCode: string;
|
||||
tripId?: string;
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}): Promise<any[]> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("📍 [getLocationHistory] 조회 시작:", params);
|
||||
|
||||
const conditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시: company_code 필터
|
||||
if (params.companyCode && params.companyCode !== "*") {
|
||||
conditions.push(`company_code = $${paramIndex}`);
|
||||
queryParams.push(params.companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// trip_id 필터
|
||||
if (params.tripId) {
|
||||
conditions.push(`trip_id = $${paramIndex}`);
|
||||
queryParams.push(params.tripId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// user_id 필터
|
||||
if (params.userId) {
|
||||
conditions.push(`user_id = $${paramIndex}`);
|
||||
queryParams.push(params.userId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (params.startDate) {
|
||||
conditions.push(`recorded_at >= $${paramIndex}`);
|
||||
queryParams.push(new Date(params.startDate));
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (params.endDate) {
|
||||
conditions.push(`recorded_at <= $${paramIndex}`);
|
||||
queryParams.push(new Date(params.endDate));
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
||||
|
||||
const sqlQuery = `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
vehicle_id,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
trip_id,
|
||||
trip_status,
|
||||
departure,
|
||||
arrival,
|
||||
departure_name,
|
||||
destination_name,
|
||||
recorded_at,
|
||||
created_at,
|
||||
company_code
|
||||
FROM vehicle_location_history
|
||||
${whereClause}
|
||||
ORDER BY recorded_at ASC
|
||||
${limitClause}
|
||||
`;
|
||||
|
||||
console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery);
|
||||
console.log("🔍 [getLocationHistory] 파라미터:", queryParams);
|
||||
|
||||
const result = await client.query(sqlQuery, queryParams);
|
||||
|
||||
console.log("✅ [getLocationHistory] 조회 완료:", {
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
console.error("❌ [getLocationHistory] 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
|
|
|
|||
|
|
@ -474,6 +474,105 @@ export class ExternalRestApiConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 헤더 생성
|
||||
*/
|
||||
static async getAuthHeaders(
|
||||
authType: AuthType,
|
||||
authConfig: any,
|
||||
companyCode?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (authType === "db-token") {
|
||||
const cfg = authConfig || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined &&
|
||||
dbWhereValue !== null &&
|
||||
dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 식별자 검증 (간단한 화이트리스트)
|
||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!identifierRegex.test(dbTableName) ||
|
||||
!identifierRegex.test(dbValueColumn) ||
|
||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||
) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||
);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT ${dbValueColumn} AS token_value
|
||||
FROM ${dbTableName}
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [companyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
params.push(dbWhereValue);
|
||||
}
|
||||
|
||||
sql += `
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||
|
||||
if (tokenResult.rowCount === 0) {
|
||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||
const headerName = dbHeaderName || "Authorization";
|
||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (authType === "bearer" && authConfig?.token) {
|
||||
headers["Authorization"] = `Bearer ${authConfig.token}`;
|
||||
} else if (authType === "basic" && authConfig) {
|
||||
const credentials = Buffer.from(
|
||||
`${authConfig.username}:${authConfig.password}`
|
||||
).toString("base64");
|
||||
headers["Authorization"] = `Basic ${credentials}`;
|
||||
} else if (authType === "api-key" && authConfig) {
|
||||
if (authConfig.keyLocation === "header") {
|
||||
headers[authConfig.keyName] = authConfig.keyValue;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
|
|
@ -485,99 +584,15 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
try {
|
||||
// 헤더 구성
|
||||
const headers = { ...testRequest.headers };
|
||||
let headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 식별자 검증 (간단한 화이트리스트)
|
||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!identifierRegex.test(dbTableName) ||
|
||||
!identifierRegex.test(dbValueColumn) ||
|
||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||
) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||
);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT ${dbValueColumn} AS token_value
|
||||
FROM ${dbTableName}
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [userCompanyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
params.push(dbWhereValue);
|
||||
}
|
||||
|
||||
sql += `
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||
|
||||
if (tokenResult.rowCount === 0) {
|
||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||
const headerName = dbHeaderName || "Authorization";
|
||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
|
||||
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
|
||||
const credentials = Buffer.from(
|
||||
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
|
||||
).toString("base64");
|
||||
headers["Authorization"] = `Basic ${credentials}`;
|
||||
} else if (
|
||||
testRequest.auth_type === "api-key" &&
|
||||
testRequest.auth_config
|
||||
) {
|
||||
if (testRequest.auth_config.keyLocation === "header") {
|
||||
headers[testRequest.auth_config.keyName] =
|
||||
testRequest.auth_config.keyValue;
|
||||
}
|
||||
}
|
||||
// 인증 헤더 생성 및 병합
|
||||
const authHeaders = await this.getAuthHeaders(
|
||||
testRequest.auth_type,
|
||||
testRequest.auth_config,
|
||||
userCompanyCode
|
||||
);
|
||||
headers = { ...headers, ...authHeaders };
|
||||
|
||||
// URL 구성
|
||||
let url = testRequest.base_url;
|
||||
|
|
|
|||
|
|
@ -27,13 +27,20 @@ export class FlowDefinitionService {
|
|||
tableName: request.tableName,
|
||||
dbSourceType: request.dbSourceType,
|
||||
dbConnectionId: request.dbConnectionId,
|
||||
restApiConnectionId: request.restApiConnectionId,
|
||||
restApiEndpoint: request.restApiEndpoint,
|
||||
restApiJsonPath: request.restApiJsonPath,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const query = `
|
||||
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO flow_definition (
|
||||
name, description, table_name, db_source_type, db_connection_id,
|
||||
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
|
||||
company_code, created_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -43,6 +50,9 @@ export class FlowDefinitionService {
|
|||
request.tableName || null,
|
||||
request.dbSourceType || "internal",
|
||||
request.dbConnectionId || null,
|
||||
request.restApiConnectionId || null,
|
||||
request.restApiEndpoint || null,
|
||||
request.restApiJsonPath || "data",
|
||||
companyCode,
|
||||
userId,
|
||||
];
|
||||
|
|
@ -206,6 +216,10 @@ export class FlowDefinitionService {
|
|||
tableName: row.table_name,
|
||||
dbSourceType: row.db_source_type || "internal",
|
||||
dbConnectionId: row.db_connection_id,
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId: row.rest_api_connection_id,
|
||||
restApiEndpoint: row.rest_api_endpoint,
|
||||
restApiJsonPath: row.rest_api_json_path,
|
||||
companyCode: row.company_code || "*",
|
||||
isActive: row.is_active,
|
||||
createdBy: row.created_by,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@ export interface MenuCopyResult {
|
|||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
copiedCategorySettings: number;
|
||||
copiedNumberingRules: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
@ -129,35 +125,6 @@ interface FlowStepConnection {
|
|||
label: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리
|
||||
*/
|
||||
interface CodeCategory {
|
||||
category_code: string;
|
||||
category_name: string;
|
||||
category_name_eng: string | null;
|
||||
description: string | null;
|
||||
sort_order: number | null;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 정보
|
||||
*/
|
||||
interface CodeInfo {
|
||||
code_category: string;
|
||||
code_value: string;
|
||||
code_name: string;
|
||||
code_name_eng: string | null;
|
||||
description: string | null;
|
||||
sort_order: number | null;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 복사 서비스
|
||||
*/
|
||||
|
|
@ -249,6 +216,24 @@ export class MenuCopyService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 탭 컴포넌트 (tabs 배열 내부의 screenId)
|
||||
if (
|
||||
props?.componentConfig?.tabs &&
|
||||
Array.isArray(props.componentConfig.tabs)
|
||||
) {
|
||||
for (const tab of props.componentConfig.tabs) {
|
||||
if (tab.screenId) {
|
||||
const screenId = tab.screenId;
|
||||
const numId =
|
||||
typeof screenId === "number" ? screenId : parseInt(screenId);
|
||||
if (!isNaN(numId)) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return referenced;
|
||||
|
|
@ -355,127 +340,6 @@ export class MenuCopyService {
|
|||
return flowIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 수집
|
||||
*/
|
||||
private async collectCodes(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> {
|
||||
logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const categories: CodeCategory[] = [];
|
||||
const codes: CodeInfo[] = [];
|
||||
|
||||
for (const menuObjid of menuObjids) {
|
||||
// 코드 카테고리
|
||||
const catsResult = await client.query<CodeCategory>(
|
||||
`SELECT * FROM code_category
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[menuObjid, sourceCompanyCode]
|
||||
);
|
||||
categories.push(...catsResult.rows);
|
||||
|
||||
// 각 카테고리의 코드 정보
|
||||
for (const cat of catsResult.rows) {
|
||||
const codesResult = await client.query<CodeInfo>(
|
||||
`SELECT * FROM code_info
|
||||
WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`,
|
||||
[cat.category_code, menuObjid, sourceCompanyCode]
|
||||
);
|
||||
codes.push(...codesResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개`
|
||||
);
|
||||
return { categories, codes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 수집
|
||||
*/
|
||||
private async collectCategorySettings(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{
|
||||
columnMappings: any[];
|
||||
categoryValues: any[];
|
||||
}> {
|
||||
logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const columnMappings: any[] = [];
|
||||
const categoryValues: any[] = [];
|
||||
|
||||
// 카테고리 컬럼 매핑 (메뉴별 + 공통)
|
||||
const mappingsResult = await client.query(
|
||||
`SELECT * FROM category_column_mapping
|
||||
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
|
||||
AND company_code = $2`,
|
||||
[menuObjids, sourceCompanyCode]
|
||||
);
|
||||
columnMappings.push(...mappingsResult.rows);
|
||||
|
||||
// 테이블 컬럼 카테고리 값 (메뉴별 + 공통)
|
||||
const valuesResult = await client.query(
|
||||
`SELECT * FROM table_column_category_values
|
||||
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
|
||||
AND company_code = $2`,
|
||||
[menuObjids, sourceCompanyCode]
|
||||
);
|
||||
categoryValues.push(...valuesResult.rows);
|
||||
|
||||
logger.info(
|
||||
`✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)`
|
||||
);
|
||||
return { columnMappings, categoryValues };
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 수집
|
||||
*/
|
||||
private async collectNumberingRules(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{
|
||||
rules: any[];
|
||||
parts: any[];
|
||||
}> {
|
||||
logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const rules: any[] = [];
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const menuObjid of menuObjids) {
|
||||
// 채번 규칙
|
||||
const rulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[menuObjid, sourceCompanyCode]
|
||||
);
|
||||
rules.push(...rulesResult.rows);
|
||||
|
||||
// 각 규칙의 파트
|
||||
for (const rule of rulesResult.rows) {
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND company_code = $2`,
|
||||
[rule.rule_id, sourceCompanyCode]
|
||||
);
|
||||
parts.push(...partsResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개`
|
||||
);
|
||||
return { rules, parts };
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 메뉴 objid 생성
|
||||
*/
|
||||
|
|
@ -709,42 +573,8 @@ export class MenuCopyService {
|
|||
]);
|
||||
logger.info(` ✅ 메뉴 권한 삭제 완료`);
|
||||
|
||||
// 5-5. 채번 규칙 파트 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rule_parts
|
||||
WHERE rule_id IN (
|
||||
SELECT rule_id FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2
|
||||
)`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 채번 규칙 파트 삭제 완료`);
|
||||
|
||||
// 5-6. 채번 규칙 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 채번 규칙 삭제 완료`);
|
||||
|
||||
// 5-7. 테이블 컬럼 카테고리 값 삭제
|
||||
await client.query(
|
||||
`DELETE FROM table_column_category_values
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 카테고리 값 삭제 완료`);
|
||||
|
||||
// 5-8. 카테고리 컬럼 매핑 삭제
|
||||
await client.query(
|
||||
`DELETE FROM category_column_mapping
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 카테고리 매핑 삭제 완료`);
|
||||
|
||||
// 5-9. 메뉴 삭제 (역순: 하위 메뉴부터)
|
||||
// 5-5. 메뉴 삭제 (역순: 하위 메뉴부터)
|
||||
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
|
||||
for (let i = existingMenus.length - 1; i >= 0; i--) {
|
||||
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
|
||||
existingMenus[i].objid,
|
||||
|
|
@ -801,33 +631,11 @@ export class MenuCopyService {
|
|||
|
||||
const flowIds = await this.collectFlows(screenIds, client);
|
||||
|
||||
const codes = await this.collectCodes(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const categorySettings = await this.collectCategorySettings(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const numberingRules = await this.collectNumberingRules(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
logger.info(`
|
||||
📊 수집 완료:
|
||||
- 메뉴: ${menus.length}개
|
||||
- 화면: ${screenIds.size}개
|
||||
- 플로우: ${flowIds.size}개
|
||||
- 코드 카테고리: ${codes.categories.length}개
|
||||
- 코드: ${codes.codes.length}개
|
||||
- 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개
|
||||
- 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개
|
||||
`);
|
||||
|
||||
// === 2단계: 플로우 복사 ===
|
||||
|
|
@ -871,30 +679,6 @@ export class MenuCopyService {
|
|||
client
|
||||
);
|
||||
|
||||
// === 6단계: 코드 복사 ===
|
||||
logger.info("\n📋 [6단계] 코드 복사");
|
||||
await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client);
|
||||
|
||||
// === 7단계: 카테고리 설정 복사 ===
|
||||
logger.info("\n📂 [7단계] 카테고리 설정 복사");
|
||||
await this.copyCategorySettings(
|
||||
categorySettings,
|
||||
menuIdMap,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
// === 8단계: 채번 규칙 복사 ===
|
||||
logger.info("\n📋 [8단계] 채번 규칙 복사");
|
||||
await this.copyNumberingRules(
|
||||
numberingRules,
|
||||
menuIdMap,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
// 커밋
|
||||
await client.query("COMMIT");
|
||||
logger.info("✅ 트랜잭션 커밋 완료");
|
||||
|
|
@ -904,13 +688,6 @@ export class MenuCopyService {
|
|||
copiedMenus: menuIdMap.size,
|
||||
copiedScreens: screenIdMap.size,
|
||||
copiedFlows: flowIdMap.size,
|
||||
copiedCategories: codes.categories.length,
|
||||
copiedCodes: codes.codes.length,
|
||||
copiedCategorySettings:
|
||||
categorySettings.columnMappings.length +
|
||||
categorySettings.categoryValues.length,
|
||||
copiedNumberingRules:
|
||||
numberingRules.rules.length + numberingRules.parts.length,
|
||||
menuIdMap: Object.fromEntries(menuIdMap),
|
||||
screenIdMap: Object.fromEntries(screenIdMap),
|
||||
flowIdMap: Object.fromEntries(flowIdMap),
|
||||
|
|
@ -923,10 +700,8 @@ export class MenuCopyService {
|
|||
- 메뉴: ${result.copiedMenus}개
|
||||
- 화면: ${result.copiedScreens}개
|
||||
- 플로우: ${result.copiedFlows}개
|
||||
- 코드 카테고리: ${result.copiedCategories}개
|
||||
- 코드: ${result.copiedCodes}개
|
||||
- 카테고리 설정: ${result.copiedCategorySettings}개
|
||||
- 채번 규칙: ${result.copiedNumberingRules}개
|
||||
|
||||
⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다.
|
||||
============================================
|
||||
`);
|
||||
|
||||
|
|
@ -1125,13 +900,31 @@ export class MenuCopyService {
|
|||
|
||||
const screenDef = screenDefResult.rows[0];
|
||||
|
||||
// 2) 새 screen_code 생성
|
||||
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
||||
const existingScreenResult = await client.query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
LIMIT 1`,
|
||||
[screenDef.screen_code, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (existingScreenResult.rows.length > 0) {
|
||||
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
|
||||
const existingScreenId = existingScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})`
|
||||
);
|
||||
continue; // 레이아웃 복사도 스킵
|
||||
}
|
||||
|
||||
// 3) 새 screen_code 생성
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// 2-1) 화면명 변환 적용
|
||||
// 4) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
// 1. 제거할 텍스트 제거
|
||||
|
|
@ -1150,7 +943,7 @@ export class MenuCopyService {
|
|||
}
|
||||
}
|
||||
|
||||
// 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code,
|
||||
|
|
@ -1479,383 +1272,4 @@ export class MenuCopyService {
|
|||
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리 중복 체크
|
||||
*/
|
||||
private async checkCodeCategoryExists(
|
||||
categoryCode: string,
|
||||
companyCode: string,
|
||||
menuObjid: number,
|
||||
client: PoolClient
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM code_category
|
||||
WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3
|
||||
) as exists`,
|
||||
[categoryCode, companyCode, menuObjid]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 정보 중복 체크
|
||||
*/
|
||||
private async checkCodeInfoExists(
|
||||
categoryCode: string,
|
||||
codeValue: string,
|
||||
companyCode: string,
|
||||
menuObjid: number,
|
||||
client: PoolClient
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM code_info
|
||||
WHERE code_category = $1 AND code_value = $2
|
||||
AND company_code = $3 AND menu_objid = $4
|
||||
) as exists`,
|
||||
[categoryCode, codeValue, companyCode, menuObjid]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 복사
|
||||
*/
|
||||
private async copyCodes(
|
||||
codes: { categories: CodeCategory[]; codes: CodeInfo[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📋 코드 복사 중...`);
|
||||
|
||||
let categoryCount = 0;
|
||||
let codeCount = 0;
|
||||
let skippedCategories = 0;
|
||||
let skippedCodes = 0;
|
||||
|
||||
// 1) 코드 카테고리 복사 (중복 체크)
|
||||
for (const category of codes.categories) {
|
||||
const newMenuObjid = menuIdMap.get(category.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 중복 체크
|
||||
const exists = await this.checkCodeCategoryExists(
|
||||
category.category_code,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
client
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
skippedCategories++;
|
||||
logger.debug(
|
||||
` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 카테고리 복사
|
||||
await client.query(
|
||||
`INSERT INTO code_category (
|
||||
category_code, category_name, category_name_eng, description,
|
||||
sort_order, is_active, company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
category.category_code,
|
||||
category.category_name,
|
||||
category.category_name_eng,
|
||||
category.description,
|
||||
category.sort_order,
|
||||
category.is_active,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
newMenuObjid, // 재매핑
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
categoryCount++;
|
||||
}
|
||||
|
||||
// 2) 코드 정보 복사 (중복 체크)
|
||||
for (const code of codes.codes) {
|
||||
const newMenuObjid = menuIdMap.get(code.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 중복 체크
|
||||
const exists = await this.checkCodeInfoExists(
|
||||
code.code_category,
|
||||
code.code_value,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
client
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
skippedCodes++;
|
||||
logger.debug(
|
||||
` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 코드 복사
|
||||
await client.query(
|
||||
`INSERT INTO code_info (
|
||||
code_category, code_value, code_name, code_name_eng, description,
|
||||
sort_order, is_active, company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
code.code_category,
|
||||
code.code_value,
|
||||
code.code_name,
|
||||
code.code_name_eng,
|
||||
code.description,
|
||||
code.sort_order,
|
||||
code.is_active,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
newMenuObjid, // 재매핑
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
codeCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 복사
|
||||
*/
|
||||
private async copyCategorySettings(
|
||||
settings: { columnMappings: any[]; categoryValues: any[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📂 카테고리 설정 복사 중...`);
|
||||
|
||||
const valueIdMap = new Map<number, number>(); // 원본 value_id → 새 value_id
|
||||
let mappingCount = 0;
|
||||
let valueCount = 0;
|
||||
|
||||
// 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드)
|
||||
for (const mapping of settings.columnMappings) {
|
||||
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
|
||||
let newMenuObjid: number | undefined;
|
||||
|
||||
if (
|
||||
mapping.menu_objid === 0 ||
|
||||
mapping.menu_objid === "0" ||
|
||||
mapping.menu_objid == 0
|
||||
) {
|
||||
newMenuObjid = 0; // 공통 설정
|
||||
} else {
|
||||
newMenuObjid = menuIdMap.get(mapping.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.debug(
|
||||
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 매핑 삭제 (덮어쓰기)
|
||||
await client.query(
|
||||
`DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`,
|
||||
[mapping.table_name, mapping.physical_column_name, targetCompanyCode]
|
||||
);
|
||||
|
||||
// 새 매핑 추가
|
||||
await client.query(
|
||||
`INSERT INTO category_column_mapping (
|
||||
table_name, logical_column_name, physical_column_name,
|
||||
menu_objid, company_code, description, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
mapping.table_name,
|
||||
mapping.logical_column_name,
|
||||
mapping.physical_column_name,
|
||||
newMenuObjid,
|
||||
targetCompanyCode,
|
||||
mapping.description,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
mappingCount++;
|
||||
}
|
||||
|
||||
// 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지)
|
||||
const sortedValues = settings.categoryValues.sort(
|
||||
(a, b) => a.depth - b.depth
|
||||
);
|
||||
|
||||
// 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위)
|
||||
const uniqueTableColumns = new Set<string>();
|
||||
for (const value of sortedValues) {
|
||||
uniqueTableColumns.add(`${value.table_name}:${value.column_name}`);
|
||||
}
|
||||
|
||||
for (const tableColumn of uniqueTableColumns) {
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
await client.query(
|
||||
`DELETE FROM table_column_category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3`,
|
||||
[tableName, columnName, targetCompanyCode]
|
||||
);
|
||||
logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`);
|
||||
}
|
||||
|
||||
// 새 값 추가
|
||||
for (const value of sortedValues) {
|
||||
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
|
||||
let newMenuObjid: number | undefined;
|
||||
|
||||
if (
|
||||
value.menu_objid === 0 ||
|
||||
value.menu_objid === "0" ||
|
||||
value.menu_objid == 0
|
||||
) {
|
||||
newMenuObjid = 0; // 공통 설정
|
||||
} else {
|
||||
newMenuObjid = menuIdMap.get(value.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.debug(
|
||||
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 재매핑
|
||||
let newParentValueId = null;
|
||||
if (value.parent_value_id) {
|
||||
newParentValueId = valueIdMap.get(value.parent_value_id) || null;
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO table_column_category_values (
|
||||
table_name, column_name, value_code, value_label,
|
||||
value_order, parent_value_id, depth, description,
|
||||
color, icon, is_active, is_default,
|
||||
company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING value_id`,
|
||||
[
|
||||
value.table_name,
|
||||
value.column_name,
|
||||
value.value_code,
|
||||
value.value_label,
|
||||
value.value_order,
|
||||
newParentValueId,
|
||||
value.depth,
|
||||
value.description,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.is_active,
|
||||
value.is_default,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
// ID 매핑 저장
|
||||
const newValueId = result.rows[0].value_id;
|
||||
valueIdMap.set(value.value_id, newValueId);
|
||||
|
||||
valueCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 복사
|
||||
*/
|
||||
private async copyNumberingRules(
|
||||
rules: { rules: any[]; parts: any[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📋 채번 규칙 복사 중...`);
|
||||
|
||||
const ruleIdMap = new Map<string, string>(); // 원본 rule_id → 새 rule_id
|
||||
let ruleCount = 0;
|
||||
let partCount = 0;
|
||||
|
||||
// 1) 채번 규칙 복사
|
||||
for (const rule of rules.rules) {
|
||||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 새 rule_id 생성 (타임스탬프 기반)
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator,
|
||||
reset_period, current_sequence, table_name, column_name,
|
||||
company_code, menu_objid, created_by, scope_type
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
newRuleId,
|
||||
rule.rule_name,
|
||||
rule.description,
|
||||
rule.separator,
|
||||
rule.reset_period,
|
||||
1, // 시퀀스 초기화
|
||||
rule.table_name,
|
||||
rule.column_name,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
userId,
|
||||
rule.scope_type,
|
||||
]
|
||||
);
|
||||
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
// 2) 채번 규칙 파트 복사
|
||||
for (const part of rules.parts) {
|
||||
const newRuleId = ruleIdMap.get(part.rule_id);
|
||||
if (!newRuleId) continue;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
newRuleId,
|
||||
part.part_order,
|
||||
part.part_type,
|
||||
part.generation_method,
|
||||
part.auto_config,
|
||||
part.manual_config,
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
|
||||
partCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회
|
||||
*
|
||||
* 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다.
|
||||
* 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다.
|
||||
*
|
||||
* @param menuObjid 메뉴 OBJID
|
||||
* @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적)
|
||||
*
|
||||
* @example
|
||||
* // 메뉴 구조:
|
||||
* // └── 구매관리 (100)
|
||||
* // ├── 공급업체관리 (101)
|
||||
* // ├── 발주관리 (102)
|
||||
* // └── 입고관리 (103)
|
||||
* // └── 입고상세 (104)
|
||||
*
|
||||
* await getMenuAndChildObjids(100);
|
||||
* // 결과: [100, 101, 102, 103, 104]
|
||||
*/
|
||||
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
|
||||
|
||||
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
|
||||
const query = `
|
||||
WITH RECURSIVE menu_tree AS (
|
||||
-- 시작점: 선택한 메뉴
|
||||
SELECT objid, parent_obj_id, 1 AS depth
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 재귀: 하위 메뉴들
|
||||
SELECT m.objid, m.parent_obj_id, mt.depth + 1
|
||||
FROM menu_info m
|
||||
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
|
||||
WHERE mt.depth < 10 -- 무한 루프 방지
|
||||
)
|
||||
SELECT objid FROM menu_tree ORDER BY depth, objid
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [menuObjid]);
|
||||
const objids = result.rows.map((row) => Number(row.objid));
|
||||
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
|
||||
menuObjid,
|
||||
totalCount: objids.length,
|
||||
objids
|
||||
});
|
||||
|
||||
return objids;
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴 및 하위 메뉴 조회 실패", {
|
||||
menuObjid,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
// 에러 발생 시 안전하게 자기 자신만 반환
|
||||
return [menuObjid];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getSiblingMenuObjids } from "./menuService";
|
||||
import { getMenuAndChildObjids } from "./menuService";
|
||||
|
||||
interface NumberingRulePart {
|
||||
id?: number;
|
||||
|
|
@ -161,7 +161,7 @@ class NumberingRuleService {
|
|||
companyCode: string,
|
||||
menuObjid?: number
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
|
||||
try {
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
|
||||
|
|
@ -171,14 +171,14 @@ class NumberingRuleService {
|
|||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 형제 메뉴 OBJID 조회
|
||||
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
|
||||
if (menuObjid) {
|
||||
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
|
||||
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 global 규칙만 반환
|
||||
if (!menuObjid || siblingObjids.length === 0) {
|
||||
if (!menuObjid || menuAndChildObjids.length === 0) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ class NumberingRuleService {
|
|||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
|
||||
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -301,8 +301,7 @@ class NumberingRuleService {
|
|||
WHERE
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
|
|
@ -311,10 +310,10 @@ class NumberingRuleService {
|
|||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
params = [menuAndChildObjids];
|
||||
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -336,8 +335,7 @@ class NumberingRuleService {
|
|||
AND (
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2))
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -347,8 +345,8 @@ class NumberingRuleService {
|
|||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
params = [companyCode, menuAndChildObjids];
|
||||
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
|
|
@ -420,7 +418,7 @@ class NumberingRuleService {
|
|||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingCount: siblingObjids.length,
|
||||
menuAndChildCount: menuAndChildObjids.length,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
|
|
@ -432,7 +430,7 @@ class NumberingRuleService {
|
|||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingObjids: siblingObjids || [],
|
||||
menuAndChildObjids: menuAndChildObjids || [],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,24 @@ export class RiskAlertService {
|
|||
|
||||
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
||||
|
||||
// 텍스트 응답 파싱 (EUC-KR 인코딩)
|
||||
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
||||
const iconv = require('iconv-lite');
|
||||
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
|
||||
const buffer = Buffer.from(warningResponse.data);
|
||||
|
||||
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
|
||||
let responseText: string;
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
responseText = utf8Text;
|
||||
console.log('📝 UTF-8 인코딩으로 디코딩');
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
responseText = iconv.decode(buffer, 'EUC-KR');
|
||||
console.log('📝 EUC-KR 인코딩으로 디코딩');
|
||||
}
|
||||
|
||||
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
||||
const lines = responseText.split('\n');
|
||||
|
|
|
|||
|
|
@ -326,7 +326,19 @@ export class ScreenManagementService {
|
|||
*/
|
||||
async updateScreenInfo(
|
||||
screenId: number,
|
||||
updateData: { screenName: string; tableName?: string; description?: string; isActive: string },
|
||||
updateData: {
|
||||
screenName: string;
|
||||
tableName?: string;
|
||||
description?: string;
|
||||
isActive: string;
|
||||
// REST API 관련 필드 추가
|
||||
dataSourceType?: string;
|
||||
dbSourceType?: string;
|
||||
dbConnectionId?: number;
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
},
|
||||
userCompanyCode: string
|
||||
): Promise<void> {
|
||||
// 권한 확인
|
||||
|
|
@ -348,24 +360,43 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 화면 정보 업데이트 (tableName 포함)
|
||||
// 화면 정보 업데이트 (REST API 필드 포함)
|
||||
await query(
|
||||
`UPDATE screen_definitions
|
||||
SET screen_name = $1,
|
||||
table_name = $2,
|
||||
description = $3,
|
||||
is_active = $4,
|
||||
updated_date = $5
|
||||
WHERE screen_id = $6`,
|
||||
updated_date = $5,
|
||||
data_source_type = $6,
|
||||
db_source_type = $7,
|
||||
db_connection_id = $8,
|
||||
rest_api_connection_id = $9,
|
||||
rest_api_endpoint = $10,
|
||||
rest_api_json_path = $11
|
||||
WHERE screen_id = $12`,
|
||||
[
|
||||
updateData.screenName,
|
||||
updateData.tableName || null,
|
||||
updateData.description || null,
|
||||
updateData.isActive,
|
||||
new Date(),
|
||||
updateData.dataSourceType || "database",
|
||||
updateData.dbSourceType || "internal",
|
||||
updateData.dbConnectionId || null,
|
||||
updateData.restApiConnectionId || null,
|
||||
updateData.restApiEndpoint || null,
|
||||
updateData.restApiJsonPath || null,
|
||||
screenId,
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
|
||||
dataSourceType: updateData.dataSourceType,
|
||||
restApiConnectionId: updateData.restApiConnectionId,
|
||||
restApiEndpoint: updateData.restApiEndpoint,
|
||||
restApiJsonPath: updateData.restApiJsonPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2016,37 +2047,40 @@ export class ScreenManagementService {
|
|||
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||
|
||||
// 해당 회사의 기존 화면 코드들 조회
|
||||
// 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
|
||||
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
|
||||
const existingScreens = await client.query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions
|
||||
WHERE company_code = $1 AND screen_code LIKE $2
|
||||
ORDER BY screen_code DESC
|
||||
LIMIT 10`,
|
||||
[companyCode, `${companyCode}%`]
|
||||
WHERE screen_code LIKE $1
|
||||
ORDER BY screen_code DESC`,
|
||||
[`${companyCode}_%`]
|
||||
);
|
||||
|
||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`
|
||||
);
|
||||
|
||||
console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`);
|
||||
console.log(`🔍 패턴: ${pattern}`);
|
||||
|
||||
for (const screen of existingScreens.rows) {
|
||||
const match = screen.screen_code.match(pattern);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||
// 다음 순번으로 화면 코드 생성
|
||||
const nextNumber = maxNumber + 1;
|
||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||
|
||||
const newCode = `${companyCode}_${paddedNumber}`;
|
||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`);
|
||||
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
|
||||
const newCode = `${companyCode}_${nextNumber}`;
|
||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
|
||||
|
||||
return newCode;
|
||||
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
||||
|
|
|
|||
|
|
@ -1066,6 +1066,66 @@ class TableCategoryValueService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param columnName - 컬럼명
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns 삭제된 매핑 수
|
||||
*/
|
||||
async deleteColumnMappingsByColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode: string
|
||||
): Promise<number> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode });
|
||||
|
||||
// 멀티테넌시 적용
|
||||
let deleteQuery: string;
|
||||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
`;
|
||||
deleteParams = [tableName, columnName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 매핑만 삭제
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
deleteParams = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(deleteQuery, deleteParams);
|
||||
const deletedCount = result.rowCount || 0;
|
||||
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
deletedCount
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
} catch (error: any) {
|
||||
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리적 컬럼명을 물리적 컬럼명으로 변환
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1165,12 +1165,26 @@ export class TableManagementService {
|
|||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
|
||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||
if (typeof value === "string" && value.includes("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
|
||||
// 날짜 타입이면 날짜 범위로 처리
|
||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||
}
|
||||
|
||||
// 그 외 타입이면 다중선택(IN 조건)으로 처리
|
||||
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
|
||||
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
|
||||
return {
|
||||
whereClause: `${columnName}::text IN (${placeholders})`,
|
||||
values: multiValues,
|
||||
paramCount: multiValues.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 날짜 범위 객체 {from, to} 체크
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
/**
|
||||
* 차량 운행 리포트 서비스
|
||||
*/
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
interface DailyReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface WeeklyReportFilters {
|
||||
year: number;
|
||||
month: number;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface MonthlyReportFilters {
|
||||
year: number;
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
}
|
||||
|
||||
interface DriverReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface RouteReportFilters {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class VehicleReportService {
|
||||
private get pool() {
|
||||
return getPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
*/
|
||||
async getDailyReport(companyCode: string, filters: DailyReportFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 기본값: 최근 30일
|
||||
const endDate = filters.endDate || new Date().toISOString().split("T")[0];
|
||||
const startDate =
|
||||
filters.startDate ||
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
|
||||
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||
params.push(startDate);
|
||||
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||
params.push(endDate);
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
|
||||
if (filters.vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(filters.vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
DATE(start_time) as date,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY DATE(start_time)
|
||||
ORDER BY DATE(start_time) DESC
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
data: result.rows.map((row) => ({
|
||||
date: row.date,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
cancelledCount: parseInt(row.cancelled_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
*/
|
||||
async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) {
|
||||
const { year, month, userId, vehicleId } = filters;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||
params.push(year);
|
||||
conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`);
|
||||
params.push(month);
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
EXTRACT(WEEK FROM start_time) as week_number,
|
||||
MIN(DATE(start_time)) as week_start,
|
||||
MAX(DATE(start_time)) as week_end,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY EXTRACT(WEEK FROM start_time)
|
||||
ORDER BY week_number
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
data: result.rows.map((row) => ({
|
||||
weekNumber: parseInt(row.week_number),
|
||||
weekStart: row.week_start,
|
||||
weekEnd: row.week_end,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) {
|
||||
const { year, userId, vehicleId } = filters;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||
params.push(year);
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(vehicleId);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM start_time) as month,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||
COUNT(DISTINCT user_id) as driver_count
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY EXTRACT(MONTH FROM start_time)
|
||||
ORDER BY month
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return {
|
||||
year,
|
||||
data: result.rows.map((row) => ({
|
||||
month: parseInt(row.month),
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
cancelledCount: parseInt(row.cancelled_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
driverCount: parseInt(row.driver_count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
*/
|
||||
async getSummaryReport(companyCode: string, period: string) {
|
||||
let dateCondition = "";
|
||||
|
||||
switch (period) {
|
||||
case "today":
|
||||
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||
break;
|
||||
case "week":
|
||||
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'";
|
||||
break;
|
||||
case "month":
|
||||
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'";
|
||||
break;
|
||||
case "year":
|
||||
dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)";
|
||||
break;
|
||||
default:
|
||||
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_trips,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips,
|
||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||
COUNT(DISTINCT user_id) as active_drivers
|
||||
FROM vehicle_trip_summary
|
||||
WHERE company_code = $1 AND ${dateCondition}
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, [companyCode]);
|
||||
const row = result.rows[0];
|
||||
|
||||
// 완료율 계산
|
||||
const totalTrips = parseInt(row.total_trips) || 0;
|
||||
const completedTrips = parseInt(row.completed_trips) || 0;
|
||||
const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0;
|
||||
|
||||
return {
|
||||
period,
|
||||
totalTrips,
|
||||
completedTrips,
|
||||
activeTrips: parseInt(row.active_trips) || 0,
|
||||
cancelledTrips: parseInt(row.cancelled_trips) || 0,
|
||||
completionRate: parseFloat(completionRate.toFixed(1)),
|
||||
totalDistance: parseFloat(row.total_distance) || 0,
|
||||
totalDuration: parseInt(row.total_duration) || 0,
|
||||
avgDistance: parseFloat(row.avg_distance) || 0,
|
||||
avgDuration: parseFloat(row.avg_duration) || 0,
|
||||
activeDrivers: parseInt(row.active_drivers) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
*/
|
||||
async getDriverReport(companyCode: string, filters: DriverReportFilters) {
|
||||
const conditions: string[] = ["vts.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`);
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
vts.user_id,
|
||||
ui.user_name,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration,
|
||||
COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY vts.user_id, ui.user_name
|
||||
ORDER BY total_distance DESC
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
userId: row.user_id,
|
||||
userName: row.user_name || row.user_id,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
totalDuration: parseInt(row.total_duration),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
*/
|
||||
async getRouteReport(companyCode: string, filters: RouteReportFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
// 출발지/도착지가 있는 것만
|
||||
conditions.push("departure IS NOT NULL");
|
||||
conditions.push("arrival IS NOT NULL");
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
departure,
|
||||
arrival,
|
||||
departure_name,
|
||||
destination_name,
|
||||
COUNT(*) as trip_count,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||
FROM vehicle_trip_summary
|
||||
WHERE ${whereClause}
|
||||
GROUP BY departure, arrival, departure_name, destination_name
|
||||
ORDER BY trip_count DESC
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
departure: row.departure,
|
||||
arrival: row.arrival,
|
||||
departureName: row.departure_name || row.departure,
|
||||
destinationName: row.destination_name || row.arrival,
|
||||
tripCount: parseInt(row.trip_count),
|
||||
completedCount: parseInt(row.completed_count),
|
||||
totalDistance: parseFloat(row.total_distance),
|
||||
avgDistance: parseFloat(row.avg_distance),
|
||||
avgDuration: parseFloat(row.avg_duration),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const vehicleReportService = new VehicleReportService();
|
||||
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* 차량 운행 이력 서비스
|
||||
*/
|
||||
import { getPool } from "../database/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { calculateDistance } from "../utils/geoUtils";
|
||||
|
||||
interface StartTripParams {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
vehicleId?: number;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departureName?: string;
|
||||
destinationName?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface EndTripParams {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface AddLocationParams {
|
||||
tripId: string;
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
interface TripListFilters {
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
class VehicleTripService {
|
||||
private get pool() {
|
||||
return getPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
*/
|
||||
async startTrip(params: StartTripParams) {
|
||||
const {
|
||||
userId,
|
||||
companyCode,
|
||||
vehicleId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
latitude,
|
||||
longitude,
|
||||
} = params;
|
||||
|
||||
const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`;
|
||||
|
||||
// 1. vehicle_trip_summary에 운행 기록 생성
|
||||
const summaryQuery = `
|
||||
INSERT INTO vehicle_trip_summary (
|
||||
trip_id, user_id, vehicle_id, departure, arrival,
|
||||
departure_name, destination_name, start_time, status, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const summaryResult = await this.pool.query(summaryQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
vehicleId || null,
|
||||
departure || null,
|
||||
arrival || null,
|
||||
departureName || null,
|
||||
destinationName || null,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 2. 시작 위치 기록
|
||||
const locationQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||
trip_status, departure, arrival, departure_name, destination_name,
|
||||
recorded_at, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
await this.pool.query(locationQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
vehicleId || null,
|
||||
latitude,
|
||||
longitude,
|
||||
departure || null,
|
||||
arrival || null,
|
||||
departureName || null,
|
||||
destinationName || null,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
return {
|
||||
tripId,
|
||||
summary: summaryResult.rows[0],
|
||||
startLocation: { latitude, longitude },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
*/
|
||||
async endTrip(params: EndTripParams) {
|
||||
const { tripId, userId, companyCode, latitude, longitude } = params;
|
||||
|
||||
// 1. 운행 정보 조회
|
||||
const tripQuery = `
|
||||
SELECT * FROM vehicle_trip_summary
|
||||
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||
`;
|
||||
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
||||
|
||||
if (tripResult.rows.length === 0) {
|
||||
throw new Error("활성 운행을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const trip = tripResult.rows[0];
|
||||
|
||||
// 2. 마지막 위치 기록
|
||||
const locationQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||
trip_status, departure, arrival, departure_name, destination_name,
|
||||
recorded_at, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
await this.pool.query(locationQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
trip.vehicle_id,
|
||||
latitude,
|
||||
longitude,
|
||||
trip.departure,
|
||||
trip.arrival,
|
||||
trip.departure_name,
|
||||
trip.destination_name,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 3. 총 거리 및 위치 수 계산
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as location_count,
|
||||
MIN(recorded_at) as start_time,
|
||||
MAX(recorded_at) as end_time
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
`;
|
||||
const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]);
|
||||
const stats = statsResult.rows[0];
|
||||
|
||||
// 4. 모든 위치 데이터로 총 거리 계산
|
||||
const locationsQuery = `
|
||||
SELECT latitude, longitude
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
ORDER BY recorded_at ASC
|
||||
`;
|
||||
const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]);
|
||||
|
||||
let totalDistance = 0;
|
||||
const locations = locationsResult.rows;
|
||||
for (let i = 1; i < locations.length; i++) {
|
||||
const prev = locations[i - 1];
|
||||
const curr = locations[i];
|
||||
totalDistance += calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
curr.latitude,
|
||||
curr.longitude
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 운행 시간 계산 (분)
|
||||
const startTime = new Date(stats.start_time);
|
||||
const endTime = new Date(stats.end_time);
|
||||
const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
||||
|
||||
// 6. 운행 요약 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE vehicle_trip_summary
|
||||
SET
|
||||
end_time = NOW(),
|
||||
total_distance = $1,
|
||||
duration_minutes = $2,
|
||||
location_count = $3,
|
||||
status = 'completed'
|
||||
WHERE trip_id = $4 AND company_code = $5
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const updateResult = await this.pool.query(updateQuery, [
|
||||
totalDistance.toFixed(3),
|
||||
durationMinutes,
|
||||
stats.location_count,
|
||||
tripId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
return {
|
||||
tripId,
|
||||
summary: updateResult.rows[0],
|
||||
totalDistance: parseFloat(totalDistance.toFixed(3)),
|
||||
durationMinutes,
|
||||
locationCount: parseInt(stats.location_count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
*/
|
||||
async addLocation(params: AddLocationParams) {
|
||||
const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params;
|
||||
|
||||
// 1. 운행 정보 조회
|
||||
const tripQuery = `
|
||||
SELECT * FROM vehicle_trip_summary
|
||||
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||
`;
|
||||
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
||||
|
||||
if (tripResult.rows.length === 0) {
|
||||
throw new Error("활성 운행을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const trip = tripResult.rows[0];
|
||||
|
||||
// 2. 이전 위치 조회 (거리 계산용)
|
||||
const prevLocationQuery = `
|
||||
SELECT latitude, longitude
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]);
|
||||
|
||||
let distanceFromPrev = 0;
|
||||
if (prevResult.rows.length > 0) {
|
||||
const prev = prevResult.rows[0];
|
||||
distanceFromPrev = calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
latitude,
|
||||
longitude
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 위치 기록 추가
|
||||
const locationQuery = `
|
||||
INSERT INTO vehicle_location_history (
|
||||
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||
accuracy, speed, distance_from_prev,
|
||||
trip_status, departure, arrival, departure_name, destination_name,
|
||||
recorded_at, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(locationQuery, [
|
||||
tripId,
|
||||
userId,
|
||||
trip.vehicle_id,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy || null,
|
||||
speed || null,
|
||||
distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null,
|
||||
trip.departure,
|
||||
trip.arrival,
|
||||
trip.departure_name,
|
||||
trip.destination_name,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 4. 운행 요약의 위치 수 업데이트
|
||||
await this.pool.query(
|
||||
`UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`,
|
||||
[tripId]
|
||||
);
|
||||
|
||||
return {
|
||||
locationId: result.rows[0].id,
|
||||
distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
*/
|
||||
async getTripList(companyCode: string, filters: TripListFilters) {
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
|
||||
if (filters.vehicleId) {
|
||||
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||
params.push(filters.vehicleId);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
conditions.push(`start_time >= $${paramIndex++}`);
|
||||
params.push(filters.startDate);
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
conditions.push(`start_time <= $${paramIndex++}`);
|
||||
params.push(filters.endDate + " 23:59:59");
|
||||
}
|
||||
|
||||
if (filters.departure) {
|
||||
conditions.push(`departure = $${paramIndex++}`);
|
||||
params.push(filters.departure);
|
||||
}
|
||||
|
||||
if (filters.arrival) {
|
||||
conditions.push(`arrival = $${paramIndex++}`);
|
||||
params.push(filters.arrival);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// 총 개수 조회
|
||||
const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`;
|
||||
const countResult = await this.pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// 목록 조회
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
const listQuery = `
|
||||
SELECT
|
||||
vts.*,
|
||||
ui.user_name,
|
||||
v.vehicle_number
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY vts.start_time DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
const listResult = await this.pool.query(listQuery, params);
|
||||
|
||||
return {
|
||||
data: listResult.rows,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
*/
|
||||
async getTripDetail(tripId: string, companyCode: string) {
|
||||
// 1. 운행 요약 조회
|
||||
const summaryQuery = `
|
||||
SELECT
|
||||
vts.*,
|
||||
ui.user_name,
|
||||
v.vehicle_number
|
||||
FROM vehicle_trip_summary vts
|
||||
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
||||
WHERE vts.trip_id = $1 AND vts.company_code = $2
|
||||
`;
|
||||
const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]);
|
||||
|
||||
if (summaryResult.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 경로 데이터 조회
|
||||
const routeQuery = `
|
||||
SELECT
|
||||
id, latitude, longitude, accuracy, speed,
|
||||
distance_from_prev, trip_status, recorded_at
|
||||
FROM vehicle_location_history
|
||||
WHERE trip_id = $1 AND company_code = $2
|
||||
ORDER BY recorded_at ASC
|
||||
`;
|
||||
const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]);
|
||||
|
||||
return {
|
||||
summary: summaryResult.rows[0],
|
||||
route: routeResult.rows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 운행 조회
|
||||
*/
|
||||
async getActiveTrip(userId: string, companyCode: string) {
|
||||
const query = `
|
||||
SELECT * FROM vehicle_trip_summary
|
||||
WHERE user_id = $1 AND company_code = $2 AND status = 'active'
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await this.pool.query(query, [userId, companyCode]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
*/
|
||||
async cancelTrip(tripId: string, companyCode: string) {
|
||||
const query = `
|
||||
UPDATE vehicle_trip_summary
|
||||
SET status = 'cancelled', end_time = NOW()
|
||||
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await this.pool.query(query, [tripId, companyCode]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
export const vehicleTripService = new VehicleTripService();
|
||||
|
|
@ -1,4 +1,98 @@
|
|||
import { ApiResponse, ColumnInfo } from './batchTypes';
|
||||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
// 공통 API 응답 타입
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 컬럼 정보 타입
|
||||
export interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
column_default?: string | null;
|
||||
}
|
||||
|
||||
// 테이블 정보 타입
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
table_type?: string;
|
||||
table_schema?: string;
|
||||
}
|
||||
|
||||
// 연결 정보 타입
|
||||
export interface ConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
// 배치 설정 필터 타입
|
||||
export interface BatchConfigFilter {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
// 배치 매핑 타입
|
||||
export interface BatchMapping {
|
||||
id?: number;
|
||||
batch_config_id?: number;
|
||||
company_code?: string;
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
from_api_url?: string;
|
||||
from_api_key?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
from_api_param_type?: 'url' | 'query';
|
||||
from_api_param_name?: string;
|
||||
from_api_param_value?: string;
|
||||
from_api_param_source?: 'static' | 'dynamic';
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_column_type?: string;
|
||||
to_api_url?: string;
|
||||
to_api_key?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_api_body?: string;
|
||||
mapping_order?: number;
|
||||
created_by?: string;
|
||||
created_date?: Date;
|
||||
}
|
||||
|
||||
// 배치 설정 타입
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
batch_name: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active: 'Y' | 'N';
|
||||
company_code?: string;
|
||||
created_by?: string;
|
||||
created_date?: Date;
|
||||
updated_by?: string;
|
||||
updated_date?: Date;
|
||||
batch_mappings?: BatchMapping[];
|
||||
}
|
||||
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
|
|
@ -27,7 +121,7 @@ export interface BatchMappingRequest {
|
|||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
// REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ export interface FlowDefinition {
|
|||
name: string;
|
||||
description?: string;
|
||||
tableName: string;
|
||||
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
||||
dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입
|
||||
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우)
|
||||
restApiEndpoint?: string; // REST API 엔드포인트
|
||||
restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data)
|
||||
companyCode: string; // 회사 코드 (* = 공통)
|
||||
isActive: boolean;
|
||||
createdBy?: string;
|
||||
|
|
@ -22,8 +26,12 @@ export interface CreateFlowDefinitionRequest {
|
|||
name: string;
|
||||
description?: string;
|
||||
tableName: string;
|
||||
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
||||
dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입
|
||||
dbConnectionId?: number; // 외부 DB 연결 ID
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId?: number; // REST API 연결 ID
|
||||
restApiEndpoint?: string; // REST API 엔드포인트
|
||||
restApiJsonPath?: string; // JSON 응답에서 데이터 경로
|
||||
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* 지리 좌표 관련 유틸리티 함수
|
||||
*/
|
||||
|
||||
/**
|
||||
* Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km)
|
||||
*
|
||||
* @param lat1 - 첫 번째 지점의 위도
|
||||
* @param lon1 - 첫 번째 지점의 경도
|
||||
* @param lat2 - 두 번째 지점의 위도
|
||||
* @param lon2 - 두 번째 지점의 경도
|
||||
* @returns 두 지점 간의 거리 (km)
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // 지구 반경 (km)
|
||||
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRadians(lat1)) *
|
||||
Math.cos(toRadians(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 각도를 라디안으로 변환
|
||||
*/
|
||||
function toRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* 라디안을 각도로 변환
|
||||
*/
|
||||
export function toDegrees(radians: number): number {
|
||||
return radians * (180 / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열에서 총 거리 계산
|
||||
*
|
||||
* @param coordinates - { latitude, longitude }[] 형태의 좌표 배열
|
||||
* @returns 총 거리 (km)
|
||||
*/
|
||||
export function calculateTotalDistance(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): number {
|
||||
let totalDistance = 0;
|
||||
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const prev = coordinates[i - 1];
|
||||
const curr = coordinates[i];
|
||||
totalDistance += calculateDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
curr.latitude,
|
||||
curr.longitude
|
||||
);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표가 특정 반경 내에 있는지 확인
|
||||
*
|
||||
* @param centerLat - 중심점 위도
|
||||
* @param centerLon - 중심점 경도
|
||||
* @param pointLat - 확인할 지점의 위도
|
||||
* @param pointLon - 확인할 지점의 경도
|
||||
* @param radiusKm - 반경 (km)
|
||||
* @returns 반경 내에 있으면 true
|
||||
*/
|
||||
export function isWithinRadius(
|
||||
centerLat: number,
|
||||
centerLon: number,
|
||||
pointLat: number,
|
||||
pointLon: number,
|
||||
radiusKm: number
|
||||
): boolean {
|
||||
const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon);
|
||||
return distance <= radiusKm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 사이의 방위각(bearing) 계산
|
||||
*
|
||||
* @param lat1 - 시작점 위도
|
||||
* @param lon1 - 시작점 경도
|
||||
* @param lat2 - 도착점 위도
|
||||
* @param lon2 - 도착점 경도
|
||||
* @returns 방위각 (0-360도)
|
||||
*/
|
||||
export function calculateBearing(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const lat1Rad = toRadians(lat1);
|
||||
const lat2Rad = toRadians(lat2);
|
||||
|
||||
const x = Math.sin(dLon) * Math.cos(lat2Rad);
|
||||
const y =
|
||||
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
|
||||
|
||||
let bearing = toDegrees(Math.atan2(x, y));
|
||||
bearing = (bearing + 360) % 360; // 0-360 범위로 정규화
|
||||
|
||||
return bearing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열의 경계 상자(bounding box) 계산
|
||||
*
|
||||
* @param coordinates - 좌표 배열
|
||||
* @returns { minLat, maxLat, minLon, maxLon }
|
||||
*/
|
||||
export function getBoundingBox(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null {
|
||||
if (coordinates.length === 0) return null;
|
||||
|
||||
let minLat = coordinates[0].latitude;
|
||||
let maxLat = coordinates[0].latitude;
|
||||
let minLon = coordinates[0].longitude;
|
||||
let maxLon = coordinates[0].longitude;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
minLat = Math.min(minLat, coord.latitude);
|
||||
maxLat = Math.max(maxLat, coord.latitude);
|
||||
minLon = Math.min(minLon, coord.longitude);
|
||||
maxLon = Math.max(maxLon, coord.longitude);
|
||||
}
|
||||
|
||||
return { minLat, maxLat, minLon, maxLon };
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌표 배열의 중심점 계산
|
||||
*
|
||||
* @param coordinates - 좌표 배열
|
||||
* @returns { latitude, longitude } 중심점
|
||||
*/
|
||||
export function getCenterPoint(
|
||||
coordinates: Array<{ latitude: number; longitude: number }>
|
||||
): { latitude: number; longitude: number } | null {
|
||||
if (coordinates.length === 0) return null;
|
||||
|
||||
let sumLat = 0;
|
||||
let sumLon = 0;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
sumLat += coord.latitude;
|
||||
sumLon += coord.longitude;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: sumLat / coordinates.length,
|
||||
longitude: sumLon / coordinates.length,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const VehicleReport = dynamic(
|
||||
() => import("@/components/vehicle/VehicleReport"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export default function VehicleReportsPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">운행 리포트</h1>
|
||||
<p className="text-muted-foreground">
|
||||
차량 운행 통계 및 분석 리포트를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<VehicleReport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const VehicleTripHistory = dynamic(
|
||||
() => import("@/components/vehicle/VehicleTripHistory"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
export default function VehicleTripsPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">운행 이력 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
차량 운행 이력을 조회하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<VehicleTripHistory />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -195,6 +195,7 @@ export default function DashboardListClient() {
|
|||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
|
|
@ -209,6 +210,9 @@ export default function DashboardListClient() {
|
|||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
|
|
@ -277,6 +281,7 @@ export default function DashboardListClient() {
|
|||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
|
|
@ -296,6 +301,9 @@ export default function DashboardListClient() {
|
|||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
|
|
@ -363,6 +371,10 @@ export default function DashboardListClient() {
|
|||
<span className="text-muted-foreground">설명</span>
|
||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성자</span>
|
||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">생성일</span>
|
||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { formatErrorMessage } from "@/lib/utils/errorUtils";
|
|||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
|
||||
export default function FlowManagementPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -52,13 +53,19 @@ export default function FlowManagementPage() {
|
|||
);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
||||
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
|
||||
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
|
||||
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
|
||||
const [externalConnections, setExternalConnections] = useState<
|
||||
Array<{ id: number; connection_name: string; db_type: string }>
|
||||
>([]);
|
||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||
|
||||
// REST API 연결 관련 상태
|
||||
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||
const [restApiEndpoint, setRestApiEndpoint] = useState("");
|
||||
const [restApiJsonPath, setRestApiJsonPath] = useState("data");
|
||||
|
||||
// 생성 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
|
|
@ -135,75 +142,132 @@ export default function FlowManagementPage() {
|
|||
loadConnections();
|
||||
}, []);
|
||||
|
||||
// REST API 연결 목록 로드
|
||||
useEffect(() => {
|
||||
const loadRestApiConnections = async () => {
|
||||
try {
|
||||
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setRestApiConnections(connections);
|
||||
} catch (error) {
|
||||
console.error("Failed to load REST API connections:", error);
|
||||
setRestApiConnections([]);
|
||||
}
|
||||
};
|
||||
loadRestApiConnections();
|
||||
}, []);
|
||||
|
||||
// 외부 DB 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedDbSource === "internal" || !selectedDbSource) {
|
||||
// REST API인 경우 테이블 목록 로드 불필요
|
||||
if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) {
|
||||
setExternalTableList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadExternalTables = async () => {
|
||||
try {
|
||||
setLoadingExternalTables(true);
|
||||
const token = localStorage.getItem("authToken");
|
||||
// 외부 DB인 경우
|
||||
if (selectedDbSource.startsWith("external_db_")) {
|
||||
const connectionId = selectedDbSource.replace("external_db_", "");
|
||||
|
||||
const loadExternalTables = async () => {
|
||||
try {
|
||||
setLoadingExternalTables(true);
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const tables = Array.isArray(data.data) ? data.data : [];
|
||||
const tableNames = tables
|
||||
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
||||
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
||||
)
|
||||
.filter(Boolean);
|
||||
setExternalTableList(tableNames);
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const tables = Array.isArray(data.data) ? data.data : [];
|
||||
const tableNames = tables
|
||||
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
||||
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
||||
)
|
||||
.filter(Boolean);
|
||||
setExternalTableList(tableNames);
|
||||
} else {
|
||||
setExternalTableList([]);
|
||||
}
|
||||
} else {
|
||||
setExternalTableList([]);
|
||||
}
|
||||
} else {
|
||||
} catch (error) {
|
||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
setExternalTableList([]);
|
||||
} finally {
|
||||
setLoadingExternalTables(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
setExternalTableList([]);
|
||||
} finally {
|
||||
setLoadingExternalTables(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
loadExternalTables();
|
||||
loadExternalTables();
|
||||
}
|
||||
}, [selectedDbSource]);
|
||||
|
||||
// 플로우 생성
|
||||
const handleCreate = async () => {
|
||||
console.log("🚀 handleCreate called with formData:", formData);
|
||||
|
||||
if (!formData.name || !formData.tableName) {
|
||||
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName });
|
||||
// REST API인 경우 테이블 이름 검증 스킵
|
||||
const isRestApi = selectedDbSource.startsWith("restapi_");
|
||||
|
||||
if (!formData.name || (!isRestApi && !formData.tableName)) {
|
||||
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi });
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "플로우 이름과 테이블 이름은 필수입니다.",
|
||||
description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// REST API인 경우 엔드포인트 검증
|
||||
if (isRestApi && !restApiEndpoint) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "REST API 엔드포인트는 필수입니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// DB 소스 정보 추가
|
||||
const requestData = {
|
||||
// 데이터 소스 타입 및 ID 파싱
|
||||
let dbSourceType: "internal" | "external" | "restapi" = "internal";
|
||||
let dbConnectionId: number | undefined = undefined;
|
||||
let restApiConnectionId: number | undefined = undefined;
|
||||
|
||||
if (selectedDbSource === "internal") {
|
||||
dbSourceType = "internal";
|
||||
} else if (selectedDbSource.startsWith("external_db_")) {
|
||||
dbSourceType = "external";
|
||||
dbConnectionId = parseInt(selectedDbSource.replace("external_db_", ""));
|
||||
} else if (selectedDbSource.startsWith("restapi_")) {
|
||||
dbSourceType = "restapi";
|
||||
restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", ""));
|
||||
}
|
||||
|
||||
// 요청 데이터 구성
|
||||
const requestData: Record<string, unknown> = {
|
||||
...formData,
|
||||
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
||||
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
};
|
||||
|
||||
// REST API인 경우 추가 정보
|
||||
if (dbSourceType === "restapi") {
|
||||
requestData.restApiConnectionId = restApiConnectionId;
|
||||
requestData.restApiEndpoint = restApiEndpoint;
|
||||
requestData.restApiJsonPath = restApiJsonPath || "data";
|
||||
// REST API는 가상 테이블명 사용
|
||||
requestData.tableName = `_restapi_${restApiConnectionId}`;
|
||||
}
|
||||
|
||||
console.log("✅ Calling createFlowDefinition with:", requestData);
|
||||
const response = await createFlowDefinition(requestData);
|
||||
const response = await createFlowDefinition(requestData as Parameters<typeof createFlowDefinition>[0]);
|
||||
if (response.success && response.data) {
|
||||
toast({
|
||||
title: "생성 완료",
|
||||
|
|
@ -212,6 +276,8 @@ export default function FlowManagementPage() {
|
|||
setIsCreateDialogOpen(false);
|
||||
setFormData({ name: "", description: "", tableName: "" });
|
||||
setSelectedDbSource("internal");
|
||||
setRestApiEndpoint("");
|
||||
setRestApiJsonPath("data");
|
||||
loadFlows();
|
||||
} else {
|
||||
toast({
|
||||
|
|
@ -415,125 +481,186 @@ export default function FlowManagementPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* DB 소스 선택 */}
|
||||
{/* 데이터 소스 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">데이터베이스 소스</Label>
|
||||
<Label className="text-xs sm:text-sm">데이터 소스</Label>
|
||||
<Select
|
||||
value={selectedDbSource.toString()}
|
||||
value={selectedDbSource}
|
||||
onValueChange={(value) => {
|
||||
const dbSource = value === "internal" ? "internal" : parseInt(value);
|
||||
setSelectedDbSource(dbSource);
|
||||
// DB 소스 변경 시 테이블 선택 초기화
|
||||
setSelectedDbSource(value);
|
||||
// 소스 변경 시 테이블 선택 및 REST API 설정 초기화
|
||||
setFormData({ ...formData, tableName: "" });
|
||||
setRestApiEndpoint("");
|
||||
setRestApiJsonPath("data");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="데이터베이스 선택" />
|
||||
<SelectValue placeholder="데이터 소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 내부 DB */}
|
||||
<SelectItem value="internal">내부 데이터베이스</SelectItem>
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{/* 외부 DB 연결 */}
|
||||
{externalConnections.length > 0 && (
|
||||
<>
|
||||
<SelectItem value="__divider_db__" disabled className="text-xs text-muted-foreground">
|
||||
-- 외부 데이터베이스 --
|
||||
</SelectItem>
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={`db_${conn.id}`} value={`external_db_${conn.id}`}>
|
||||
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* REST API 연결 */}
|
||||
{restApiConnections.length > 0 && (
|
||||
<>
|
||||
<SelectItem value="__divider_api__" disabled className="text-xs text-muted-foreground">
|
||||
-- REST API --
|
||||
</SelectItem>
|
||||
{restApiConnections.map((conn) => (
|
||||
<SelectItem key={`api_${conn.id}`} value={`restapi_${conn.id}`}>
|
||||
{conn.connection_name} (REST API)
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
플로우에서 사용할 데이터베이스를 선택합니다
|
||||
플로우에서 사용할 데이터 소스를 선택합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
||||
연결 테이블 *
|
||||
</Label>
|
||||
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombobox}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
|
||||
>
|
||||
{formData.tableName
|
||||
? selectedDbSource === "internal"
|
||||
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
||||
formData.tableName
|
||||
: formData.tableName
|
||||
: loadingTables || loadingExternalTables
|
||||
? "로딩 중..."
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectedDbSource === "internal"
|
||||
? // 내부 DB 테이블 목록
|
||||
tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(currentValue) => {
|
||||
console.log("📝 Internal table selected:", {
|
||||
tableName: table.tableName,
|
||||
currentValue,
|
||||
});
|
||||
setFormData({ ...formData, tableName: currentValue });
|
||||
setOpenTableCombobox(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.description && (
|
||||
<span className="text-[10px] text-gray-500">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))
|
||||
: // 외부 DB 테이블 목록
|
||||
externalTableList.map((tableName, index) => (
|
||||
<CommandItem
|
||||
key={`external-${selectedDbSource}-${tableName}-${index}`}
|
||||
value={tableName}
|
||||
onSelect={(currentValue) => {
|
||||
setFormData({ ...formData, tableName: currentValue });
|
||||
setOpenTableCombobox(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>{tableName}</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)
|
||||
</p>
|
||||
</div>
|
||||
{/* REST API인 경우 엔드포인트 설정 */}
|
||||
{selectedDbSource.startsWith("restapi_") ? (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="restApiEndpoint" className="text-xs sm:text-sm">
|
||||
API 엔드포인트 *
|
||||
</Label>
|
||||
<Input
|
||||
id="restApiEndpoint"
|
||||
value={restApiEndpoint}
|
||||
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
||||
placeholder="예: /api/data/list"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
데이터를 조회할 API 엔드포인트 경로입니다
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="restApiJsonPath" className="text-xs sm:text-sm">
|
||||
JSON 경로
|
||||
</Label>
|
||||
<Input
|
||||
id="restApiJsonPath"
|
||||
value={restApiJsonPath}
|
||||
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
||||
placeholder="예: data 또는 result.items"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
응답 JSON에서 데이터 배열의 경로입니다 (기본: data)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 테이블 선택 (내부 DB 또는 외부 DB) */
|
||||
<div>
|
||||
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
||||
연결 테이블 *
|
||||
</Label>
|
||||
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombobox}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
|
||||
>
|
||||
{formData.tableName
|
||||
? selectedDbSource === "internal"
|
||||
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
||||
formData.tableName
|
||||
: formData.tableName
|
||||
: loadingTables || loadingExternalTables
|
||||
? "로딩 중..."
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectedDbSource === "internal"
|
||||
? // 내부 DB 테이블 목록
|
||||
tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(currentValue) => {
|
||||
console.log("📝 Internal table selected:", {
|
||||
tableName: table.tableName,
|
||||
currentValue,
|
||||
});
|
||||
setFormData({ ...formData, tableName: currentValue });
|
||||
setOpenTableCombobox(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.description && (
|
||||
<span className="text-[10px] text-gray-500">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))
|
||||
: // 외부 DB 테이블 목록
|
||||
externalTableList.map((tableName, index) => (
|
||||
<CommandItem
|
||||
key={`external-${selectedDbSource}-${tableName}-${index}`}
|
||||
value={tableName}
|
||||
onSelect={(currentValue) => {
|
||||
setFormData({ ...formData, tableName: currentValue });
|
||||
setOpenTableCombobox(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>{tableName}</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { apiClient } from "@/lib/api/client";
|
|||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
|
|
@ -488,52 +488,69 @@ export default function TableManagementPage() {
|
|||
if (response.data.success) {
|
||||
console.log("✅ 컬럼 설정 저장 성공");
|
||||
|
||||
// 🆕 Category 타입인 경우 컬럼 매핑 생성
|
||||
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
||||
console.log("🔍 카테고리 조건 체크:", {
|
||||
isCategory: column.inputType === "category",
|
||||
hasCategoryMenus: !!column.categoryMenus,
|
||||
length: column.categoryMenus?.length || 0,
|
||||
});
|
||||
|
||||
if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) {
|
||||
console.log("📥 카테고리 메뉴 매핑 시작:", {
|
||||
if (column.inputType === "category") {
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
categoryMenus: column.categoryMenus,
|
||||
count: column.categoryMenus.length,
|
||||
});
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const menuObjid of column.categoryMenus) {
|
||||
try {
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
if (column.categoryMenus && column.categoryMenus.length > 0) {
|
||||
console.log("📥 카테고리 메뉴 매핑 시작:", {
|
||||
columnName: column.columnName,
|
||||
categoryMenus: column.categoryMenus,
|
||||
count: column.categoryMenus.length,
|
||||
});
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const menuObjid of column.categoryMenus) {
|
||||
try {
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
||||
}
|
||||
} else {
|
||||
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
|
||||
}
|
||||
} else {
|
||||
toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
|
||||
|
|
@ -596,10 +613,8 @@ export default function TableManagementPage() {
|
|||
);
|
||||
|
||||
if (response.data.success) {
|
||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 생성
|
||||
const categoryColumns = columns.filter(
|
||||
(col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0
|
||||
);
|
||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
||||
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
||||
|
||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||
totalColumns: columns.length,
|
||||
|
|
@ -615,33 +630,49 @@ export default function TableManagementPage() {
|
|||
let totalFailCount = 0;
|
||||
|
||||
for (const column of categoryColumns) {
|
||||
for (const menuObjid of column.categoryMenus!) {
|
||||
try {
|
||||
console.log("🔄 매핑 API 호출:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
menuObjid,
|
||||
});
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
});
|
||||
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
if (column.categoryMenus && column.categoryMenus.length > 0) {
|
||||
for (const menuObjid of column.categoryMenus) {
|
||||
try {
|
||||
console.log("🔄 매핑 API 호출:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
totalSuccessCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
||||
|
||||
if (mappingResponse.success) {
|
||||
totalSuccessCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
totalFailCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
totalFailCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
totalFailCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
|||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -796,7 +797,9 @@ function ScreenViewPage() {
|
|||
function ScreenViewPageWrapper() {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenViewPage />
|
||||
<ScreenContextProvider>
|
||||
<ScreenViewPage />
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleManageDepartments(company)}
|
||||
|
|
@ -170,7 +170,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
aria-label="부서관리"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -294,18 +294,10 @@ export function MenuCopyDialog({
|
|||
<span className="text-muted-foreground">화면:</span>{" "}
|
||||
<span className="font-medium">{result.copiedScreens}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||
<span className="font-medium">{result.copiedFlows}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCategories}개</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">코드:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCodes}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
|
@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.externalConnectionId || "");
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
|
||||
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
||||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||
|
|
@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
|
||||
useEffect(() => {
|
||||
if (dataSource.externalConnectionId) {
|
||||
setSelectedConnectionId(dataSource.externalConnectionId);
|
||||
}
|
||||
}, [dataSource.externalConnectionId]);
|
||||
|
||||
// 외부 커넥션 선택 핸들러
|
||||
const handleConnectionSelect = async (connectionId: string) => {
|
||||
setSelectedConnectionId(connectionId);
|
||||
|
|
@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
|
||||
const updates: Partial<ChartDataSource> = {
|
||||
endpoint: fullEndpoint,
|
||||
externalConnectionId: connectionId, // 외부 연결 ID 저장
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
// 기본 메서드/바디가 있으면 적용
|
||||
if (connection.default_method) {
|
||||
updates.method = connection.default_method as ChartDataSource["method"];
|
||||
}
|
||||
if (connection.default_body) {
|
||||
updates.body = connection.default_body;
|
||||
}
|
||||
|
||||
// 기본 헤더가 있으면 적용
|
||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||
|
|
@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
}
|
||||
});
|
||||
|
||||
const bodyPayload =
|
||||
dataSource.body && dataSource.body.trim().length > 0
|
||||
? dataSource.body
|
||||
: undefined;
|
||||
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
method: dataSource.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
body: bodyPayload,
|
||||
externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={dataSource.method || "GET"}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
method: value as ChartDataSource["method"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET" className="text-xs">
|
||||
GET
|
||||
</SelectItem>
|
||||
<SelectItem value="POST" className="text-xs">
|
||||
POST
|
||||
</SelectItem>
|
||||
<SelectItem value="PUT" className="text-xs">
|
||||
PUT
|
||||
</SelectItem>
|
||||
<SelectItem value="DELETE" className="text-xs">
|
||||
DELETE
|
||||
</SelectItem>
|
||||
<SelectItem value="PATCH" className="text-xs">
|
||||
PATCH
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Request Body (POST/PUT/PATCH 일 때만) */}
|
||||
{(dataSource.method === "POST" ||
|
||||
dataSource.method === "PUT" ||
|
||||
dataSource.method === "PATCH") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Request Body (선택)</Label>
|
||||
<Textarea
|
||||
value={dataSource.body || ""}
|
||||
onChange={(e) => onChange({ body: e.target.value })}
|
||||
placeholder='{"key": "value"} 또는 원시 페이로드를 그대로 입력하세요'
|
||||
className="h-24 text-xs font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
이 내용은 그대로 외부 API 요청 Body로 전송됩니다. JSON이 아니어도 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@ export interface ChartDataSource {
|
|||
|
||||
// API 관련
|
||||
endpoint?: string; // API URL
|
||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
||||
// HTTP 메서드 (기본 GET, POST/PUT/DELETE/PATCH도 지원)
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
// 요청 Body (옵션) - 문자열 그대로 전송 (JSON 또는 일반 텍스트)
|
||||
body?: string;
|
||||
headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
|
||||
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
|
||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
|
|
|||
|
|
@ -57,9 +57,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 추가
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
|
||||
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
|
||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
|
|
@ -120,28 +123,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
};
|
||||
|
||||
// 🆕 선택된 데이터 상태 추가 (RepeatScreenModal 등에서 사용)
|
||||
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
||||
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
|
||||
const modalOpenedAtRef = React.useRef<number>(0);
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, size, urlParams, selectedData: eventSelectedData, selectedIds } = event.detail;
|
||||
const { screenId, title, description, size, urlParams, editData } = event.detail;
|
||||
|
||||
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
|
||||
screenId,
|
||||
title,
|
||||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
});
|
||||
|
||||
// 🆕 선택된 데이터 저장
|
||||
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||
setSelectedData(eventSelectedData);
|
||||
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
|
||||
} else {
|
||||
setSelectedData([]);
|
||||
}
|
||||
// 🆕 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
|
||||
|
||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||
if (urlParams && typeof window !== "undefined") {
|
||||
|
|
@ -154,6 +146,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||
}
|
||||
|
||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||
if (editData) {
|
||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||
setFormData(editData);
|
||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
} else {
|
||||
setOriginalData(null); // 신규 등록 모드
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
screenId,
|
||||
|
|
@ -182,7 +183,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setOriginalData(null); // 🆕 원본 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
|
|
@ -190,6 +191,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
|
||||
const handleSaveSuccess = () => {
|
||||
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
|
||||
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
|
||||
if (timeSinceOpen < 500) {
|
||||
console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
|
||||
return;
|
||||
}
|
||||
|
||||
const isContinuousMode = continuousMode;
|
||||
console.log("💾 저장 성공 이벤트 수신");
|
||||
console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
|
|
@ -201,11 +209,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey(prev => prev + 1);
|
||||
setResetKey((prev) => prev + 1);
|
||||
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
|
|
@ -333,17 +341,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
if (Array.isArray(data)) {
|
||||
return data.map(normalizeDates);
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
|
||||
const before = value;
|
||||
const after = value.split('T')[0];
|
||||
const after = value.split("T")[0];
|
||||
console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`);
|
||||
normalized[key] = after;
|
||||
} else {
|
||||
|
|
@ -352,21 +360,26 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
|
||||
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
|
||||
const normalizedData = normalizeDates(response.data);
|
||||
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
|
||||
|
||||
|
||||
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
|
||||
if (Array.isArray(normalizedData)) {
|
||||
console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.");
|
||||
console.log(
|
||||
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
||||
);
|
||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
|
||||
} else {
|
||||
setFormData(normalizedData);
|
||||
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
}
|
||||
|
||||
// setFormData 직후 확인
|
||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
|
||||
} else {
|
||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||
toast.error("데이터를 불러올 수 없습니다.");
|
||||
|
|
@ -435,7 +448,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
|
||||
}
|
||||
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -459,7 +472,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
|
||||
return {
|
||||
|
|
@ -600,17 +613,32 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
// 🆕 formData 전달 확인 로그
|
||||
console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
});
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
|
|
@ -624,8 +652,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
|
||||
groupedData={selectedData.length > 0 ? selectedData : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
/**
|
||||
* 임베드된 화면 컴포넌트
|
||||
* 다른 화면 안에 임베드되어 표시되는 화면
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useImperativeHandle, useState, useEffect, useRef, useCallback } from "react";
|
||||
import type {
|
||||
ScreenEmbedding,
|
||||
DataReceiver,
|
||||
DataReceivable,
|
||||
EmbeddedScreenHandle,
|
||||
DataReceiveMode,
|
||||
} from "@/types/screen-embedding";
|
||||
import type { ComponentData } from "@/types/screen";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface EmbeddedScreenProps {
|
||||
embedding: ScreenEmbedding;
|
||||
onSelectionChanged?: (selectedRows: any[]) => void;
|
||||
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드된 화면 컴포넌트
|
||||
*/
|
||||
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||||
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
|
||||
const [layout, setLayout] = useState<ComponentData[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [screenInfo, setScreenInfo] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
||||
|
||||
// 컴포넌트 참조 맵
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
||||
const { userId, userName, companyCode } = useAuth();
|
||||
|
||||
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
|
||||
const contentBounds = React.useMemo(() => {
|
||||
if (layout.length === 0) return { width: 0, height: 0 };
|
||||
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
|
||||
layout.forEach((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
|
||||
const right = (compPosition.x || 0) + (size.width || 200);
|
||||
const bottom = (compPosition.y || 0) + (size.height || 40);
|
||||
|
||||
if (right > maxRight) maxRight = right;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
});
|
||||
|
||||
return { width: maxRight, height: maxBottom };
|
||||
}, [layout]);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
||||
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 화면 데이터 로드
|
||||
useEffect(() => {
|
||||
loadScreenData();
|
||||
}, [embedding.childScreenId]);
|
||||
|
||||
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
|
||||
useEffect(() => {
|
||||
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
||||
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
}, [initialFormData]);
|
||||
|
||||
// 선택 변경 이벤트 전파
|
||||
useEffect(() => {
|
||||
onSelectionChanged?.(selectedRows);
|
||||
}, [selectedRows, onSelectionChanged]);
|
||||
|
||||
/**
|
||||
* 화면 레이아웃 로드
|
||||
*/
|
||||
const loadScreenData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
|
||||
const screenData = await screenApi.getScreen(embedding.childScreenId);
|
||||
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
|
||||
screenId: embedding.childScreenId,
|
||||
hasData: !!screenData,
|
||||
tableName: screenData?.tableName,
|
||||
screenName: screenData?.name || screenData?.screenName,
|
||||
position,
|
||||
});
|
||||
if (screenData) {
|
||||
setScreenInfo(screenData);
|
||||
} else {
|
||||
console.warn("⚠️ [EmbeddedScreen] 화면 정보 로드 실패:", {
|
||||
screenId: embedding.childScreenId,
|
||||
});
|
||||
}
|
||||
|
||||
// 화면 레이아웃 로드 (별도 API)
|
||||
const layoutData = await screenApi.getLayout(embedding.childScreenId);
|
||||
|
||||
logger.info("📦 화면 레이아웃 로드 완료", {
|
||||
screenId: embedding.childScreenId,
|
||||
mode: embedding.mode,
|
||||
hasLayoutData: !!layoutData,
|
||||
componentsCount: layoutData?.components?.length || 0,
|
||||
position,
|
||||
});
|
||||
|
||||
if (layoutData && layoutData.components && Array.isArray(layoutData.components)) {
|
||||
setLayout(layoutData.components);
|
||||
|
||||
logger.info("✅ 임베드 화면 컴포넌트 설정 완료", {
|
||||
screenId: embedding.childScreenId,
|
||||
componentsCount: layoutData.components.length,
|
||||
});
|
||||
} else {
|
||||
logger.warn("⚠️ 화면에 컴포넌트가 없습니다", {
|
||||
screenId: embedding.childScreenId,
|
||||
layoutData,
|
||||
});
|
||||
setLayout([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error("화면 레이아웃 로드 실패", err);
|
||||
setError(err.message || "화면을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 등록
|
||||
*/
|
||||
const registerComponent = useCallback((id: string, component: DataReceivable) => {
|
||||
componentRefs.current.set(id, component);
|
||||
|
||||
logger.debug("컴포넌트 등록", {
|
||||
componentId: id,
|
||||
componentType: component.componentType,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 컴포넌트 등록 해제
|
||||
*/
|
||||
const unregisterComponent = useCallback((id: string) => {
|
||||
componentRefs.current.delete(id);
|
||||
|
||||
logger.debug("컴포넌트 등록 해제", {
|
||||
componentId: id,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 선택된 행 업데이트
|
||||
*/
|
||||
const handleSelectionChange = useCallback((rows: any[]) => {
|
||||
setSelectedRows(rows);
|
||||
}, []);
|
||||
|
||||
// 외부에서 호출 가능한 메서드
|
||||
useImperativeHandle(ref, () => ({
|
||||
/**
|
||||
* 선택된 행 가져오기
|
||||
*/
|
||||
getSelectedRows: () => {
|
||||
return selectedRows;
|
||||
},
|
||||
|
||||
/**
|
||||
* 선택 초기화
|
||||
*/
|
||||
clearSelection: () => {
|
||||
setSelectedRows([]);
|
||||
},
|
||||
|
||||
/**
|
||||
* 데이터 수신
|
||||
*/
|
||||
receiveData: async (data: any[], receivers: DataReceiver[]) => {
|
||||
logger.info("데이터 수신 시작", {
|
||||
dataCount: data.length,
|
||||
receiversCount: receivers.length,
|
||||
});
|
||||
|
||||
const errors: Array<{ componentId: string; error: string }> = [];
|
||||
|
||||
// 각 데이터 수신자에게 데이터 전달
|
||||
for (const receiver of receivers) {
|
||||
try {
|
||||
const component = componentRefs.current.get(receiver.targetComponentId);
|
||||
|
||||
if (!component) {
|
||||
const errorMsg = `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`;
|
||||
logger.warn(errorMsg);
|
||||
errors.push({
|
||||
componentId: receiver.targetComponentId,
|
||||
error: errorMsg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. 조건 필터링
|
||||
let filteredData = data;
|
||||
if (receiver.condition) {
|
||||
filteredData = filterDataByCondition(data, receiver.condition);
|
||||
|
||||
logger.debug("조건 필터링 적용", {
|
||||
componentId: receiver.targetComponentId,
|
||||
originalCount: data.length,
|
||||
filteredCount: filteredData.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 매핑 규칙 적용
|
||||
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
|
||||
|
||||
logger.debug("매핑 규칙 적용", {
|
||||
componentId: receiver.targetComponentId,
|
||||
mappingRulesCount: receiver.mappingRules.length,
|
||||
});
|
||||
|
||||
// 3. 검증
|
||||
if (receiver.validation) {
|
||||
if (receiver.validation.required && mappedData.length === 0) {
|
||||
throw new Error("필수 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
if (receiver.validation.minRows && mappedData.length < receiver.validation.minRows) {
|
||||
throw new Error(`최소 ${receiver.validation.minRows}개의 데이터가 필요합니다.`);
|
||||
}
|
||||
|
||||
if (receiver.validation.maxRows && mappedData.length > receiver.validation.maxRows) {
|
||||
throw new Error(`최대 ${receiver.validation.maxRows}개까지만 허용됩니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 데이터 전달
|
||||
await component.receiveData(mappedData, receiver.mode);
|
||||
|
||||
logger.info("데이터 전달 성공", {
|
||||
componentId: receiver.targetComponentId,
|
||||
componentType: receiver.targetComponentType,
|
||||
mode: receiver.mode,
|
||||
dataCount: mappedData.length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("데이터 전달 실패", {
|
||||
componentId: receiver.targetComponentId,
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
errors.push({
|
||||
componentId: receiver.targetComponentId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`일부 컴포넌트에 데이터 전달 실패: ${errors.map((e) => e.componentId).join(", ")}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 데이터 가져오기
|
||||
*/
|
||||
getData: () => {
|
||||
const allData: Record<string, any> = {};
|
||||
|
||||
componentRefs.current.forEach((component, id) => {
|
||||
allData[id] = component.getData();
|
||||
});
|
||||
|
||||
return allData;
|
||||
},
|
||||
}));
|
||||
|
||||
// 로딩 상태
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
<p className="text-muted-foreground text-sm">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<svg className="text-destructive h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">화면을 불러올 수 없습니다</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{error}</p>
|
||||
</div>
|
||||
<button onClick={loadScreenData} className="text-primary text-sm hover:underline">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게)
|
||||
// position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함
|
||||
return (
|
||||
<ScreenContextProvider
|
||||
screenId={embedding.childScreenId}
|
||||
tableName={screenInfo?.tableName}
|
||||
splitPanelPosition={position}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-auto p-4">
|
||||
{layout.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
minHeight: contentBounds.height + 20, // 여유 공간 추가
|
||||
}}
|
||||
>
|
||||
{layout.map((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||
|
||||
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
|
||||
// 부모 컨테이너의 100%를 기준으로 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
left: compPosition.x || 0,
|
||||
top: compPosition.y || 0,
|
||||
width: size.width || 200,
|
||||
height: size.height || 40,
|
||||
zIndex: compPosition.z || 1,
|
||||
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
|
||||
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
className="absolute"
|
||||
style={componentStyle}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={embedding.childScreenId}
|
||||
tableName={screenInfo?.tableName}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFieldChange}
|
||||
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScreenContextProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EmbeddedScreen.displayName = "EmbeddedScreen";
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* 분할 패널 컴포넌트
|
||||
* 좌측과 우측에 화면을 임베드합니다.
|
||||
*
|
||||
* 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다.
|
||||
* 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { EmbeddedScreen } from "./EmbeddedScreen";
|
||||
import { Columns2 } from "lucide-react";
|
||||
import { SplitPanelProvider } from "@/contexts/SplitPanelContext";
|
||||
|
||||
interface ScreenSplitPanelProps {
|
||||
screenId?: number;
|
||||
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
|
||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컴포넌트
|
||||
* 순수하게 화면 분할 기능만 제공합니다.
|
||||
*/
|
||||
export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
|
||||
// config에서 splitRatio 추출 (기본값 50)
|
||||
const configSplitRatio = config?.splitRatio ?? 50;
|
||||
|
||||
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
|
||||
screenId,
|
||||
config,
|
||||
leftScreenId: config?.leftScreenId,
|
||||
rightScreenId: config?.rightScreenId,
|
||||
configSplitRatio,
|
||||
configKeys: config ? Object.keys(config) : [],
|
||||
});
|
||||
|
||||
// 🆕 initialFormData 별도 로그 (명확한 확인)
|
||||
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
|
||||
hasInitialFormData: !!initialFormData,
|
||||
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
|
||||
initialFormData: initialFormData,
|
||||
});
|
||||
|
||||
// 드래그로 조절 가능한 splitRatio 상태
|
||||
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
||||
|
||||
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
||||
React.useEffect(() => {
|
||||
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
|
||||
setSplitRatio(configSplitRatio);
|
||||
}, [configSplitRatio]);
|
||||
|
||||
// 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환
|
||||
const leftEmbedding = config?.leftScreenId
|
||||
? {
|
||||
id: 1,
|
||||
parentScreenId: screenId || 0,
|
||||
childScreenId: config.leftScreenId,
|
||||
position: "left" as const,
|
||||
mode: "view" as const, // 기본 view 모드 (select는 테이블 자체 설정)
|
||||
config: {},
|
||||
companyCode: "*",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const rightEmbedding = config?.rightScreenId
|
||||
? {
|
||||
id: 2,
|
||||
parentScreenId: screenId || 0,
|
||||
childScreenId: config.rightScreenId,
|
||||
position: "right" as const,
|
||||
mode: "view" as const, // 기본 view 모드
|
||||
config: {},
|
||||
companyCode: "*",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: null;
|
||||
|
||||
/**
|
||||
* 리사이저 드래그 핸들러
|
||||
*/
|
||||
const handleResize = useCallback((newRatio: number) => {
|
||||
setSplitRatio(Math.max(20, Math.min(80, newRatio)));
|
||||
}, []);
|
||||
|
||||
// config가 없는 경우 (디자이너 모드 또는 초기 상태)
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="border-muted-foreground/25 flex h-full items-center justify-center rounded-lg border-2 border-dashed">
|
||||
<div className="space-y-4 p-6 text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
|
||||
<Columns2 className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-2 text-base font-semibold">화면 분할 패널</p>
|
||||
<p className="text-muted-foreground/60 mb-1 text-xs">좌우로 화면을 나눕니다</p>
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
우측 속성 패널 → 상세 설정에서 좌측/우측 화면을 선택하세요
|
||||
</p>
|
||||
<p className="text-muted-foreground/60 mt-2 text-[10px]">
|
||||
💡 데이터 전달: 좌측 화면에 버튼 배치 후 transferData 액션 설정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시
|
||||
const hasLeftScreen = !!leftEmbedding;
|
||||
const hasRightScreen = !!rightEmbedding;
|
||||
|
||||
// 분할 패널 고유 ID 생성
|
||||
const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider
|
||||
splitPanelId={splitPanelId}
|
||||
leftScreenId={config?.leftScreenId || null}
|
||||
rightScreenId={config?.rightScreenId || null}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
||||
{hasLeftScreen ? (
|
||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{config?.resizable !== false && (
|
||||
<div
|
||||
className="group bg-border hover:bg-primary/20 relative w-1 flex-shrink-0 cursor-col-resize transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startRatio = splitRatio;
|
||||
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaRatio = (deltaX / containerWidth) * 100;
|
||||
handleResize(startRatio + deltaRatio);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
||||
{hasRightScreen ? (
|
||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SplitPanelProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 컴포넌트
|
||||
*/
|
||||
|
||||
export { EmbeddedScreen } from "./EmbeddedScreen";
|
||||
export { ScreenSplitPanel } from "./ScreenSplitPanel";
|
||||
|
||||
|
|
@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps {
|
|||
disabledFields?: string[];
|
||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||
isInModal?: boolean;
|
||||
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
originalData?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
groupedData,
|
||||
disabledFields = [],
|
||||
isInModal = false,
|
||||
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
|
@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
component={comp}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
|
|
|
|||
|
|
@ -528,9 +528,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
||||
if (path === "size.width" || path === "size.height" || path === "size") {
|
||||
if (!newComp.style) {
|
||||
newComp.style = {};
|
||||
}
|
||||
// 🔧 style 객체를 새로 복사하여 불변성 유지
|
||||
newComp.style = { ...(newComp.style || {}) };
|
||||
|
||||
if (path === "size.width") {
|
||||
newComp.style.width = `${value}px`;
|
||||
|
|
@ -839,18 +838,46 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 화면의 기본 테이블/REST API 정보 로드
|
||||
useEffect(() => {
|
||||
const loadScreenDataSource = async () => {
|
||||
console.log("🔍 [ScreenDesigner] 데이터 소스 로드 시작:", {
|
||||
screenId: selectedScreen?.screenId,
|
||||
screenName: selectedScreen?.screenName,
|
||||
dataSourceType: selectedScreen?.dataSourceType,
|
||||
tableName: selectedScreen?.tableName,
|
||||
restApiConnectionId: selectedScreen?.restApiConnectionId,
|
||||
restApiEndpoint: selectedScreen?.restApiEndpoint,
|
||||
restApiJsonPath: selectedScreen?.restApiJsonPath,
|
||||
});
|
||||
|
||||
// REST API 데이터 소스인 경우
|
||||
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
|
||||
// tableName이 restapi_로 시작하면 REST API로 간주
|
||||
const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
|
||||
selectedScreen?.tableName?.startsWith("restapi_") ||
|
||||
selectedScreen?.tableName?.startsWith("_restapi_");
|
||||
|
||||
if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) {
|
||||
try {
|
||||
// 연결 ID 추출 (restApiConnectionId가 없으면 tableName에서 추출)
|
||||
let connectionId = selectedScreen?.restApiConnectionId;
|
||||
if (!connectionId && selectedScreen?.tableName) {
|
||||
const match = selectedScreen.tableName.match(/restapi_(\d+)/);
|
||||
connectionId = match ? parseInt(match[1]) : undefined;
|
||||
}
|
||||
|
||||
if (!connectionId) {
|
||||
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
console.log("🌐 [ScreenDesigner] REST API 데이터 로드:", { connectionId });
|
||||
|
||||
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||
selectedScreen.restApiConnectionId,
|
||||
selectedScreen.restApiEndpoint,
|
||||
selectedScreen.restApiJsonPath || "data",
|
||||
connectionId,
|
||||
selectedScreen?.restApiEndpoint,
|
||||
selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경
|
||||
);
|
||||
|
||||
// REST API 응답에서 컬럼 정보 생성
|
||||
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
|
||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||
tableName: `restapi_${connectionId}`,
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
|
||||
|
|
@ -862,10 +889,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}));
|
||||
|
||||
const tableInfo: TableInfo = {
|
||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||
tableName: `restapi_${connectionId}`,
|
||||
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
|
||||
columns,
|
||||
};
|
||||
|
||||
console.log("✅ [ScreenDesigner] REST API 컬럼 로드 완료:", {
|
||||
tableName: tableInfo.tableName,
|
||||
tableLabel: tableInfo.tableLabel,
|
||||
columnsCount: columns.length,
|
||||
columns: columns.map(c => c.columnName),
|
||||
});
|
||||
|
||||
setTables([tableInfo]);
|
||||
console.log("REST API 데이터 소스 로드 완료:", {
|
||||
|
|
@ -996,6 +1030,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// console.log("🔧 기본 해상도 적용:", defaultResolution);
|
||||
}
|
||||
|
||||
// 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인
|
||||
const buttonComponents = layoutWithDefaultGrid.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button")
|
||||
);
|
||||
console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: c.componentType,
|
||||
actionType: c.componentConfig?.action?.type,
|
||||
fullAction: c.componentConfig?.action,
|
||||
})));
|
||||
|
||||
setLayout(layoutWithDefaultGrid);
|
||||
setHistory([layoutWithDefaultGrid]);
|
||||
setHistoryIndex(0);
|
||||
|
|
@ -1453,7 +1498,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary",
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
console.log("💾 저장 시작:", {
|
||||
screenId: selectedScreen.screenId,
|
||||
|
|
@ -1463,6 +1508,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
buttonComponents: buttonComponents.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
componentType: c.componentType,
|
||||
text: c.componentConfig?.text,
|
||||
actionType: c.componentConfig?.action?.type,
|
||||
fullAction: c.componentConfig?.action,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { cn } from "@/lib/utils";
|
|||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
import CreateScreenModal from "./CreateScreenModal";
|
||||
import CopyScreenModal from "./CopyScreenModal";
|
||||
import dynamic from "next/dynamic";
|
||||
|
|
@ -132,10 +133,18 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
description: "",
|
||||
isActive: "Y",
|
||||
tableName: "",
|
||||
dataSourceType: "database" as "database" | "restapi",
|
||||
restApiConnectionId: null as number | null,
|
||||
restApiEndpoint: "",
|
||||
restApiJsonPath: "data",
|
||||
});
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
|
||||
// REST API 연결 관련 상태 (편집용)
|
||||
const [editRestApiConnections, setEditRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||
const [editRestApiComboboxOpen, setEditRestApiComboboxOpen] = useState(false);
|
||||
|
||||
// 미리보기 관련 상태
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
|
|
@ -272,11 +281,19 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
|
||||
const handleEdit = async (screen: ScreenDefinition) => {
|
||||
setScreenToEdit(screen);
|
||||
|
||||
// 데이터 소스 타입 결정
|
||||
const isRestApi = screen.dataSourceType === "restapi" || screen.tableName?.startsWith("_restapi_");
|
||||
|
||||
setEditFormData({
|
||||
screenName: screen.screenName,
|
||||
description: screen.description || "",
|
||||
isActive: screen.isActive,
|
||||
tableName: screen.tableName || "",
|
||||
dataSourceType: isRestApi ? "restapi" : "database",
|
||||
restApiConnectionId: (screen as any).restApiConnectionId || null,
|
||||
restApiEndpoint: (screen as any).restApiEndpoint || "",
|
||||
restApiJsonPath: (screen as any).restApiJsonPath || "data",
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
|
||||
|
|
@ -298,14 +315,50 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
|
||||
// REST API 연결 목록 로드
|
||||
try {
|
||||
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setEditRestApiConnections(connections);
|
||||
} catch (error) {
|
||||
console.error("REST API 연결 목록 조회 실패:", error);
|
||||
setEditRestApiConnections([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!screenToEdit) return;
|
||||
|
||||
try {
|
||||
// 데이터 소스 타입에 따라 업데이트 데이터 구성
|
||||
const updateData: any = {
|
||||
screenName: editFormData.screenName,
|
||||
description: editFormData.description,
|
||||
isActive: editFormData.isActive,
|
||||
dataSourceType: editFormData.dataSourceType,
|
||||
};
|
||||
|
||||
if (editFormData.dataSourceType === "database") {
|
||||
updateData.tableName = editFormData.tableName;
|
||||
updateData.restApiConnectionId = null;
|
||||
updateData.restApiEndpoint = null;
|
||||
updateData.restApiJsonPath = null;
|
||||
} else {
|
||||
// REST API
|
||||
updateData.tableName = `_restapi_${editFormData.restApiConnectionId}`;
|
||||
updateData.restApiConnectionId = editFormData.restApiConnectionId;
|
||||
updateData.restApiEndpoint = editFormData.restApiEndpoint;
|
||||
updateData.restApiJsonPath = editFormData.restApiJsonPath || "data";
|
||||
}
|
||||
|
||||
console.log("📤 화면 편집 저장 요청:", {
|
||||
screenId: screenToEdit.screenId,
|
||||
editFormData,
|
||||
updateData,
|
||||
});
|
||||
|
||||
// 화면 정보 업데이트 API 호출
|
||||
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
|
||||
await screenApi.updateScreenInfo(screenToEdit.screenId, updateData);
|
||||
|
||||
// 선택된 테이블의 라벨 찾기
|
||||
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
|
||||
|
|
@ -318,10 +371,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
? {
|
||||
...s,
|
||||
screenName: editFormData.screenName,
|
||||
tableName: editFormData.tableName,
|
||||
tableName: updateData.tableName,
|
||||
tableLabel: tableLabel,
|
||||
description: editFormData.description,
|
||||
isActive: editFormData.isActive,
|
||||
dataSourceType: editFormData.dataSourceType,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
|
|
@ -1216,65 +1270,184 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
placeholder="화면명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tableName">테이블 *</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
{loadingTables
|
||||
? "로딩 중..."
|
||||
: editFormData.tableName
|
||||
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
|
||||
: "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel}`}
|
||||
onSelect={() => {
|
||||
setEditFormData({ ...editFormData, tableName: table.tableName });
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel}</span>
|
||||
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<Select
|
||||
value={editFormData.dataSourceType}
|
||||
onValueChange={(value: "database" | "restapi") => {
|
||||
setEditFormData({
|
||||
...editFormData,
|
||||
dataSourceType: value,
|
||||
tableName: "",
|
||||
restApiConnectionId: null,
|
||||
restApiEndpoint: "",
|
||||
restApiJsonPath: "data",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="database">데이터베이스</SelectItem>
|
||||
<SelectItem value="restapi">REST API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 데이터베이스 선택 (database 타입인 경우) */}
|
||||
{editFormData.dataSourceType === "database" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tableName">테이블 *</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
{loadingTables
|
||||
? "로딩 중..."
|
||||
: editFormData.tableName
|
||||
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
|
||||
: "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel}`}
|
||||
onSelect={() => {
|
||||
setEditFormData({ ...editFormData, tableName: table.tableName });
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel}</span>
|
||||
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* REST API 선택 (restapi 타입인 경우) */}
|
||||
{editFormData.dataSourceType === "restapi" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>REST API 연결 *</Label>
|
||||
<Popover open={editRestApiComboboxOpen} onOpenChange={setEditRestApiComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={editRestApiComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{editFormData.restApiConnectionId
|
||||
? editRestApiConnections.find((c) => c.id === editFormData.restApiConnectionId)?.connection_name || "선택된 연결"
|
||||
: "REST API 연결 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="연결 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
연결을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{editRestApiConnections.map((conn) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={conn.connection_name}
|
||||
onSelect={() => {
|
||||
setEditFormData({ ...editFormData, restApiConnectionId: conn.id || null });
|
||||
setEditRestApiComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
editFormData.restApiConnectionId === conn.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-[10px] text-gray-500">{conn.base_url}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-restApiEndpoint">API 엔드포인트</Label>
|
||||
<Input
|
||||
id="edit-restApiEndpoint"
|
||||
value={editFormData.restApiEndpoint}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, restApiEndpoint: e.target.value })}
|
||||
placeholder="예: /api/data/list"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
데이터를 조회할 API 엔드포인트 경로입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-restApiJsonPath">JSON 경로</Label>
|
||||
<Input
|
||||
id="edit-restApiJsonPath"
|
||||
value={editFormData.restApiJsonPath}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, restApiJsonPath: e.target.value })}
|
||||
placeholder="예: data 또는 result.items"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
응답 JSON에서 데이터 배열의 경로입니다 (기본: data)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">설명</Label>
|
||||
<Textarea
|
||||
|
|
@ -1305,7 +1478,14 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim() || !editFormData.tableName.trim()}>
|
||||
<Button
|
||||
onClick={handleEditSave}
|
||||
disabled={
|
||||
!editFormData.screenName.trim() ||
|
||||
(editFormData.dataSourceType === "database" && !editFormData.tableName.trim()) ||
|
||||
(editFormData.dataSourceType === "restapi" && !editFormData.restApiConnectionId)
|
||||
}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||
const freshConfig = { ...newConfig };
|
||||
console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", {
|
||||
widgetId: widget.id,
|
||||
widgetLabel: widget.label,
|
||||
widgetType: widget.widgetType,
|
||||
newConfig: freshConfig,
|
||||
});
|
||||
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
|
||||
|
||||
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}) => {
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
|
||||
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
||||
const [localHeight, setLocalHeight] = useState<string>("");
|
||||
const [localWidth, setLocalWidth] = useState<string>("");
|
||||
|
|
@ -147,7 +147,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}
|
||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||
|
||||
|
||||
// 높이 값 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.size?.height !== undefined) {
|
||||
|
|
@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = currentResolution
|
||||
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||
? Math.floor(
|
||||
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
|
||||
(MIN_COLUMN_WIDTH + gridSettings.gap),
|
||||
)
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
|
|
@ -189,7 +192,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Grid3X3 className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">격자 설정</h4>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 토글들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
{/* 10px 단위 스냅 안내 */}
|
||||
<div className="bg-muted/50 rounded-md p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 컴포넌트는 10px 단위로 자동 배치됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white overflow-hidden">
|
||||
<div className="flex h-full flex-col overflow-x-auto bg-white">
|
||||
{/* 해상도 설정과 격자 설정 표시 */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
@ -287,9 +288,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
if (!selectedComponent) return null;
|
||||
|
||||
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
selectedComponent.type;
|
||||
|
||||
|
|
@ -305,15 +306,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
||||
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
|
@ -325,29 +326,41 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
|
||||
const handlePanelConfigChange = (newConfig: any) => {
|
||||
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
||||
const mergedConfig = {
|
||||
...currentConfig, // 기존 설정 유지
|
||||
...newConfig, // 새 설정 병합
|
||||
};
|
||||
console.log("🔧 [ConfigPanel] handleConfigChange:", {
|
||||
componentId: selectedComponent.id,
|
||||
currentConfig,
|
||||
newConfig,
|
||||
mergedConfig,
|
||||
});
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" key={selectedComponent.id}>
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<Settings className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground text-sm">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handlePanelConfigChange}
|
||||
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
|
|
@ -414,9 +427,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Card 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">제목과 테두리가 있는 명확한 그룹화 컨테이너</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
|
|
@ -428,7 +439,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="showHeader" className="cursor-pointer text-xs">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -458,7 +469,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
|
||||
}}
|
||||
placeholder="섹션 설명 입력"
|
||||
className="text-xs resize-none"
|
||||
className="resize-none text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -526,7 +537,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 접기/펼치기 기능 */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="collapsible"
|
||||
|
|
@ -535,13 +546,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="collapsible" className="cursor-pointer text-xs">
|
||||
접기/펼치기 가능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{selectedComponent.componentConfig?.collapsible && (
|
||||
<div className="flex items-center space-x-2 ml-6">
|
||||
<div className="ml-6 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="defaultOpen"
|
||||
checked={selectedComponent.componentConfig?.defaultOpen !== false}
|
||||
|
|
@ -549,7 +560,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
|
||||
기본으로 펼치기
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -563,9 +574,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Paper 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
배경색 기반의 미니멀한 그룹화 컨테이너
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">배경색 기반의 미니멀한 그룹화 컨테이너</p>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
|
|
@ -676,7 +685,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="showBorder" className="cursor-pointer text-xs">
|
||||
미묘한 테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -687,9 +696,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// ConfigPanel이 없는 경우 경고 표시
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-base font-medium">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1403,7 +1412,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white overflow-hidden">
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 헤더 - 간소화 */}
|
||||
<div className="border-border border-b px-3 py-2">
|
||||
{selectedComponent.type === "widget" && (
|
||||
|
|
@ -1414,7 +1423,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 통합 컨텐츠 (탭 제거) */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getSummaryReport,
|
||||
getDailyReport,
|
||||
getMonthlyReport,
|
||||
getDriverReport,
|
||||
getRouteReport,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
SummaryReport,
|
||||
DailyStat,
|
||||
MonthlyStat,
|
||||
DriverStat,
|
||||
RouteStat,
|
||||
} from "@/lib/api/vehicleTrip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
RefreshCw,
|
||||
Car,
|
||||
Route,
|
||||
Clock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
||||
export default function VehicleReport() {
|
||||
// 요약 통계
|
||||
const [summary, setSummary] = useState<SummaryReport | null>(null);
|
||||
const [summaryPeriod, setSummaryPeriod] = useState("month");
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
|
||||
// 일별 통계
|
||||
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
|
||||
const [dailyStartDate, setDailyStartDate] = useState(
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
|
||||
);
|
||||
const [dailyEndDate, setDailyEndDate] = useState(
|
||||
new Date().toISOString().split("T")[0]
|
||||
);
|
||||
const [dailyLoading, setDailyLoading] = useState(false);
|
||||
|
||||
// 월별 통계
|
||||
const [monthlyData, setMonthlyData] = useState<MonthlyStat[]>([]);
|
||||
const [monthlyYear, setMonthlyYear] = useState(new Date().getFullYear());
|
||||
const [monthlyLoading, setMonthlyLoading] = useState(false);
|
||||
|
||||
// 운전자별 통계
|
||||
const [driverData, setDriverData] = useState<DriverStat[]>([]);
|
||||
const [driverLoading, setDriverLoading] = useState(false);
|
||||
|
||||
// 구간별 통계
|
||||
const [routeData, setRouteData] = useState<RouteStat[]>([]);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
|
||||
// 요약 로드
|
||||
const loadSummary = useCallback(async () => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const response = await getSummaryReport(summaryPeriod);
|
||||
if (response.success) {
|
||||
setSummary(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("요약 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, [summaryPeriod]);
|
||||
|
||||
// 일별 로드
|
||||
const loadDaily = useCallback(async () => {
|
||||
setDailyLoading(true);
|
||||
try {
|
||||
const response = await getDailyReport({
|
||||
startDate: dailyStartDate,
|
||||
endDate: dailyEndDate,
|
||||
});
|
||||
if (response.success) {
|
||||
setDailyData(response.data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("일별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setDailyLoading(false);
|
||||
}
|
||||
}, [dailyStartDate, dailyEndDate]);
|
||||
|
||||
// 월별 로드
|
||||
const loadMonthly = useCallback(async () => {
|
||||
setMonthlyLoading(true);
|
||||
try {
|
||||
const response = await getMonthlyReport({ year: monthlyYear });
|
||||
if (response.success) {
|
||||
setMonthlyData(response.data?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("월별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setMonthlyLoading(false);
|
||||
}
|
||||
}, [monthlyYear]);
|
||||
|
||||
// 운전자별 로드
|
||||
const loadDrivers = useCallback(async () => {
|
||||
setDriverLoading(true);
|
||||
try {
|
||||
const response = await getDriverReport({ limit: 20 });
|
||||
if (response.success) {
|
||||
setDriverData(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("운전자별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setDriverLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 구간별 로드
|
||||
const loadRoutes = useCallback(async () => {
|
||||
setRouteLoading(true);
|
||||
try {
|
||||
const response = await getRouteReport({ limit: 20 });
|
||||
if (response.success) {
|
||||
setRouteData(response.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("구간별 통계 조회 실패:", error);
|
||||
} finally {
|
||||
setRouteLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
// 기간 레이블
|
||||
const getPeriodLabel = (period: string) => {
|
||||
switch (period) {
|
||||
case "today":
|
||||
return "오늘";
|
||||
case "week":
|
||||
return "최근 7일";
|
||||
case "month":
|
||||
return "최근 30일";
|
||||
case "year":
|
||||
return "올해";
|
||||
default:
|
||||
return period;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 요약 통계 카드 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">요약 통계</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={summaryPeriod} onValueChange={setSummaryPeriod}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">오늘</SelectItem>
|
||||
<SelectItem value="week">최근 7일</SelectItem>
|
||||
<SelectItem value="month">최근 30일</SelectItem>
|
||||
<SelectItem value="year">올해</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadSummary}
|
||||
disabled={summaryLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${summaryLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Car className="h-3 w-3" />
|
||||
총 운행
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{summary.totalTrips.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getPeriodLabel(summaryPeriod)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
완료율
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{summary.completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary.completedTrips} / {summary.totalTrips}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Route className="h-3 w-3" />
|
||||
총 거리
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{formatDistance(summary.totalDistance)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
평균 {formatDistance(summary.avgDistance)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
총 시간
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{formatDuration(summary.totalDuration)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
평균 {formatDuration(Math.round(summary.avgDuration))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
운전자
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold">
|
||||
{summary.activeDrivers}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">활동 중</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Car className="h-3 w-3" />
|
||||
진행 중
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-green-600">
|
||||
{summary.activeTrips}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">현재 운행</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 통계 탭 */}
|
||||
<Tabs defaultValue="daily" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="daily" onClick={loadDaily}>
|
||||
일별 통계
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monthly" onClick={loadMonthly}>
|
||||
월별 통계
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drivers" onClick={loadDrivers}>
|
||||
운전자별
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routes" onClick={loadRoutes}>
|
||||
구간별
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 일별 통계 */}
|
||||
<TabsContent value="daily">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">일별 운행 통계</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">시작</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dailyStartDate}
|
||||
onChange={(e) => setDailyStartDate(e.target.value)}
|
||||
className="h-8 w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-xs">종료</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dailyEndDate}
|
||||
onChange={(e) => setDailyEndDate(e.target.value)}
|
||||
className="h-8 w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={loadDaily} disabled={dailyLoading}>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>날짜</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">완료</TableHead>
|
||||
<TableHead className="text-right">취소</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">총 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dailyLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dailyData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
dailyData.map((row) => (
|
||||
<TableRow key={row.date}>
|
||||
<TableCell>
|
||||
{format(new Date(row.date), "MM/dd (E)", {
|
||||
locale: ko,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.completedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.cancelledCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(row.totalDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 월별 통계 */}
|
||||
<TabsContent value="monthly">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">월별 운행 통계</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(monthlyYear)}
|
||||
onValueChange={(v) => setMonthlyYear(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[0, 1, 2].map((offset) => {
|
||||
const year = new Date().getFullYear() - offset;
|
||||
return (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}년
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={loadMonthly}
|
||||
disabled={monthlyLoading}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>월</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">완료</TableHead>
|
||||
<TableHead className="text-right">취소</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">운전자 수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : monthlyData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
monthlyData.map((row) => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell>{row.month}월</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.completedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.cancelledCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.driverCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 운전자별 통계 */}
|
||||
<TabsContent value="drivers">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">운전자별 통계</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={loadDrivers}
|
||||
disabled={driverLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${driverLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>운전자</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">완료</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">총 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{driverLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : driverData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
driverData.map((row) => (
|
||||
<TableRow key={row.userId}>
|
||||
<TableCell className="font-medium">
|
||||
{row.userName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.completedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(row.totalDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 구간별 통계 */}
|
||||
<TabsContent value="routes">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">구간별 통계</CardTitle>
|
||||
<Button size="sm" onClick={loadRoutes} disabled={routeLoading}>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${routeLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
출발지
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
도착지
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">운행 수</TableHead>
|
||||
<TableHead className="text-right">총 거리</TableHead>
|
||||
<TableHead className="text-right">평균 거리</TableHead>
|
||||
<TableHead className="text-right">평균 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{routeLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : routeData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
routeData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{row.departureName}</TableCell>
|
||||
<TableCell>{row.destinationName}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.tripCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.totalDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistance(row.avgDistance)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDuration(Math.round(row.avgDuration))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getTripList,
|
||||
getTripDetail,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
getStatusLabel,
|
||||
getStatusColor,
|
||||
TripSummary,
|
||||
TripDetail,
|
||||
TripListFilters,
|
||||
} from "@/lib/api/vehicleTrip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
MapPin,
|
||||
Clock,
|
||||
Route,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function VehicleTripHistory() {
|
||||
// 상태
|
||||
const [trips, setTrips] = useState<TripSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 필터
|
||||
const [filters, setFilters] = useState<TripListFilters>({
|
||||
status: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
departure: "",
|
||||
arrival: "",
|
||||
});
|
||||
|
||||
// 상세 모달
|
||||
const [selectedTrip, setSelectedTrip] = useState<TripDetail | null>(null);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadTrips = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTripList({
|
||||
...filters,
|
||||
status: filters.status || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined,
|
||||
departure: filters.departure || undefined,
|
||||
arrival: filters.arrival || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setTrips(response.data || []);
|
||||
setTotal(response.total || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("운행 이력 조회 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTrips();
|
||||
}, [loadTrips]);
|
||||
|
||||
// 상세 조회
|
||||
const handleViewDetail = async (tripId: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailModalOpen(true);
|
||||
try {
|
||||
const response = await getTripDetail(tripId);
|
||||
if (response.success && response.data) {
|
||||
setSelectedTrip(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("운행 상세 조회 실패:", error);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 변경
|
||||
const handleFilterChange = (key: keyof TripListFilters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadTrips();
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setFilters({
|
||||
status: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
departure: "",
|
||||
arrival: "",
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필터 영역 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">검색 조건</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{/* 상태 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select
|
||||
value={filters.status || "all"}
|
||||
onValueChange={(v) =>
|
||||
handleFilterChange("status", v === "all" ? "" : v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="active">운행 중</SelectItem>
|
||||
<SelectItem value="completed">완료</SelectItem>
|
||||
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 시작일 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.startDate || ""}
|
||||
onChange={(e) => handleFilterChange("startDate", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종료일 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.endDate || ""}
|
||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 출발지 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">출발지</Label>
|
||||
<Input
|
||||
placeholder="출발지"
|
||||
value={filters.departure || ""}
|
||||
onChange={(e) => handleFilterChange("departure", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">도착지</Label>
|
||||
<Input
|
||||
placeholder="도착지"
|
||||
value={filters.arrival || ""}
|
||||
onChange={(e) => handleFilterChange("arrival", e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={handleSearch} size="sm">
|
||||
<Search className="mr-1 h-4 w-4" />
|
||||
검색
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline" size="sm">
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
운행 이력 ({total.toLocaleString()}건)
|
||||
</CardTitle>
|
||||
<Button onClick={loadTrips} variant="ghost" size="sm">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[120px]">운행ID</TableHead>
|
||||
<TableHead>운전자</TableHead>
|
||||
<TableHead>출발지</TableHead>
|
||||
<TableHead>도착지</TableHead>
|
||||
<TableHead>시작 시간</TableHead>
|
||||
<TableHead>종료 시간</TableHead>
|
||||
<TableHead className="text-right">거리</TableHead>
|
||||
<TableHead className="text-right">시간</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-24 text-center">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : trips.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-24 text-center">
|
||||
운행 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
trips.map((trip) => (
|
||||
<TableRow key={trip.trip_id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{trip.trip_id.substring(0, 15)}...
|
||||
</TableCell>
|
||||
<TableCell>{trip.user_name || trip.user_id}</TableCell>
|
||||
<TableCell>{trip.departure_name || trip.departure || "-"}</TableCell>
|
||||
<TableCell>{trip.destination_name || trip.arrival || "-"}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{format(new Date(trip.start_time), "MM/dd HH:mm", {
|
||||
locale: ko,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{trip.end_time
|
||||
? format(new Date(trip.end_time), "MM/dd HH:mm", {
|
||||
locale: ko,
|
||||
})
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{trip.total_distance
|
||||
? formatDistance(Number(trip.total_distance))
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{trip.duration_minutes
|
||||
? formatDuration(trip.duration_minutes)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={getStatusColor(trip.status)}>
|
||||
{getStatusLabel(trip.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(trip.trip_id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상세 모달 */}
|
||||
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>운행 상세 정보</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : selectedTrip ? (
|
||||
<div className="space-y-4">
|
||||
{/* 요약 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
출발지
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.departure_name ||
|
||||
selectedTrip.summary.departure ||
|
||||
"-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
도착지
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.destination_name ||
|
||||
selectedTrip.summary.arrival ||
|
||||
"-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Route className="h-3 w-3" />
|
||||
총 거리
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.total_distance
|
||||
? formatDistance(Number(selectedTrip.summary.total_distance))
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
운행 시간
|
||||
</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{selectedTrip.summary.duration_minutes
|
||||
? formatDuration(selectedTrip.summary.duration_minutes)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 운행 정보 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 font-medium">운행 정보</h4>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">운행 ID</span>
|
||||
<span className="font-mono text-xs">
|
||||
{selectedTrip.summary.trip_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">운전자</span>
|
||||
<span>
|
||||
{selectedTrip.summary.user_name ||
|
||||
selectedTrip.summary.user_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">시작 시간</span>
|
||||
<span>
|
||||
{format(
|
||||
new Date(selectedTrip.summary.start_time),
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
{ locale: ko }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">종료 시간</span>
|
||||
<span>
|
||||
{selectedTrip.summary.end_time
|
||||
? format(
|
||||
new Date(selectedTrip.summary.end_time),
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
{ locale: ko }
|
||||
)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">상태</span>
|
||||
<Badge className={getStatusColor(selectedTrip.summary.status)}>
|
||||
{getStatusLabel(selectedTrip.summary.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">위치 기록 수</span>
|
||||
<span>{selectedTrip.summary.location_count}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 경로 데이터 */}
|
||||
{selectedTrip.route && selectedTrip.route.length > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 font-medium">
|
||||
경로 데이터 ({selectedTrip.route.length}개 지점)
|
||||
</h4>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">#</TableHead>
|
||||
<TableHead>위도</TableHead>
|
||||
<TableHead>경도</TableHead>
|
||||
<TableHead>정확도</TableHead>
|
||||
<TableHead>이전 거리</TableHead>
|
||||
<TableHead>기록 시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTrip.route.map((loc, idx) => (
|
||||
<TableRow key={loc.id}>
|
||||
<TableCell className="text-xs">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{loc.latitude.toFixed(6)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{loc.longitude.toFixed(6)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{loc.accuracy ? `${loc.accuracy.toFixed(0)}m` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{loc.distance_from_prev
|
||||
? formatDistance(Number(loc.distance_from_prev))
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{format(new Date(loc.recorded_at), "HH:mm:ss", {
|
||||
locale: ko,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -8,8 +8,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||
|
|
@ -21,6 +22,7 @@ export interface RepeaterInputProps {
|
|||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
menuObjid?: number; // 카테고리 조회용 메뉴 ID
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +36,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
disabled = false,
|
||||
readonly = false,
|
||||
className,
|
||||
menuObjid,
|
||||
}) => {
|
||||
// 현재 브레이크포인트 감지
|
||||
const globalBreakpoint = useBreakpoint();
|
||||
|
|
@ -42,6 +45,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용
|
||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
||||
|
||||
// 설정 기본값
|
||||
const {
|
||||
fields = [],
|
||||
|
|
@ -72,6 +78,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 접힌 상태 관리 (각 항목별)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||
const initialCalcDoneRef = useRef(false);
|
||||
|
||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||
const deletedItemIdsRef = useRef<string[]>([]);
|
||||
|
||||
// 빈 항목 생성
|
||||
function createEmptyItem(): RepeaterItemData {
|
||||
|
|
@ -82,10 +94,39 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
return item;
|
||||
}
|
||||
|
||||
// 외부 value 변경 시 동기화
|
||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
setItems(value);
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
|
||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||
const updatedValue = value.map(item => {
|
||||
const updatedItem = { ...item };
|
||||
let hasChange = false;
|
||||
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||
updatedItem[calcField.name] = calculatedValue;
|
||||
hasChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
setItems(updatedValue);
|
||||
initialCalcDoneRef.current = true;
|
||||
|
||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||
const dataWithMeta = config.targetTable
|
||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: updatedValue;
|
||||
onChange?.(dataWithMeta);
|
||||
} else {
|
||||
setItems(value);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
|
|
@ -111,14 +152,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||
const removedItem = items[index];
|
||||
if (removedItem?.id) {
|
||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||
}
|
||||
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
}))
|
||||
: newItems;
|
||||
|
||||
console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta);
|
||||
onChange?.(dataWithMeta);
|
||||
|
||||
// 접힌 상태도 업데이트
|
||||
|
|
@ -134,6 +193,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
...newItems[itemIndex],
|
||||
[fieldName]: value,
|
||||
};
|
||||
|
||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||
if (calculatedValue !== null) {
|
||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||
}
|
||||
});
|
||||
|
||||
setItems(newItems);
|
||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||
itemIndex,
|
||||
|
|
@ -143,8 +212,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
});
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
// 🆕 삭제된 항목 ID 목록도 유지
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
}))
|
||||
: newItems;
|
||||
|
||||
onChange?.(dataWithMeta);
|
||||
|
|
@ -192,24 +268,183 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 계산식 실행
|
||||
* @param formula 계산식 정의
|
||||
* @param item 현재 항목 데이터
|
||||
* @returns 계산 결과
|
||||
*/
|
||||
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
||||
if (!formula || !formula.field1) return null;
|
||||
|
||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||
const value2 = formula.field2
|
||||
? (parseFloat(item[formula.field2]) || 0)
|
||||
: (formula.constantValue ?? 0);
|
||||
|
||||
let result: number;
|
||||
|
||||
switch (formula.operator) {
|
||||
case "+":
|
||||
result = value1 + value2;
|
||||
break;
|
||||
case "-":
|
||||
result = value1 - value2;
|
||||
break;
|
||||
case "*":
|
||||
result = value1 * value2;
|
||||
break;
|
||||
case "/":
|
||||
result = value2 !== 0 ? value1 / value2 : 0;
|
||||
break;
|
||||
case "%":
|
||||
result = value2 !== 0 ? value1 % value2 : 0;
|
||||
break;
|
||||
case "round":
|
||||
const decimalPlaces = formula.decimalPlaces ?? 0;
|
||||
const multiplier = Math.pow(10, decimalPlaces);
|
||||
result = Math.round(value1 * multiplier) / multiplier;
|
||||
break;
|
||||
case "floor":
|
||||
const floorMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
|
||||
result = Math.floor(value1 * floorMultiplier) / floorMultiplier;
|
||||
break;
|
||||
case "ceil":
|
||||
const ceilMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
|
||||
result = Math.ceil(value1 * ceilMultiplier) / ceilMultiplier;
|
||||
break;
|
||||
case "abs":
|
||||
result = Math.abs(value1);
|
||||
break;
|
||||
default:
|
||||
result = value1;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅
|
||||
* @param value 숫자 값
|
||||
* @param format 포맷 설정
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
const formatNumber = (
|
||||
value: number | null,
|
||||
format?: RepeaterFieldDefinition["numberFormat"]
|
||||
): string => {
|
||||
if (value === null || isNaN(value)) return "-";
|
||||
|
||||
let formattedValue = value;
|
||||
|
||||
// 소수점 자릿수 적용
|
||||
if (format?.decimalPlaces !== undefined) {
|
||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||
}
|
||||
|
||||
// 천 단위 구분자
|
||||
let result = format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
// 접두사/접미사 추가
|
||||
if (format?.prefix) result = format.prefix + result;
|
||||
if (format?.suffix) result = result + format.suffix;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const isReadonly = disabled || readonly || field.readonly;
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: disabled || readonly,
|
||||
disabled: isReadonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
// 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용)
|
||||
if (field.type === "calculated") {
|
||||
const item = items[itemIndex];
|
||||
const calculatedValue = calculateValue(field.formula, item);
|
||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||
|
||||
return (
|
||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
||||
{formattedValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||
if (field.type === "category") {
|
||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||
|
||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||
const mapping = categoryMappings[field.name];
|
||||
const valueStr = String(value); // 값을 문자열로 변환
|
||||
const categoryData = mapping?.[valueStr];
|
||||
const displayLabel = categoryData?.label || valueStr;
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||
|
||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||
fieldName: field.name,
|
||||
value: valueStr,
|
||||
mapping,
|
||||
categoryData,
|
||||
displayLabel,
|
||||
displayColor,
|
||||
});
|
||||
|
||||
// 색상이 "none"이면 일반 텍스트로 표시
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 읽기 전용 모드: 텍스트로 표시
|
||||
// displayMode가 "readonly"이면 isReadonly 여부와 관계없이 텍스트로 표시
|
||||
if (field.displayMode === "readonly") {
|
||||
// select 타입인 경우 옵션에서 라벨 찾기
|
||||
if (field.type === "select" && value && field.options) {
|
||||
const option = field.options.find(opt => opt.value === value);
|
||||
return <span className="text-sm">{option?.label || value}</span>;
|
||||
}
|
||||
|
||||
// 일반 텍스트
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
|
||||
disabled={disabled || readonly}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="w-full min-w-[80px]">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -228,7 +463,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
className="resize-none min-w-[100px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -238,10 +473,45 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||
|
||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||
if (isReadonly) {
|
||||
return (
|
||||
<span className="text-sm min-w-[80px] inline-block">
|
||||
{formattedDisplay}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||
return (
|
||||
<div className="relative min-w-[80px]">
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="number"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className="pr-1"
|
||||
/>
|
||||
{value && (
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{formattedDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
|
|
@ -249,6 +519,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className="min-w-[80px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -258,6 +529,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
type="email"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -267,6 +539,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
type="tel"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -277,11 +550,69 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
type="text"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
maxLength={field.validation?.maxLength}
|
||||
className="min-w-[80px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드)
|
||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
if (categoryFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name; // 실제 컬럼명
|
||||
const categoryCode = field.categoryCode || columnName;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
// config에서 targetTable 가져오기, 없으면 스킵
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) {
|
||||
console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`);
|
||||
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 컬럼명과 동일)
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryMappings();
|
||||
}, [fields, config.targetTable]);
|
||||
|
||||
// 필드가 정의되지 않았을 때
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
|
|
@ -324,18 +655,18 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
{showIndex && (
|
||||
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold">#</TableHead>
|
||||
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold">#</TableHead>
|
||||
)}
|
||||
{allowReorder && (
|
||||
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold"></TableHead>
|
||||
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
|
||||
)}
|
||||
{fields.map((field) => (
|
||||
<TableHead key={field.name} className="h-12 px-6 py-3 text-sm font-semibold">
|
||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="h-12 w-20 px-6 py-3 text-center text-sm font-semibold">작업</TableHead>
|
||||
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -354,27 +685,27 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<TableCell className="h-16 px-6 py-3 text-center text-sm font-medium">
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
||||
{itemIndex + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<TableCell className="h-16 px-6 py-3 text-center">
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 필드들 */}
|
||||
{fields.map((field) => (
|
||||
<TableCell key={field.name} className="h-16 px-6 py-3">
|
||||
<TableCell key={field.name} className="h-12 px-2.5 py-2">
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<TableCell className="h-16 px-6 py-3 text-center">
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -192,6 +192,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<p className="text-xs text-gray-500">반복 필드 데이터를 저장할 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹화 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">수정 시 그룹화 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={config.groupByColumn || "__none__"}
|
||||
onValueChange={(value) => handleChange("groupByColumn", value === "__none__" ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="그룹화 컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">사용 안함</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
수정 모드에서 이 컬럼 값을 기준으로 관련된 모든 데이터를 조회합니다.
|
||||
<br />
|
||||
예: 입고번호를 선택하면 같은 입고번호를 가진 모든 품목이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필드 정의 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
|
@ -235,10 +261,23 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||
const col = column as any;
|
||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||
|
||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||
columnName: column.columnName,
|
||||
input_type: col.input_type,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
widgetType: col.widgetType,
|
||||
finalType: fieldType,
|
||||
});
|
||||
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: (column.widgetType as RepeaterFieldType) || "text",
|
||||
type: fieldType as RepeaterFieldType,
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
|
|
@ -293,13 +332,25 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택박스</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역</SelectItem>
|
||||
{/* 테이블 타입 관리에서 사용하는 input_type 목록 */}
|
||||
<SelectItem value="text">텍스트 (text)</SelectItem>
|
||||
<SelectItem value="number">숫자 (number)</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역 (textarea)</SelectItem>
|
||||
<SelectItem value="date">날짜 (date)</SelectItem>
|
||||
<SelectItem value="select">선택박스 (select)</SelectItem>
|
||||
<SelectItem value="checkbox">체크박스 (checkbox)</SelectItem>
|
||||
<SelectItem value="radio">라디오 (radio)</SelectItem>
|
||||
<SelectItem value="category">카테고리 (category)</SelectItem>
|
||||
<SelectItem value="entity">엔티티 참조 (entity)</SelectItem>
|
||||
<SelectItem value="code">공통코드 (code)</SelectItem>
|
||||
<SelectItem value="image">이미지 (image)</SelectItem>
|
||||
<SelectItem value="direct">직접입력 (direct)</SelectItem>
|
||||
<SelectItem value="calculated">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calculator className="h-3 w-3" />
|
||||
계산식 (calculated)
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -316,16 +367,316 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
{/* 계산식 타입일 때 계산식 설정 */}
|
||||
{field.type === "calculated" && (
|
||||
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||
</div>
|
||||
|
||||
{/* 필드 1 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||
<Select
|
||||
value={field.formula?.field1 || ""}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{localFields
|
||||
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
|
||||
.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||
<Select
|
||||
value={field.formula?.operator || "+"}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">× 곱하기</SelectItem>
|
||||
<SelectItem value="/" className="text-xs">÷ 나누기</SelectItem>
|
||||
<SelectItem value="%" className="text-xs">% 나머지</SelectItem>
|
||||
<SelectItem value="round" className="text-xs">반올림</SelectItem>
|
||||
<SelectItem value="floor" className="text-xs">내림</SelectItem>
|
||||
<SelectItem value="ceil" className="text-xs">올림</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 필드 또는 상수값 */}
|
||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||
<Select
|
||||
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
|
||||
onValueChange={(value) => {
|
||||
if (value.startsWith("__const__")) {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0
|
||||
} as CalculationFormula
|
||||
});
|
||||
} else {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined
|
||||
} as CalculationFormula
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{localFields
|
||||
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
|
||||
.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__const__0" className="text-xs text-blue-600">
|
||||
상수값 입력
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">소수점 자릿수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={field.formula?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상수값 입력 필드 */}
|
||||
{field.formula?.constantValue !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">상수값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.formula.constantValue}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
placeholder="숫자 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 숫자 포맷 설정 */}
|
||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
/>
|
||||
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||
천 단위 구분자
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-[10px]">소수점:</Label>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
className="h-6 w-12 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산식 미리보기 */}
|
||||
<div className="rounded bg-white p-2 text-xs">
|
||||
<span className="text-gray-500">계산식: </span>
|
||||
<code className="font-mono text-blue-700">
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
||||
field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
||||
}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 숫자 타입일 때 숫자 표시 형식 설정 */}
|
||||
{field.type === "number" && (
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-xs font-semibold text-gray-700">숫자 표시 형식</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`number-thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
/>
|
||||
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||
천 단위 구분자
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-[10px]">소수점:</Label>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
className="h-6 w-12 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입일 때 카테고리 코드 입력 */}
|
||||
{field.type === "category" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">카테고리 코드</Label>
|
||||
<Input
|
||||
value={field.categoryCode || field.name || ""}
|
||||
onChange={(e) => updateField(index, { categoryCode: e.target.value })}
|
||||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
|
||||
{field.type !== "category" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시 모드</Label>
|
||||
<Select
|
||||
value={field.displayMode || "input"}
|
||||
onValueChange={(value) => updateField(index, { displayMode: value as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="input">입력 (편집 가능)</SelectItem>
|
||||
<SelectItem value="readonly">읽기전용 (텍스트)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입일 때는 필수만 표시 */}
|
||||
{field.type === "category" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* 화면 컨텍스트
|
||||
* 같은 화면 내의 컴포넌트들이 서로 통신할 수 있도록 합니다.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
||||
interface ScreenContextValue {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
// 컴포넌트 등록
|
||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||
unregisterDataProvider: (componentId: string) => void;
|
||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||
unregisterDataReceiver: (componentId: string) => void;
|
||||
|
||||
// 컴포넌트 조회
|
||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||
|
||||
// 모든 컴포넌트 조회
|
||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||
}
|
||||
|
||||
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
||||
|
||||
interface ScreenContextProviderProps {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 프로바이더
|
||||
*/
|
||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
||||
dataProvidersRef.current.set(componentId, provider);
|
||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||
}, []);
|
||||
|
||||
const unregisterDataProvider = useCallback((componentId: string) => {
|
||||
dataProvidersRef.current.delete(componentId);
|
||||
logger.debug("데이터 제공자 해제", { componentId });
|
||||
}, []);
|
||||
|
||||
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
||||
dataReceiversRef.current.set(componentId, receiver);
|
||||
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
||||
}, []);
|
||||
|
||||
const unregisterDataReceiver = useCallback((componentId: string) => {
|
||||
dataReceiversRef.current.delete(componentId);
|
||||
logger.debug("데이터 수신자 해제", { componentId });
|
||||
}, []);
|
||||
|
||||
const getDataProvider = useCallback((componentId: string) => {
|
||||
return dataProvidersRef.current.get(componentId);
|
||||
}, []);
|
||||
|
||||
const getDataReceiver = useCallback((componentId: string) => {
|
||||
return dataReceiversRef.current.get(componentId);
|
||||
}, []);
|
||||
|
||||
const getAllDataProviders = useCallback(() => {
|
||||
return new Map(dataProvidersRef.current);
|
||||
}, []);
|
||||
|
||||
const getAllDataReceivers = useCallback(() => {
|
||||
return new Map(dataReceiversRef.current);
|
||||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}), [
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
]);
|
||||
|
||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 훅
|
||||
*/
|
||||
export function useScreenContext() {
|
||||
const context = useContext(ScreenContext);
|
||||
if (!context) {
|
||||
throw new Error("useScreenContext는 ScreenContextProvider 내부에서만 사용할 수 있습니다.");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 훅 (선택적)
|
||||
* 컨텍스트가 없어도 에러를 발생시키지 않습니다.
|
||||
*/
|
||||
export function useScreenContextOptional() {
|
||||
return useContext(ScreenContext);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
|
||||
/**
|
||||
* 분할 패널 내 화면 위치
|
||||
*/
|
||||
export type SplitPanelPosition = "left" | "right";
|
||||
|
||||
/**
|
||||
* 데이터 수신자 인터페이스
|
||||
*/
|
||||
export interface SplitPanelDataReceiver {
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 값
|
||||
*/
|
||||
interface SplitPanelContextValue {
|
||||
// 분할 패널 ID
|
||||
splitPanelId: string;
|
||||
|
||||
// 좌측/우측 화면 ID
|
||||
leftScreenId: number | null;
|
||||
rightScreenId: number | null;
|
||||
|
||||
// 데이터 수신자 등록/해제
|
||||
registerReceiver: (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => void;
|
||||
unregisterReceiver: (position: SplitPanelPosition, componentId: string) => void;
|
||||
|
||||
// 반대편 화면으로 데이터 전달
|
||||
transferToOtherSide: (
|
||||
fromPosition: SplitPanelPosition,
|
||||
data: any[],
|
||||
targetComponentId?: string, // 특정 컴포넌트 지정 (없으면 첫 번째 수신자)
|
||||
mode?: "append" | "replace" | "merge"
|
||||
) => Promise<{ success: boolean; message: string }>;
|
||||
|
||||
// 반대편 화면의 수신자 목록 가져오기
|
||||
getOtherSideReceivers: (fromPosition: SplitPanelPosition) => SplitPanelDataReceiver[];
|
||||
|
||||
// 현재 위치 확인
|
||||
isInSplitPanel: boolean;
|
||||
|
||||
// screenId로 위치 찾기
|
||||
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
|
||||
|
||||
// 🆕 우측에 추가된 항목 ID 관리 (좌측 테이블에서 필터링용)
|
||||
addedItemIds: Set<string>;
|
||||
addItemIds: (ids: string[]) => void;
|
||||
removeItemIds: (ids: string[]) => void;
|
||||
clearItemIds: () => void;
|
||||
}
|
||||
|
||||
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||
|
||||
interface SplitPanelProviderProps {
|
||||
splitPanelId: string;
|
||||
leftScreenId: number | null;
|
||||
rightScreenId: number | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 프로바이더
|
||||
*/
|
||||
export function SplitPanelProvider({
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
children,
|
||||
}: SplitPanelProviderProps) {
|
||||
// 좌측/우측 화면의 데이터 수신자 맵
|
||||
const leftReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
|
||||
const rightReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
|
||||
|
||||
// 강제 리렌더링용 상태
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// 🆕 우측에 추가된 항목 ID 상태
|
||||
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* 데이터 수신자 등록
|
||||
*/
|
||||
const registerReceiver = useCallback(
|
||||
(position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => {
|
||||
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
|
||||
receiversRef.current.set(componentId, receiver);
|
||||
|
||||
logger.debug(`[SplitPanelContext] 수신자 등록: ${position} - ${componentId}`, {
|
||||
componentType: receiver.componentType,
|
||||
});
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 데이터 수신자 해제
|
||||
*/
|
||||
const unregisterReceiver = useCallback(
|
||||
(position: SplitPanelPosition, componentId: string) => {
|
||||
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
|
||||
receiversRef.current.delete(componentId);
|
||||
|
||||
logger.debug(`[SplitPanelContext] 수신자 해제: ${position} - ${componentId}`);
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 반대편 화면의 수신자 목록 가져오기
|
||||
*/
|
||||
const getOtherSideReceivers = useCallback(
|
||||
(fromPosition: SplitPanelPosition): SplitPanelDataReceiver[] => {
|
||||
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
|
||||
return Array.from(receiversRef.current.values());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 반대편 화면으로 데이터 전달
|
||||
*/
|
||||
const transferToOtherSide = useCallback(
|
||||
async (
|
||||
fromPosition: SplitPanelPosition,
|
||||
data: any[],
|
||||
targetComponentId?: string,
|
||||
mode: "append" | "replace" | "merge" = "append"
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const toPosition = fromPosition === "left" ? "right" : "left";
|
||||
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
|
||||
|
||||
logger.info(`[SplitPanelContext] 데이터 전달 시작: ${fromPosition} → ${toPosition}`, {
|
||||
dataCount: data.length,
|
||||
targetComponentId,
|
||||
mode,
|
||||
availableReceivers: Array.from(receiversRef.current.keys()),
|
||||
});
|
||||
|
||||
if (receiversRef.current.size === 0) {
|
||||
const message = `${toPosition === "left" ? "좌측" : "우측"} 화면에 데이터를 받을 수 있는 컴포넌트가 없습니다.`;
|
||||
logger.warn(`[SplitPanelContext] ${message}`);
|
||||
return { success: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
let targetReceiver: SplitPanelDataReceiver | undefined;
|
||||
|
||||
if (targetComponentId) {
|
||||
// 특정 컴포넌트 지정
|
||||
targetReceiver = receiversRef.current.get(targetComponentId);
|
||||
if (!targetReceiver) {
|
||||
const message = `타겟 컴포넌트 '${targetComponentId}'를 찾을 수 없습니다.`;
|
||||
logger.warn(`[SplitPanelContext] ${message}`);
|
||||
return { success: false, message };
|
||||
}
|
||||
} else {
|
||||
// 첫 번째 수신자 사용
|
||||
targetReceiver = receiversRef.current.values().next().value;
|
||||
}
|
||||
|
||||
if (!targetReceiver) {
|
||||
return { success: false, message: "데이터 수신자를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
await targetReceiver.receiveData(data, mode);
|
||||
|
||||
const message = `${data.length}개 항목이 ${toPosition === "left" ? "좌측" : "우측"} 화면으로 전달되었습니다.`;
|
||||
logger.info(`[SplitPanelContext] ${message}`);
|
||||
|
||||
return { success: true, message };
|
||||
} catch (error: any) {
|
||||
const message = error.message || "데이터 전달 중 오류가 발생했습니다.";
|
||||
logger.error(`[SplitPanelContext] 데이터 전달 실패`, error);
|
||||
return { success: false, message };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* screenId로 위치 찾기
|
||||
*/
|
||||
const getPositionByScreenId = useCallback(
|
||||
(screenId: number): SplitPanelPosition | null => {
|
||||
if (leftScreenId === screenId) return "left";
|
||||
if (rightScreenId === screenId) return "right";
|
||||
return null;
|
||||
},
|
||||
[leftScreenId, rightScreenId]
|
||||
);
|
||||
|
||||
/**
|
||||
* 🆕 추가된 항목 ID 등록
|
||||
*/
|
||||
const addItemIds = useCallback((ids: string[]) => {
|
||||
setAddedItemIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
ids.forEach((id) => newSet.add(id));
|
||||
logger.debug(`[SplitPanelContext] 항목 ID 추가: ${ids.length}개`, { ids });
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 🆕 추가된 항목 ID 제거
|
||||
*/
|
||||
const removeItemIds = useCallback((ids: string[]) => {
|
||||
setAddedItemIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
ids.forEach((id) => newSet.delete(id));
|
||||
logger.debug(`[SplitPanelContext] 항목 ID 제거: ${ids.length}개`, { ids });
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 🆕 모든 항목 ID 초기화
|
||||
*/
|
||||
const clearItemIds = useCallback(() => {
|
||||
setAddedItemIds(new Set());
|
||||
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
|
||||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
registerReceiver,
|
||||
unregisterReceiver,
|
||||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
isInSplitPanel: true,
|
||||
getPositionByScreenId,
|
||||
addedItemIds,
|
||||
addItemIds,
|
||||
removeItemIds,
|
||||
clearItemIds,
|
||||
}), [
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
registerReceiver,
|
||||
unregisterReceiver,
|
||||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
getPositionByScreenId,
|
||||
addedItemIds,
|
||||
addItemIds,
|
||||
removeItemIds,
|
||||
clearItemIds,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SplitPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</SplitPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 훅
|
||||
*/
|
||||
export function useSplitPanelContext() {
|
||||
return useContext(SplitPanelContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 내부인지 확인하는 훅
|
||||
*/
|
||||
export function useIsInSplitPanel(): boolean {
|
||||
const context = useContext(SplitPanelContext);
|
||||
return context?.isInSplitPanel ?? false;
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +90,7 @@ export interface Dashboard {
|
|||
thumbnailUrl?: string;
|
||||
isPublic: boolean;
|
||||
createdBy: string;
|
||||
createdByName?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: string[];
|
||||
|
|
@ -97,6 +98,7 @@ export interface Dashboard {
|
|||
viewCount: number;
|
||||
elementsCount?: number;
|
||||
creatorName?: string;
|
||||
companyCode?: string;
|
||||
elements?: DashboardElement[];
|
||||
settings?: {
|
||||
resolution?: string;
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export class DynamicFormApi {
|
|||
* @returns 업데이트 결과
|
||||
*/
|
||||
static async updateFormDataPartial(
|
||||
id: number,
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>,
|
||||
tableName: string,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ export interface ExternalApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
// 기본 HTTP 메서드/바디 (외부 REST API 커넥션과 동일한 필드)
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
keyLocation?: "header" | "query";
|
||||
|
|
|
|||
|
|
@ -199,8 +199,6 @@ export interface MenuCopyResult {
|
|||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 API 클라이언트
|
||||
*/
|
||||
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
ScreenEmbedding,
|
||||
ScreenDataTransfer,
|
||||
ScreenSplitPanel,
|
||||
CreateScreenEmbeddingRequest,
|
||||
CreateScreenDataTransferRequest,
|
||||
CreateScreenSplitPanelRequest,
|
||||
ApiResponse,
|
||||
} from "@/types/screen-embedding";
|
||||
|
||||
// ============================================
|
||||
// 1. 화면 임베딩 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 화면 임베딩 목록 조회
|
||||
*/
|
||||
export async function getScreenEmbeddings(
|
||||
parentScreenId: number
|
||||
): Promise<ApiResponse<ScreenEmbedding[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/screen-embedding", {
|
||||
params: { parentScreenId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 목록 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 상세 조회
|
||||
*/
|
||||
export async function getScreenEmbeddingById(
|
||||
id: number
|
||||
): Promise<ApiResponse<ScreenEmbedding>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/screen-embedding/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 생성
|
||||
*/
|
||||
export async function createScreenEmbedding(
|
||||
data: CreateScreenEmbeddingRequest
|
||||
): Promise<ApiResponse<ScreenEmbedding>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-embedding", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 생성 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 수정
|
||||
*/
|
||||
export async function updateScreenEmbedding(
|
||||
id: number,
|
||||
data: Partial<CreateScreenEmbeddingRequest>
|
||||
): Promise<ApiResponse<ScreenEmbedding>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/screen-embedding/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 수정 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 삭제
|
||||
*/
|
||||
export async function deleteScreenEmbedding(
|
||||
id: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-embedding/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. 데이터 전달 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 조회
|
||||
*/
|
||||
export async function getScreenDataTransfer(
|
||||
sourceScreenId: number,
|
||||
targetScreenId: number
|
||||
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||||
try {
|
||||
const response = await apiClient.get("/screen-data-transfer", {
|
||||
params: { sourceScreenId, targetScreenId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 생성
|
||||
*/
|
||||
export async function createScreenDataTransfer(
|
||||
data: CreateScreenDataTransferRequest
|
||||
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-data-transfer", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 생성 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 수정
|
||||
*/
|
||||
export async function updateScreenDataTransfer(
|
||||
id: number,
|
||||
data: Partial<CreateScreenDataTransferRequest>
|
||||
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/screen-data-transfer/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 수정 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 삭제
|
||||
*/
|
||||
export async function deleteScreenDataTransfer(
|
||||
id: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-data-transfer/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. 분할 패널 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 조회
|
||||
*/
|
||||
export async function getScreenSplitPanel(
|
||||
screenId: number
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/screen-split-panel/${screenId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 생성
|
||||
*/
|
||||
export async function createScreenSplitPanel(
|
||||
data: CreateScreenSplitPanelRequest
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-split-panel", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 생성 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 수정
|
||||
*/
|
||||
export async function updateScreenSplitPanel(
|
||||
id: number,
|
||||
layoutConfig: any
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/screen-split-panel/${id}`, {
|
||||
layoutConfig,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 수정 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 삭제
|
||||
*/
|
||||
export async function deleteScreenSplitPanel(
|
||||
id: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-split-panel/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. 유틸리티 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 화면 임베딩 전체 설정 조회 (분할 패널 포함)
|
||||
*/
|
||||
export async function getFullScreenEmbeddingConfig(
|
||||
screenId: number
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
return getScreenSplitPanel(screenId);
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +259,28 @@ export async function deleteColumnMapping(mappingId: number) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param columnName - 컬럼명
|
||||
*/
|
||||
export async function deleteColumnMappingsByColumn(tableName: string, columnName: string) {
|
||||
try {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
deletedCount: number;
|
||||
}>(`/table-categories/column-mapping/${tableName}/${columnName}/all`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("테이블+컬럼 기준 매핑 삭제 실패:", error);
|
||||
return { success: false, error: error.message, deletedCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* 차량 운행 이력 API 클라이언트
|
||||
*/
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// 타입 정의
|
||||
export interface TripSummary {
|
||||
id: number;
|
||||
trip_id: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
vehicle_id?: number;
|
||||
vehicle_number?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departure_name?: string;
|
||||
destination_name?: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
total_distance: number;
|
||||
duration_minutes?: number;
|
||||
status: "active" | "completed" | "cancelled";
|
||||
location_count: number;
|
||||
company_code: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TripLocation {
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
distance_from_prev?: number;
|
||||
trip_status: "start" | "tracking" | "end";
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface TripDetail {
|
||||
summary: TripSummary;
|
||||
route: TripLocation[];
|
||||
}
|
||||
|
||||
export interface TripListFilters {
|
||||
userId?: string;
|
||||
vehicleId?: number;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface StartTripParams {
|
||||
vehicleId?: number;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
departureName?: string;
|
||||
destinationName?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface EndTripParams {
|
||||
tripId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface AddLocationParams {
|
||||
tripId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
// API 함수들
|
||||
|
||||
/**
|
||||
* 운행 시작
|
||||
*/
|
||||
export async function startTrip(params: StartTripParams) {
|
||||
const response = await apiClient.post("/vehicle/trip/start", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 종료
|
||||
*/
|
||||
export async function endTrip(params: EndTripParams) {
|
||||
const response = await apiClient.post("/vehicle/trip/end", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 기록 추가 (연속 추적)
|
||||
*/
|
||||
export async function addTripLocation(params: AddLocationParams) {
|
||||
const response = await apiClient.post("/vehicle/trip/location", params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 운행 조회
|
||||
*/
|
||||
export async function getActiveTrip() {
|
||||
const response = await apiClient.get("/vehicle/trip/active");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 취소
|
||||
*/
|
||||
export async function cancelTrip(tripId: string) {
|
||||
const response = await apiClient.post("/vehicle/trip/cancel", { tripId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 이력 목록 조회
|
||||
*/
|
||||
export async function getTripList(filters?: TripListFilters) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
if (filters.userId) params.append("userId", filters.userId);
|
||||
if (filters.vehicleId) params.append("vehicleId", String(filters.vehicleId));
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters.departure) params.append("departure", filters.departure);
|
||||
if (filters.arrival) params.append("arrival", filters.arrival);
|
||||
if (filters.limit) params.append("limit", String(filters.limit));
|
||||
if (filters.offset) params.append("offset", String(filters.offset));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/trips?${queryString}` : "/vehicle/trips";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 상세 조회 (경로 포함)
|
||||
*/
|
||||
export async function getTripDetail(tripId: string): Promise<{ success: boolean; data?: TripDetail; message?: string }> {
|
||||
const response = await apiClient.get(`/vehicle/trips/${tripId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거리 포맷팅 (km)
|
||||
*/
|
||||
export function formatDistance(distanceKm: number): string {
|
||||
if (distanceKm < 1) {
|
||||
return `${Math.round(distanceKm * 1000)}m`;
|
||||
}
|
||||
return `${distanceKm.toFixed(2)}km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 시간 포맷팅
|
||||
*/
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}분`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 한글 변환
|
||||
*/
|
||||
export function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "운행 중";
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "cancelled":
|
||||
return "취소됨";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 색상
|
||||
*/
|
||||
export function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "completed":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "cancelled":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 리포트 API ==============
|
||||
|
||||
export interface DailyStat {
|
||||
date: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
cancelledCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
export interface WeeklyStat {
|
||||
weekNumber: number;
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
}
|
||||
|
||||
export interface MonthlyStat {
|
||||
month: number;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
cancelledCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
driverCount: number;
|
||||
}
|
||||
|
||||
export interface SummaryReport {
|
||||
period: string;
|
||||
totalTrips: number;
|
||||
completedTrips: number;
|
||||
activeTrips: number;
|
||||
cancelledTrips: number;
|
||||
completionRate: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
activeDrivers: number;
|
||||
}
|
||||
|
||||
export interface DriverStat {
|
||||
userId: string;
|
||||
userName: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
avgDistance: number;
|
||||
}
|
||||
|
||||
export interface RouteStat {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
departureName: string;
|
||||
destinationName: string;
|
||||
tripCount: number;
|
||||
completedCount: number;
|
||||
totalDistance: number;
|
||||
avgDistance: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회 (대시보드용)
|
||||
*/
|
||||
export async function getSummaryReport(period?: string) {
|
||||
const url = period ? `/vehicle/reports/summary?period=${period}` : "/vehicle/reports/summary";
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 통계 조회
|
||||
*/
|
||||
export async function getDailyReport(filters?: { startDate?: string; endDate?: string; userId?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters?.userId) params.append("userId", filters.userId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/daily?${queryString}` : "/vehicle/reports/daily";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주별 통계 조회
|
||||
*/
|
||||
export async function getWeeklyReport(filters?: { year?: number; month?: number; userId?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.year) params.append("year", String(filters.year));
|
||||
if (filters?.month) params.append("month", String(filters.month));
|
||||
if (filters?.userId) params.append("userId", filters.userId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/weekly?${queryString}` : "/vehicle/reports/weekly";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계 조회
|
||||
*/
|
||||
export async function getMonthlyReport(filters?: { year?: number; userId?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.year) params.append("year", String(filters.year));
|
||||
if (filters?.userId) params.append("userId", filters.userId);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/monthly?${queryString}` : "/vehicle/reports/monthly";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운전자별 통계 조회
|
||||
*/
|
||||
export async function getDriverReport(filters?: { startDate?: string; endDate?: string; limit?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/by-driver?${queryString}` : "/vehicle/reports/by-driver";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간별 통계 조회
|
||||
*/
|
||||
export async function getRouteReport(filters?: { startDate?: string; endDate?: string; limit?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/vehicle/reports/by-route?${queryString}` : "/vehicle/reports/by-route";
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +224,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||
|
||||
// 🔍 디버깅: screen-split-panel 조회 결과 확인
|
||||
if (componentType === "screen-split-panel") {
|
||||
console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", {
|
||||
componentType,
|
||||
found: !!newComponent,
|
||||
componentId: component.id,
|
||||
componentConfig: component.componentConfig,
|
||||
hasFormData: !!props.formData,
|
||||
formDataKeys: props.formData ? Object.keys(props.formData) : [],
|
||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
||||
});
|
||||
}
|
||||
|
||||
// 🔍 디버깅: select-basic 조회 결과 확인
|
||||
if (componentType === "select-basic") {
|
||||
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
|
||||
|
|
@ -234,6 +247,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 디버깅: text-input 컴포넌트 조회 결과 확인
|
||||
if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") {
|
||||
console.log("🔍 [DynamicComponentRenderer] text-input 조회:", {
|
||||
componentType,
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
componentConfig: component.componentConfig,
|
||||
webTypeConfig: (component as any).webTypeConfig,
|
||||
autoGeneration: (component as any).autoGeneration,
|
||||
found: !!newComponent,
|
||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (newComponent) {
|
||||
// 새 컴포넌트 시스템으로 렌더링
|
||||
try {
|
||||
|
|
@ -294,9 +321,27 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
||||
// 🆕 디버깅: text-input 값 추출 확인
|
||||
if (componentType === "text-input" && formData && Object.keys(formData).length > 0) {
|
||||
console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", {
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
columnName: (component as any).columnName,
|
||||
fieldName,
|
||||
currentValue,
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만
|
||||
});
|
||||
}
|
||||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
|
||||
return;
|
||||
}
|
||||
|
||||
// React 이벤트 객체인 경우 값 추출
|
||||
let actualValue = value;
|
||||
if (value && typeof value === "object" && value.nativeEvent && value.target) {
|
||||
|
|
@ -422,8 +467,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
if (!renderer) {
|
||||
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
|
||||
component: component,
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
componentType: componentType,
|
||||
originalType: component.type,
|
||||
originalComponentType: (component as any).componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
webTypeConfig: (component as any).webTypeConfig,
|
||||
autoGeneration: (component as any).autoGeneration,
|
||||
availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,20 +57,42 @@ export function AutocompleteSearchInputComponent({
|
|||
filterCondition,
|
||||
});
|
||||
|
||||
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
|
||||
const selectedDataRef = useRef<EntitySearchResult | null>(null);
|
||||
const inputValueRef = useRef<string>("");
|
||||
|
||||
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
||||
const currentValue = isInteractive && formData && component?.columnName
|
||||
? formData[component.columnName]
|
||||
: value;
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
// selectedData 변경 시 ref도 업데이트
|
||||
useEffect(() => {
|
||||
if (currentValue && selectedData) {
|
||||
setInputValue(selectedData[displayField] || "");
|
||||
} else if (!currentValue) {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
if (selectedData) {
|
||||
selectedDataRef.current = selectedData;
|
||||
inputValueRef.current = inputValue;
|
||||
}
|
||||
}, [currentValue, displayField, selectedData]);
|
||||
}, [selectedData, inputValue]);
|
||||
|
||||
// 리렌더링 시 ref에서 값 복원
|
||||
useEffect(() => {
|
||||
if (!selectedData && selectedDataRef.current) {
|
||||
setSelectedData(selectedDataRef.current);
|
||||
setInputValue(inputValueRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
|
||||
useEffect(() => {
|
||||
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
|
||||
if (selectedData || selectedDataRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentValue) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [currentValue, selectedData]);
|
||||
|
||||
// 외부 클릭 감지
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -21,7 +21,9 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
config,
|
||||
onConfigChange,
|
||||
}: AutocompleteSearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
// 초기화 여부 추적 (첫 마운트 시에만 config로 초기화)
|
||||
const isInitialized = useRef(false);
|
||||
const [localConfig, setLocalConfig] = useState<AutocompleteSearchInputConfig>(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
|
||||
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
|
||||
|
|
@ -32,12 +34,21 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||
|
||||
// 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지)
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
if (!isInitialized.current && config) {
|
||||
setLocalConfig(config);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
console.log("🔧 [AutocompleteConfigPanel] updateConfig:", {
|
||||
updates,
|
||||
localConfig,
|
||||
newConfig,
|
||||
});
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
|
@ -325,10 +336,11 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">외부 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.sourceField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { sourceField: value })
|
||||
}
|
||||
value={mapping.sourceField || undefined}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔧 [Select] sourceField 변경:", value);
|
||||
updateFieldMapping(index, { sourceField: value });
|
||||
}}
|
||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
@ -347,10 +359,11 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { targetField: value })
|
||||
}
|
||||
value={mapping.targetField || undefined}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔧 [Select] targetField 변경:", value);
|
||||
updateFieldMapping(index, { targetField: value });
|
||||
}}
|
||||
disabled={!localConfig.targetTable || isLoadingTargetColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import { toast } from "sonner";
|
|||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
|
|
@ -97,6 +100,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...props
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
|
||||
const effectiveTableName = tableName || screenContext?.tableName;
|
||||
const effectiveScreenId = screenId || screenContext?.screenId;
|
||||
|
||||
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
||||
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
||||
|
|
@ -146,7 +157,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
} | null>(null);
|
||||
|
||||
// 토스트 정리를 위한 ref
|
||||
const currentLoadingToastRef = useRef<string | number | undefined>();
|
||||
const currentLoadingToastRef = useRef<string | number | undefined>(undefined);
|
||||
|
||||
// 컴포넌트 언마운트 시 토스트 정리
|
||||
useEffect(() => {
|
||||
|
|
@ -190,9 +201,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
|
||||
|
||||
// 컴포넌트 설정
|
||||
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
||||
} as ButtonPrimaryConfig;
|
||||
|
||||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||
|
|
@ -227,13 +240,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 스타일 계산
|
||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
|
|
@ -374,6 +386,261 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
};
|
||||
|
||||
// 이벤트 핸들러
|
||||
/**
|
||||
* transferData 액션 처리
|
||||
*/
|
||||
const handleTransferDataAction = async (actionConfig: any) => {
|
||||
const dataTransferConfig = actionConfig.dataTransfer;
|
||||
|
||||
if (!dataTransferConfig) {
|
||||
toast.error("데이터 전달 설정이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!screenContext) {
|
||||
toast.error("화면 컨텍스트를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
|
||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||
if (!sourceProvider) {
|
||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
// 테이블 리스트 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (provider.componentType === "table-list") {
|
||||
sourceProvider = provider;
|
||||
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||
if (!sourceProvider && allProviders.size > 0) {
|
||||
const firstEntry = allProviders.entries().next().value;
|
||||
if (firstEntry) {
|
||||
sourceProvider = firstEntry[1];
|
||||
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceProvider) {
|
||||
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rawSourceData = sourceProvider.getSelectedData();
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
|
||||
|
||||
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
|
||||
|
||||
if (!sourceData || sourceData.length === 0) {
|
||||
toast.warning("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
||||
let additionalData: Record<string, any> = {};
|
||||
|
||||
// 방법 1: additionalSources 설정에서 가져오기
|
||||
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
||||
for (const additionalSource of dataTransferConfig.additionalSources) {
|
||||
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
||||
|
||||
if (additionalProvider) {
|
||||
const additionalValues = additionalProvider.getSelectedData();
|
||||
|
||||
if (additionalValues && additionalValues.length > 0) {
|
||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||
const firstValue = additionalValues[0];
|
||||
|
||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||
if (additionalSource.fieldName) {
|
||||
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
} else {
|
||||
// fieldName이 없으면 전체 객체 병합
|
||||
additionalData = { ...additionalData, ...firstValue };
|
||||
}
|
||||
|
||||
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
||||
sourceId: additionalSource.componentId,
|
||||
fieldName: additionalSource.fieldName,
|
||||
value: additionalData[additionalSource.fieldName || 'all'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동)
|
||||
// ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴
|
||||
if (formData && formData.__conditionalContainerValue) {
|
||||
// includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함
|
||||
if (dataTransferConfig.includeConditionalValue !== false) {
|
||||
const conditionalValue = formData.__conditionalContainerValue;
|
||||
const conditionalLabel = formData.__conditionalContainerLabel;
|
||||
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
||||
|
||||
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
||||
if (controlField) {
|
||||
additionalData[controlField] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 매핑:", {
|
||||
controlField,
|
||||
value: conditionalValue,
|
||||
label: conditionalLabel,
|
||||
});
|
||||
} else {
|
||||
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (value === conditionalValue && !key.startsWith('__')) {
|
||||
additionalData[key] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
||||
fieldName: key,
|
||||
value: conditionalValue,
|
||||
label: conditionalLabel,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 못 찾았으면 기본 필드명 사용
|
||||
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
|
||||
additionalData['condition_type'] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
||||
fieldName: 'condition_type',
|
||||
value: conditionalValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 검증
|
||||
const validation = dataTransferConfig.validation;
|
||||
if (validation) {
|
||||
if (validation.minSelection && sourceData.length < validation.minSelection) {
|
||||
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
|
||||
return;
|
||||
}
|
||||
if (validation.maxSelection && sourceData.length > validation.maxSelection) {
|
||||
toast.error(`최대 ${validation.maxSelection}개까지 선택할 수 있습니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 확인 메시지
|
||||
if (dataTransferConfig.confirmBeforeTransfer) {
|
||||
const confirmMessage = dataTransferConfig.confirmMessage || `${sourceData.length}개 항목을 전달하시겠습니까?`;
|
||||
if (!window.confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||
const mappedData = sourceData.map((row) => {
|
||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
|
||||
// 추가 데이터를 모든 행에 포함
|
||||
return {
|
||||
...mappedRow,
|
||||
...additionalData,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("📦 데이터 전달:", {
|
||||
sourceData,
|
||||
mappedData,
|
||||
targetType: dataTransferConfig.targetType,
|
||||
targetComponentId: dataTransferConfig.targetComponentId,
|
||||
targetScreenId: dataTransferConfig.targetScreenId,
|
||||
});
|
||||
|
||||
// 5. 타겟으로 데이터 전달
|
||||
if (dataTransferConfig.targetType === "component") {
|
||||
// 같은 화면의 컴포넌트로 전달
|
||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
||||
|
||||
if (!targetReceiver) {
|
||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await targetReceiver.receiveData(mappedData, {
|
||||
targetComponentId: dataTransferConfig.targetComponentId,
|
||||
targetComponentType: targetReceiver.componentType,
|
||||
mode: dataTransferConfig.mode || "append",
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
});
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||
if (!splitPanelContext) {
|
||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
||||
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
if (!currentPosition) {
|
||||
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📦 분할 패널 데이터 전달:", {
|
||||
currentPosition,
|
||||
splitPanelPositionFromHook: splitPanelPosition,
|
||||
screenId,
|
||||
leftScreenId: splitPanelContext.leftScreenId,
|
||||
rightScreenId: splitPanelContext.rightScreenId,
|
||||
});
|
||||
|
||||
const result = await splitPanelContext.transferToOtherSide(
|
||||
currentPosition,
|
||||
mappedData,
|
||||
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
||||
dataTransferConfig.mode || "append"
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
return;
|
||||
}
|
||||
} else if (dataTransferConfig.targetType === "screen") {
|
||||
// 다른 화면으로 전달 (구현 예정)
|
||||
toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다.");
|
||||
return;
|
||||
} else {
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
}
|
||||
|
||||
// 6. 전달 후 정리
|
||||
if (dataTransferConfig.clearAfterTransfer) {
|
||||
sourceProvider.clearSelection();
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ 데이터 전달 실패:", error);
|
||||
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -390,6 +657,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 인터랙티브 모드에서 액션 실행
|
||||
if (isInteractive && processedConfig.action) {
|
||||
// transferData 액션 처리 (화면 컨텍스트 필요)
|
||||
if (processedConfig.action.type === "transferData") {
|
||||
await handleTransferDataAction(processedConfig.action);
|
||||
return;
|
||||
}
|
||||
|
||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||
const hasDataToDelete =
|
||||
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
|
|
@ -409,11 +682,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 디버깅: tableName 확인
|
||||
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
|
||||
propsTableName: tableName,
|
||||
contextTableName: screenContext?.tableName,
|
||||
effectiveTableName,
|
||||
propsScreenId: screenId,
|
||||
contextScreenId: screenContext?.screenId,
|
||||
effectiveScreenId,
|
||||
});
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
screenId,
|
||||
tableName,
|
||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
|
|
@ -42,6 +44,9 @@ export function ConditionalContainerComponent({
|
|||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
}: ConditionalContainerProps) {
|
||||
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||
|
|
@ -50,30 +55,86 @@ export function ConditionalContainerComponent({
|
|||
const showBorder = config?.showBorder ?? propShowBorder ?? true;
|
||||
const spacing = config?.spacing || propSpacing || "normal";
|
||||
|
||||
// 초기값 계산 (한 번만)
|
||||
const initialValue = React.useMemo(() => {
|
||||
return value || formData?.[controlField] || defaultValue || "";
|
||||
}, []); // 의존성 없음 - 마운트 시 한 번만 계산
|
||||
|
||||
// 현재 선택된 값
|
||||
const [selectedValue, setSelectedValue] = useState<string>(
|
||||
value || formData?.[controlField] || defaultValue || ""
|
||||
);
|
||||
const [selectedValue, setSelectedValue] = useState<string>(initialValue);
|
||||
|
||||
// 최신 값을 ref로 유지 (클로저 문제 방지)
|
||||
const selectedValueRef = React.useRef(selectedValue);
|
||||
selectedValueRef.current = selectedValue; // 렌더링마다 업데이트 (useEffect 대신)
|
||||
|
||||
// formData 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (formData?.[controlField]) {
|
||||
setSelectedValue(formData[controlField]);
|
||||
}
|
||||
}, [formData, controlField]);
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleValueChange = (newValue: string) => {
|
||||
// 콜백 refs (의존성 제거)
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onFormDataChangeRef = React.useRef(onFormDataChange);
|
||||
onChangeRef.current = onChange;
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
// 값 변경 핸들러 - 의존성 없음
|
||||
const handleValueChange = React.useCallback((newValue: string) => {
|
||||
// 같은 값이면 무시
|
||||
if (newValue === selectedValueRef.current) return;
|
||||
|
||||
setSelectedValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
if (onChangeRef.current) {
|
||||
onChangeRef.current(newValue);
|
||||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(controlField, newValue);
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(controlField, newValue);
|
||||
}
|
||||
};
|
||||
}, [controlField]);
|
||||
|
||||
// sectionsRef 추가 (dataProvider에서 사용)
|
||||
const sectionsRef = React.useRef(sections);
|
||||
React.useEffect(() => {
|
||||
sectionsRef.current = sections;
|
||||
}, [sections]);
|
||||
|
||||
// dataProvider를 useMemo로 감싸서 불필요한 재생성 방지
|
||||
const dataProvider = React.useMemo<DataProvidable>(() => ({
|
||||
componentId: componentId || "conditional-container",
|
||||
componentType: "conditional-container",
|
||||
|
||||
getSelectedData: () => {
|
||||
// ref를 통해 최신 값 참조 (클로저 문제 방지)
|
||||
const currentValue = selectedValueRef.current;
|
||||
const currentSections = sectionsRef.current;
|
||||
return [{
|
||||
[controlField]: currentValue,
|
||||
condition: currentValue,
|
||||
label: currentSections.find(s => s.condition === currentValue)?.label || currentValue,
|
||||
}];
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
const currentSections = sectionsRef.current;
|
||||
return currentSections.map(section => ({
|
||||
condition: section.condition,
|
||||
label: section.label,
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
// 조건부 컨테이너는 초기화하지 않음
|
||||
console.log("조건부 컨테이너는 선택 초기화를 지원하지 않습니다.");
|
||||
},
|
||||
}), [componentId, controlField]); // selectedValue, sections는 ref로 참조
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && componentId) {
|
||||
screenContext.registerDataProvider(componentId, dataProvider);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(componentId);
|
||||
};
|
||||
}
|
||||
}, [screenContext, componentId, dataProvider]);
|
||||
|
||||
// 컨테이너 높이 측정용 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -158,6 +219,8 @@ export function ConditionalContainerComponent({
|
|||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
controlField={controlField}
|
||||
selectedCondition={selectedValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -179,6 +242,8 @@ export function ConditionalContainerComponent({
|
|||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
controlField={controlField}
|
||||
selectedCondition={selectedValue}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,19 +12,38 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Plus, Trash2, GripVertical, Loader2, Check, ChevronsUpDown, Database } from "lucide-react";
|
||||
import { ConditionalContainerConfig, ConditionalSection } from "./types";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCategoryColumnsByMenu, getCategoryValues, getSecondLevelMenus } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
interface ConditionalContainerConfigPanelProps {
|
||||
config: ConditionalContainerConfig;
|
||||
onConfigChange: (config: ConditionalContainerConfig) => void;
|
||||
onChange?: (config: ConditionalContainerConfig) => void;
|
||||
onConfigChange?: (config: ConditionalContainerConfig) => void;
|
||||
}
|
||||
|
||||
export function ConditionalContainerConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}: ConditionalContainerConfigPanelProps) {
|
||||
// onChange 또는 onConfigChange 둘 다 지원
|
||||
const handleConfigChange = onChange || onConfigChange;
|
||||
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
|
||||
controlField: config.controlField || "condition",
|
||||
controlLabel: config.controlLabel || "조건 선택",
|
||||
|
|
@ -38,6 +57,21 @@ export function ConditionalContainerConfigPanel({
|
|||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 🆕 메뉴 기반 카테고리 관련 상태
|
||||
const [availableMenus, setAvailableMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string; screenCode?: string }>>([]);
|
||||
const [menusLoading, setMenusLoading] = useState(false);
|
||||
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | null>(null);
|
||||
const [menuPopoverOpen, setMenuPopoverOpen] = useState(false);
|
||||
|
||||
const [categoryColumns, setCategoryColumns] = useState<Array<{ columnName: string; columnLabel: string; tableName: string }>>([]);
|
||||
const [categoryColumnsLoading, setCategoryColumnsLoading] = useState(false);
|
||||
const [selectedCategoryColumn, setSelectedCategoryColumn] = useState<string>("");
|
||||
const [selectedCategoryTableName, setSelectedCategoryTableName] = useState<string>("");
|
||||
const [columnPopoverOpen, setColumnPopoverOpen] = useState(false);
|
||||
|
||||
const [categoryValues, setCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [categoryValuesLoading, setCategoryValuesLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
|
|
@ -56,11 +90,122 @@ export function ConditionalContainerConfigPanel({
|
|||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 🆕 2레벨 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
setMenusLoading(true);
|
||||
try {
|
||||
const response = await getSecondLevelMenus();
|
||||
console.log("🔍 [ConditionalContainer] 메뉴 목록 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
setAvailableMenus(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setMenusLoading(false);
|
||||
}
|
||||
};
|
||||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// 🆕 선택된 메뉴의 카테고리 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedMenuObjid) {
|
||||
setCategoryColumns([]);
|
||||
setSelectedCategoryColumn("");
|
||||
setSelectedCategoryTableName("");
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryColumns = async () => {
|
||||
setCategoryColumnsLoading(true);
|
||||
try {
|
||||
console.log("🔍 [ConditionalContainer] 메뉴별 카테고리 컬럼 로드:", selectedMenuObjid);
|
||||
const response = await getCategoryColumnsByMenu(selectedMenuObjid);
|
||||
console.log("✅ [ConditionalContainer] 카테고리 컬럼 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setCategoryColumns(response.data.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
tableName: col.tableName || col.table_name,
|
||||
})));
|
||||
} else {
|
||||
setCategoryColumns([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 컬럼 로드 실패:", error);
|
||||
setCategoryColumns([]);
|
||||
} finally {
|
||||
setCategoryColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
loadCategoryColumns();
|
||||
}, [selectedMenuObjid]);
|
||||
|
||||
// 🆕 선택된 카테고리 컬럼의 값 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryTableName || !selectedCategoryColumn || !selectedMenuObjid) {
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryValues = async () => {
|
||||
setCategoryValuesLoading(true);
|
||||
try {
|
||||
console.log("🔍 [ConditionalContainer] 카테고리 값 로드:", selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid);
|
||||
const response = await getCategoryValues(selectedCategoryTableName, selectedCategoryColumn, false, selectedMenuObjid);
|
||||
console.log("✅ [ConditionalContainer] 카테고리 값 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const values = response.data.map((v: any) => ({
|
||||
value: v.valueCode || v.value_code,
|
||||
label: v.valueLabel || v.value_label || v.valueCode || v.value_code,
|
||||
}));
|
||||
setCategoryValues(values);
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 로드 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setCategoryValuesLoading(false);
|
||||
}
|
||||
};
|
||||
loadCategoryValues();
|
||||
}, [selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid]);
|
||||
|
||||
// 🆕 테이블 카테고리에서 섹션 자동 생성
|
||||
const generateSectionsFromCategory = () => {
|
||||
if (categoryValues.length === 0) {
|
||||
alert("먼저 테이블과 카테고리 컬럼을 선택하고 값을 로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newSections: ConditionalSection[] = categoryValues.map((option, index) => ({
|
||||
id: `section_${Date.now()}_${index}`,
|
||||
condition: option.value,
|
||||
label: option.label,
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
}));
|
||||
|
||||
updateConfig({
|
||||
sections: newSections,
|
||||
controlField: selectedCategoryColumn, // 카테고리 컬럼명을 제어 필드로 사용
|
||||
});
|
||||
|
||||
alert(`${newSections.length}개의 섹션이 생성되었습니다.`);
|
||||
};
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
handleConfigChange?.(newConfig);
|
||||
};
|
||||
|
||||
// 새 섹션 추가
|
||||
|
|
@ -134,6 +279,207 @@ export function ConditionalContainerConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 메뉴별 카테고리에서 섹션 자동 생성 */}
|
||||
<div className="space-y-3 p-3 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-xs font-semibold text-blue-700 dark:text-blue-400">
|
||||
메뉴 카테고리에서 자동 생성
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 1. 메뉴 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
1. 메뉴 선택
|
||||
</Label>
|
||||
<Popover open={menuPopoverOpen} onOpenChange={setMenuPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={menuPopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={menusLoading}
|
||||
>
|
||||
{menusLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</>
|
||||
) : selectedMenuObjid ? (
|
||||
(() => {
|
||||
const menu = availableMenus.find((m) => m.menuObjid === selectedMenuObjid);
|
||||
return menu ? `${menu.parentMenuName} > ${menu.menuName}` : `메뉴 ${selectedMenuObjid}`;
|
||||
})()
|
||||
) : (
|
||||
"메뉴 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="메뉴 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">메뉴를 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableMenus.map((menu) => (
|
||||
<CommandItem
|
||||
key={menu.menuObjid}
|
||||
value={`${menu.parentMenuName} ${menu.menuName}`}
|
||||
onSelect={() => {
|
||||
setSelectedMenuObjid(menu.menuObjid);
|
||||
setSelectedCategoryColumn("");
|
||||
setSelectedCategoryTableName("");
|
||||
setMenuPopoverOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
selectedMenuObjid === menu.menuObjid ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{menu.parentMenuName} > {menu.menuName}</span>
|
||||
{menu.screenCode && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{menu.screenCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 2. 카테고리 컬럼 선택 */}
|
||||
{selectedMenuObjid && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
2. 카테고리 컬럼 선택
|
||||
</Label>
|
||||
{categoryColumnsLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground h-8 px-3 border rounded">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : categoryColumns.length > 0 ? (
|
||||
<Popover open={columnPopoverOpen} onOpenChange={setColumnPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={columnPopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedCategoryColumn ? (
|
||||
categoryColumns.find((c) => c.columnName === selectedCategoryColumn)?.columnLabel || selectedCategoryColumn
|
||||
) : (
|
||||
"카테고리 컬럼 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">카테고리 컬럼이 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{categoryColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
setSelectedCategoryColumn(col.columnName);
|
||||
setSelectedCategoryTableName(col.tableName);
|
||||
setColumnPopoverOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
selectedCategoryColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.tableName}.{col.columnName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
이 메뉴에 설정된 카테고리 컬럼이 없습니다.
|
||||
카테고리 관리에서 먼저 설정해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. 카테고리 값 미리보기 */}
|
||||
{selectedCategoryColumn && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
3. 카테고리 값 미리보기
|
||||
</Label>
|
||||
{categoryValuesLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : categoryValues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoryValues.map((option) => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="px-2 py-0.5 text-[10px] bg-blue-100 text-blue-800 rounded dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
이 컬럼에 등록된 카테고리 값이 없습니다.
|
||||
카테고리 관리에서 값을 먼저 등록해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={generateSectionsFromCategory}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedCategoryColumn || categoryValues.length === 0 || categoryValuesLoading}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{categoryValues.length > 0 ? `${categoryValues.length}개 섹션 자동 생성` : "섹션 자동 생성"}
|
||||
</Button>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 메뉴의 카테고리 값들로 조건별 섹션을 자동으로 생성합니다.
|
||||
각 섹션에 표시할 화면은 아래에서 개별 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export function ConditionalSectionViewer({
|
|||
onFormDataChange,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
controlField, // 🆕 조건부 컨테이너의 제어 필드명
|
||||
selectedCondition, // 🆕 현재 선택된 조건 값
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -34,6 +36,24 @@ export function ConditionalSectionViewer({
|
|||
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 🆕 조건 값을 포함한 formData 생성
|
||||
const enhancedFormData = React.useMemo(() => {
|
||||
const base = formData || {};
|
||||
|
||||
// 조건부 컨테이너의 현재 선택 값을 formData에 포함
|
||||
if (controlField && selectedCondition) {
|
||||
return {
|
||||
...base,
|
||||
[controlField]: selectedCondition,
|
||||
__conditionalContainerValue: selectedCondition,
|
||||
__conditionalContainerLabel: label,
|
||||
__conditionalContainerControlField: controlField, // 🆕 제어 필드명도 포함
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}, [formData, controlField, selectedCondition, label]);
|
||||
|
||||
// 화면 로드
|
||||
useEffect(() => {
|
||||
if (!screenId) {
|
||||
|
|
@ -154,18 +174,18 @@ export function ConditionalSectionViewer({
|
|||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={enhancedFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -79,5 +79,8 @@ export interface ConditionalSectionViewerProps {
|
|||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
// 🆕 조건부 컨테이너 정보 (자식 화면에 전달)
|
||||
controlField?: string; // 제어 필드명 (예: "inbound_type")
|
||||
selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
};
|
||||
|
||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
|
|
@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
// 추가된 props 필터링
|
||||
webType: _webType,
|
||||
autoGeneration: _autoGeneration,
|
||||
isInteractive: _isInteractive,
|
||||
formData: _formData,
|
||||
onFormDataChange: _onFormDataChange,
|
||||
menuId: _menuId,
|
||||
menuObjid: _menuObjid,
|
||||
onSave: _onSave,
|
||||
userId: _userId,
|
||||
userName: _userName,
|
||||
companyCode: _companyCode,
|
||||
isInModal: _isInModal,
|
||||
readonly: _readonly,
|
||||
originalData: _originalData,
|
||||
allComponents: _allComponents,
|
||||
onUpdateLayout: _onUpdateLayout,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
onSelectedRowsChange: _onSelectedRowsChange,
|
||||
sortBy: _sortBy,
|
||||
sortOrder: _sortOrder,
|
||||
tableDisplayData: _tableDisplayData,
|
||||
flowSelectedData: _flowSelectedData,
|
||||
flowSelectedStepId: _flowSelectedStepId,
|
||||
onFlowSelectedDataChange: _onFlowSelectedDataChange,
|
||||
onConfigChange: _onConfigChange,
|
||||
refreshKey: _refreshKey,
|
||||
flowRefreshKey: _flowRefreshKey,
|
||||
onFlowRefresh: _onFlowRefresh,
|
||||
isPreview: _isPreview,
|
||||
groupedData: _groupedData,
|
||||
...domProps
|
||||
} = props;
|
||||
} = props as any;
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
|
|||
// 🆕 탭 컴포넌트
|
||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
||||
// 🆕 반복 화면 모달 컴포넌트
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer";
|
||||
|
||||
// 🆕 출발지/도착지 선택 컴포넌트
|
||||
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||
|
||||
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
||||
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,520 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DataSourceConfig {
|
||||
type: "table" | "code" | "static";
|
||||
tableName?: string;
|
||||
valueField?: string;
|
||||
labelField?: string;
|
||||
codeCategory?: string;
|
||||
staticOptions?: LocationOption[];
|
||||
}
|
||||
|
||||
export interface LocationSwapSelectorProps {
|
||||
// 기본 props
|
||||
id?: string;
|
||||
style?: React.CSSProperties;
|
||||
isDesignMode?: boolean;
|
||||
|
||||
// 데이터 소스 설정
|
||||
dataSource?: DataSourceConfig;
|
||||
|
||||
// 필드 매핑
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
|
||||
// UI 설정
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
|
||||
// 폼 데이터
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
|
||||
// componentConfig (화면 디자이너에서 전달)
|
||||
componentConfig?: {
|
||||
dataSource?: DataSourceConfig;
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트
|
||||
* 출발지/도착지 선택 및 교환 기능
|
||||
*/
|
||||
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
|
||||
const {
|
||||
id,
|
||||
style,
|
||||
isDesignMode = false,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
} = props;
|
||||
|
||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||
const config = componentConfig || {};
|
||||
const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] };
|
||||
const departureField = config.departureField || props.departureField || "departure";
|
||||
const destinationField = config.destinationField || props.destinationField || "destination";
|
||||
const departureLabelField = config.departureLabelField || props.departureLabelField;
|
||||
const destinationLabelField = config.destinationLabelField || props.destinationLabelField;
|
||||
const departureLabel = config.departureLabel || props.departureLabel || "출발지";
|
||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
const variant = config.variant || props.variant || "card";
|
||||
|
||||
// 기본 옵션 (포항/광양)
|
||||
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
];
|
||||
|
||||
// 상태
|
||||
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
|
||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||
const [localDestination, setLocalDestination] = useState<string>("");
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
|
||||
|
||||
// 정적 옵션 처리 (기본값)
|
||||
// type이 없거나 static이거나, table인데 tableName이 없는 경우
|
||||
const shouldUseStatic =
|
||||
!dataSource.type ||
|
||||
dataSource.type === "static" ||
|
||||
(dataSource.type === "table" && !dataSource.tableName) ||
|
||||
(dataSource.type === "code" && !dataSource.codeCategory);
|
||||
|
||||
if (shouldUseStatic) {
|
||||
const staticOpts = dataSource.staticOptions || [];
|
||||
// 정적 옵션이 설정되어 있고, value가 유효한 경우 사용
|
||||
// (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용)
|
||||
const isValidOptions = staticOpts.length > 0 &&
|
||||
staticOpts[0]?.value &&
|
||||
staticOpts[0].value !== departureField &&
|
||||
staticOpts[0].value !== destinationField;
|
||||
|
||||
if (isValidOptions) {
|
||||
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
|
||||
setOptions(staticOpts);
|
||||
} else {
|
||||
// 기본값 (포항/광양)
|
||||
console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS });
|
||||
setOptions(DEFAULT_OPTIONS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "code" && dataSource.codeCategory) {
|
||||
// 코드 관리에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/code-management/codes`, {
|
||||
params: { categoryCode: dataSource.codeCategory },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
const codeOptions = response.data.data.map((code: any) => ({
|
||||
value: code.code_value || code.codeValue || code.code,
|
||||
label: code.code_name || code.codeName || code.name,
|
||||
}));
|
||||
setOptions(codeOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "table" && dataSource.tableName) {
|
||||
// 테이블에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
|
||||
params: { page: 1, pageSize: 1000 },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
// data가 배열인지 또는 data.rows인지 확인
|
||||
const rows = Array.isArray(response.data.data)
|
||||
? response.data.data
|
||||
: response.data.data.rows || [];
|
||||
const tableOptions = rows.map((row: any) => ({
|
||||
value: String(row[dataSource.valueField || "id"] || ""),
|
||||
label: String(row[dataSource.labelField || "name"] || ""),
|
||||
}));
|
||||
setOptions(tableOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadOptions();
|
||||
}, [dataSource, isDesignMode]);
|
||||
|
||||
// formData에서 초기값 동기화
|
||||
useEffect(() => {
|
||||
const depVal = formData[departureField];
|
||||
const destVal = formData[destinationField];
|
||||
|
||||
if (depVal && options.some(o => o.value === depVal)) {
|
||||
setLocalDeparture(depVal);
|
||||
}
|
||||
if (destVal && options.some(o => o.value === destVal)) {
|
||||
setLocalDestination(destVal);
|
||||
}
|
||||
}, [formData, departureField, destinationField, options]);
|
||||
|
||||
// 출발지 변경
|
||||
const handleDepartureChange = (selectedValue: string) => {
|
||||
console.log("[LocationSwapSelector] 출발지 변경:", {
|
||||
selectedValue,
|
||||
departureField,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
options
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setLocalDeparture(selectedValue);
|
||||
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
|
||||
onFormDataChange(departureField, selectedValue);
|
||||
// 라벨 필드도 업데이트
|
||||
if (departureLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
if (selectedOption) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`);
|
||||
onFormDataChange(departureLabelField, selectedOption.label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
// 도착지 변경
|
||||
const handleDestinationChange = (selectedValue: string) => {
|
||||
console.log("[LocationSwapSelector] 도착지 변경:", {
|
||||
selectedValue,
|
||||
destinationField,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
options
|
||||
});
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setLocalDestination(selectedValue);
|
||||
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
|
||||
onFormDataChange(destinationField, selectedValue);
|
||||
// 라벨 필드도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||
if (selectedOption) {
|
||||
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
|
||||
onFormDataChange(destinationLabelField, selectedOption.label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
// 출발지/도착지 교환
|
||||
const handleSwap = () => {
|
||||
setIsSwapping(true);
|
||||
|
||||
// 로컬 상태 교환
|
||||
const tempDeparture = localDeparture;
|
||||
const tempDestination = localDestination;
|
||||
|
||||
setLocalDeparture(tempDestination);
|
||||
setLocalDestination(tempDeparture);
|
||||
|
||||
// 부모에게 전달
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(departureField, tempDestination);
|
||||
onFormDataChange(destinationField, tempDeparture);
|
||||
|
||||
// 라벨도 교환
|
||||
if (departureLabelField && destinationLabelField) {
|
||||
const depOption = options.find(o => o.value === tempDestination);
|
||||
const destOption = options.find(o => o.value === tempDeparture);
|
||||
onFormDataChange(departureLabelField, depOption?.label || "");
|
||||
onFormDataChange(destinationLabelField, destOption?.label || "");
|
||||
}
|
||||
}
|
||||
|
||||
// 애니메이션 효과
|
||||
setTimeout(() => setIsSwapping(false), 300);
|
||||
};
|
||||
|
||||
// 스타일에서 width, height 추출
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
|
||||
// 선택된 라벨 가져오기
|
||||
const getDepartureLabel = () => {
|
||||
const opt = options.find(o => o.value === localDeparture);
|
||||
return opt?.label || "";
|
||||
};
|
||||
|
||||
const getDestinationLabel = () => {
|
||||
const opt = options.find(o => o.value === localDestination);
|
||||
return opt?.label || "";
|
||||
};
|
||||
|
||||
// 디버그 로그
|
||||
console.log("[LocationSwapSelector] 렌더:", {
|
||||
localDeparture,
|
||||
localDestination,
|
||||
options: options.map(o => `${o.value}:${o.label}`),
|
||||
});
|
||||
|
||||
// Card 스타일 (이미지 참고)
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="h-full w-full"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||
{/* 출발지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
|
||||
<Select
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className={cn(
|
||||
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
||||
isSwapping && "animate-pulse"
|
||||
)}>
|
||||
{localDeparture ? (
|
||||
<span>{getDepartureLabel()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">선택</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 교환 버튼 */}
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
className={cn(
|
||||
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
||||
isSwapping && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
|
||||
<Select
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className={cn(
|
||||
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
|
||||
isSwapping && "animate-pulse"
|
||||
)}>
|
||||
{localDestination ? (
|
||||
<span>{getDestinationLabel()}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">선택</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline 스타일
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-2"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
|
||||
<Select
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">선택</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
className="mt-5 h-10 w-10"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
|
||||
<Select
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">선택</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal 스타일
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-1"
|
||||
style={restStyle}
|
||||
>
|
||||
<Select
|
||||
value={localDeparture || undefined}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={localDestination || undefined}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
"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 { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationSwapSelectorConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 설정 패널
|
||||
*/
|
||||
export function LocationSwapSelectorConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
screenTableName,
|
||||
}: LocationSwapSelectorConfigPanelProps) {
|
||||
const [tables, setTables] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [codeCategories, setCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data.success && response.data.data) {
|
||||
setTables(
|
||||
response.data.data.map((t: any) => ({
|
||||
name: t.tableName || t.table_name,
|
||||
label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
const tableName = config?.dataSource?.tableName;
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data.success) {
|
||||
// API 응답 구조 처리: data가 배열이거나 data.columns가 배열일 수 있음
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
setColumns(
|
||||
columnData.map((c: any) => ({
|
||||
name: c.columnName || c.column_name || c.name,
|
||||
label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (config?.dataSource?.type === "table") {
|
||||
loadColumns();
|
||||
}
|
||||
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
|
||||
|
||||
// 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시)
|
||||
useEffect(() => {
|
||||
const loadCodeCategories = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/code-management/categories");
|
||||
if (response.data.success && response.data.data) {
|
||||
setCodeCategories(
|
||||
response.data.data.map((c: any) => ({
|
||||
value: c.category_code || c.categoryCode || c.code,
|
||||
label: c.category_name || c.categoryName || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 404는 API가 없는 것이므로 무시
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error("코드 카테고리 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCodeCategories();
|
||||
}, []);
|
||||
|
||||
const handleChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current: any = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.type || "static"}
|
||||
onValueChange={(value) => handleChange("dataSource.type", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정 옵션 (포항/광양 등)</SelectItem>
|
||||
<SelectItem value="table">테이블에서 가져오기</SelectItem>
|
||||
<SelectItem value="code">코드 관리에서 가져오기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 고정 옵션 설정 (type이 static일 때) */}
|
||||
{(!config?.dataSource?.type || config?.dataSource?.type === "static") && (
|
||||
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
||||
<h4 className="text-sm font-medium">고정 옵션 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 1 (값)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[0] = { ...newOptions[0], value: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: pohang"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 1 (표시명)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[0] = { ...newOptions[0], label: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: 포항"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 2 (값)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[1] = { ...newOptions[1], value: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: gwangyang"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>옵션 2 (표시명)</Label>
|
||||
<Input
|
||||
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
|
||||
onChange={(e) => {
|
||||
const options = config?.dataSource?.staticOptions || [];
|
||||
const newOptions = [...options];
|
||||
newOptions[1] = { ...newOptions[1], label: e.target.value };
|
||||
handleChange("dataSource.staticOptions", newOptions);
|
||||
}}
|
||||
placeholder="예: 광양"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
고정된 2개 장소만 사용할 때 설정하세요. (예: 포항 ↔ 광양)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 (type이 table일 때) */}
|
||||
{config?.dataSource?.type === "table" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>테이블</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.tableName || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.tableName", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>값 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.valueField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.valueField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>표시 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.labelField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.labelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 코드 카테고리 선택 (type이 code일 때) */}
|
||||
{config?.dataSource?.type === "code" && (
|
||||
<div className="space-y-2">
|
||||
<Label>코드 카테고리</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.codeCategory || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">필드 매핑 (저장 위치)</h4>
|
||||
{screenTableName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 화면 테이블: <strong>{screenTableName}</strong>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureField || ""}
|
||||
onValueChange={(value) => handleChange("departureField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureField || "departure"}
|
||||
onChange={(e) => handleChange("departureField", e.target.value)}
|
||||
placeholder="departure"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationField || ""}
|
||||
onValueChange={(value) => handleChange("destinationField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationField || "destination"}
|
||||
onChange={(e) => handleChange("destinationField", e.target.value)}
|
||||
placeholder="destination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureLabelField || "__none__"}
|
||||
onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureLabelField || ""}
|
||||
onChange={(e) => handleChange("departureLabelField", e.target.value)}
|
||||
placeholder="departure_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationLabelField || "__none__"}
|
||||
onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationLabelField || ""}
|
||||
onChange={(e) => handleChange("destinationLabelField", e.target.value)}
|
||||
placeholder="destination_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 라벨</Label>
|
||||
<Input
|
||||
value={config?.departureLabel || "출발지"}
|
||||
onChange={(e) => handleChange("departureLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 라벨</Label>
|
||||
<Input
|
||||
value={config?.destinationLabel || "도착지"}
|
||||
onChange={(e) => handleChange("destinationLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>스타일</Label>
|
||||
<Select
|
||||
value={config?.variant || "card"}
|
||||
onValueChange={(value) => handleChange("variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="card">카드 (이미지 참고)</SelectItem>
|
||||
<SelectItem value="inline">인라인</SelectItem>
|
||||
<SelectItem value="minimal">미니멀</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>교환 버튼 표시</Label>
|
||||
<Switch
|
||||
checked={config?.showSwapButton !== false}
|
||||
onCheckedChange={(checked) => handleChange("showSwapButton", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 데이터 소스에서 장소 목록을 가져올 위치를 선택합니다
|
||||
<br />
|
||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||
<br />
|
||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { LocationSwapSelectorDefinition } from "./index";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 렌더러
|
||||
*/
|
||||
export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = LocationSwapSelectorDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props;
|
||||
|
||||
// component.componentConfig에서 설정 가져오기
|
||||
const componentConfig = component?.componentConfig || {};
|
||||
|
||||
console.log("[LocationSwapSelectorRenderer] render:", {
|
||||
componentConfig,
|
||||
formData,
|
||||
isDesignMode
|
||||
});
|
||||
|
||||
return (
|
||||
<LocationSwapSelectorComponent
|
||||
id={component?.id}
|
||||
style={style}
|
||||
isDesignMode={isDesignMode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
componentConfig={componentConfig}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
LocationSwapSelectorRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
LocationSwapSelectorRenderer.enableHotReload();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트 정의
|
||||
* 출발지/도착지 선택 및 교환 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
export const LocationSwapSelectorDefinition = createComponentDefinition({
|
||||
id: "location-swap-selector",
|
||||
name: "출발지/도착지 선택",
|
||||
nameEng: "Location Swap Selector",
|
||||
description: "출발지와 도착지를 선택하고 교환할 수 있는 컴포넌트 (모바일 최적화)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "form",
|
||||
component: LocationSwapSelectorComponent,
|
||||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "static", // "table" | "code" | "static"
|
||||
tableName: "", // 장소 테이블명
|
||||
valueField: "location_code", // 값 필드
|
||||
labelField: "location_name", // 표시 필드
|
||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||
staticOptions: [
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
], // 정적 옵션 (type이 "static"일 때)
|
||||
},
|
||||
// 필드 매핑
|
||||
departureField: "departure", // 출발지 저장 필드
|
||||
destinationField: "destination", // 도착지 저장 필드
|
||||
departureLabelField: "departure_name", // 출발지명 저장 필드 (선택)
|
||||
destinationLabelField: "destination_name", // 도착지명 저장 필드 (선택)
|
||||
// UI 설정
|
||||
departureLabel: "출발지",
|
||||
destinationLabel: "도착지",
|
||||
showSwapButton: true,
|
||||
swapButtonPosition: "center", // "center" | "right"
|
||||
// 스타일
|
||||
variant: "card", // "card" | "inline" | "minimal"
|
||||
},
|
||||
defaultSize: { width: 400, height: 100 },
|
||||
configPanel: LocationSwapSelectorConfigPanel,
|
||||
icon: "ArrowLeftRight",
|
||||
tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
export { LocationSwapSelectorRenderer } from "./LocationSwapSelectorRenderer";
|
||||
|
||||
|
|
@ -1,44 +1,440 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
|
||||
import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
|
||||
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
|
||||
import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* Repeater Field Group 컴포넌트
|
||||
*/
|
||||
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
|
||||
const { component, value, onChange, readonly, disabled } = props;
|
||||
const { component, value, onChange, readonly, disabled, formData, onFormDataChange, menuObjid } = props;
|
||||
const screenContext = useScreenContextOptional();
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
const receiverRef = useRef<DataReceivable | null>(null);
|
||||
|
||||
// 🆕 그룹화된 데이터를 저장하는 상태
|
||||
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
||||
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
||||
const groupDataLoadedRef = useRef(false);
|
||||
|
||||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||
|
||||
// 컴포넌트의 필드명 (formData 키)
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = config.groupByColumn;
|
||||
const targetTable = config.targetTable;
|
||||
|
||||
// formData에서 값 가져오기 (value prop보다 우선)
|
||||
const rawValue = formData?.[fieldName] ?? value;
|
||||
|
||||
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
||||
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
||||
const isEditMode = formData?.id && !rawValue && !value;
|
||||
|
||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||
const configFields = config.fields || [];
|
||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||
|
||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||
fieldName,
|
||||
hasFormData: !!formData,
|
||||
formDataId: formData?.id,
|
||||
formDataValue: formData?.[fieldName],
|
||||
propsValue: value,
|
||||
rawValue,
|
||||
isEditMode,
|
||||
hasRepeaterFieldsInFormData,
|
||||
configFieldNames: configFields.map((f: any) => f.name),
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
matchingFieldNames: matchingFields.map((f: any) => f.name),
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
hasGroupedData: groupedData !== null,
|
||||
groupedDataLength: groupedData?.length,
|
||||
});
|
||||
|
||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadGroupedData = async () => {
|
||||
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
||||
if (groupDataLoadedRef.current) return;
|
||||
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
setIsLoadingGroupData(true);
|
||||
groupDataLoadedRef.current = true;
|
||||
|
||||
try {
|
||||
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
||||
// search 파라미터 사용 (filters가 아닌 search)
|
||||
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
|
||||
page: 1,
|
||||
size: 100, // 충분히 큰 값
|
||||
search: { [groupByColumn]: groupKeyValue },
|
||||
});
|
||||
|
||||
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
||||
success: response.data?.success,
|
||||
hasData: !!response.data?.data,
|
||||
dataType: typeof response.data?.data,
|
||||
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
||||
});
|
||||
|
||||
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
||||
if (response.data?.success && response.data?.data?.data) {
|
||||
const items = response.data.data.data; // 실제 데이터 배열
|
||||
console.log("✅ [RepeaterFieldGroup] 그룹 데이터 로드 완료:", {
|
||||
count: items.length,
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
firstItem: items[0],
|
||||
});
|
||||
setGroupedData(items);
|
||||
|
||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
||||
setOriginalItemIds(itemIds);
|
||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||
|
||||
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||
splitPanelContext.addItemIds(itemIds);
|
||||
}
|
||||
|
||||
// onChange 호출하여 부모에게 알림
|
||||
if (onChange && items.length > 0) {
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: targetTable,
|
||||
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ [RepeaterFieldGroup] 그룹 데이터 로드 실패:", response.data);
|
||||
setGroupedData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [RepeaterFieldGroup] 그룹 데이터 로드 오류:", error);
|
||||
setGroupedData([]);
|
||||
} finally {
|
||||
setIsLoadingGroupData(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGroupedData();
|
||||
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
||||
|
||||
// 값이 JSON 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
if (typeof value === "string") {
|
||||
|
||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
||||
if (groupedData !== null && groupedData.length > 0) {
|
||||
parsedValue = groupedData;
|
||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||
console.log("📝 [RepeaterFieldGroup] 수정 모드 - formData를 초기 데이터로 사용", {
|
||||
formDataId: formData?.id,
|
||||
matchingFieldsCount: matchingFields.length,
|
||||
});
|
||||
parsedValue = [{ ...formData }];
|
||||
} else if (typeof rawValue === "string" && rawValue.trim() !== "") {
|
||||
// 빈 문자열이 아닌 경우에만 JSON 파싱 시도
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
parsedValue = JSON.parse(rawValue);
|
||||
} catch {
|
||||
parsedValue = [];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
parsedValue = value;
|
||||
} else if (Array.isArray(rawValue)) {
|
||||
parsedValue = rawValue;
|
||||
}
|
||||
|
||||
// parsedValue를 ref로 관리하여 최신 값 유지
|
||||
const parsedValueRef = useRef(parsedValue);
|
||||
parsedValueRef.current = parsedValue;
|
||||
|
||||
// onChange를 ref로 관리
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// onFormDataChange를 ref로 관리
|
||||
const onFormDataChangeRef = useRef(onFormDataChange);
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
// fieldName을 ref로 관리
|
||||
const fieldNameRef = useRef(fieldName);
|
||||
fieldNameRef.current = fieldName;
|
||||
|
||||
// config를 ref로 관리
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
// 데이터 수신 핸들러
|
||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 규칙이 배열인 경우에만 적용
|
||||
let processedData = data;
|
||||
if (Array.isArray(mappingRulesOrMode) && mappingRulesOrMode.length > 0) {
|
||||
processedData = applyMappingRules(data, mappingRulesOrMode);
|
||||
}
|
||||
|
||||
// 데이터 정규화: 각 항목에서 실제 데이터 추출
|
||||
// 데이터가 {0: {...}, inbound_type: "..."} 형태인 경우 처리
|
||||
const normalizedData = processedData.map((item: any) => {
|
||||
// item이 {0: {...실제데이터...}, 추가필드: 값} 형태인 경우
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
// 0번 인덱스의 데이터와 나머지 필드를 병합
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
return { ...originalData, ...additionalFields };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
// 🆕 정의된 필드만 필터링 (불필요한 필드 제거)
|
||||
// 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지
|
||||
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']);
|
||||
|
||||
const filteredData = normalizedData.map((item: any) => {
|
||||
const filteredItem: Record<string, any> = {};
|
||||
Object.keys(item).forEach(key => {
|
||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||
if (definedFieldNames.has(key) || systemFields.has(key)) {
|
||||
filteredItem[key] = item[key];
|
||||
}
|
||||
});
|
||||
return filteredItem;
|
||||
});
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData);
|
||||
console.log("📥 [RepeaterFieldGroup] 필터링된 데이터:", filteredData);
|
||||
|
||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||
const currentValue = parsedValueRef.current;
|
||||
|
||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||
|
||||
let newItems: any[];
|
||||
let addedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
|
||||
if (mode === "replace") {
|
||||
newItems = filteredData;
|
||||
addedCount = filteredData.length;
|
||||
} else {
|
||||
// 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외
|
||||
const existingIds = new Set(
|
||||
currentValue
|
||||
.map((item: any) => item.id || item.po_item_id || item.item_id)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const uniqueNewItems = filteredData.filter((item: any) => {
|
||||
const itemId = item.id || item.po_item_id || item.item_id;
|
||||
if (itemId && existingIds.has(itemId)) {
|
||||
duplicateCount++;
|
||||
return false; // 중복 항목 제외
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
newItems = [...currentValue, ...uniqueNewItems];
|
||||
addedCount = uniqueNewItems.length;
|
||||
}
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||
currentValue,
|
||||
newItems,
|
||||
mode,
|
||||
addedCount,
|
||||
duplicateCount,
|
||||
});
|
||||
|
||||
// 🆕 groupedData 상태도 직접 업데이트 (UI 즉시 반영)
|
||||
setGroupedData(newItems);
|
||||
|
||||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||
const newItemIds = newItems
|
||||
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
||||
.filter(Boolean);
|
||||
splitPanelContext.addItemIds(newItemIds);
|
||||
}
|
||||
|
||||
// JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newItems);
|
||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||
jsonValue,
|
||||
hasOnChange: !!onChangeRef.current,
|
||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||
fieldName: fieldNameRef.current,
|
||||
});
|
||||
|
||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||
}
|
||||
// 그렇지 않으면 onChange 사용
|
||||
else if (onChangeRef.current) {
|
||||
onChangeRef.current(jsonValue);
|
||||
}
|
||||
|
||||
// 결과 메시지 표시
|
||||
if (addedCount > 0) {
|
||||
if (duplicateCount > 0) {
|
||||
toast.success(`${addedCount}개 항목이 추가되었습니다 (${duplicateCount}개 중복 제외)`);
|
||||
} else {
|
||||
toast.success(`${addedCount}개 항목이 추가되었습니다`);
|
||||
}
|
||||
} else if (duplicateCount > 0) {
|
||||
toast.warning(`${duplicateCount}개 항목이 이미 추가되어 있습니다`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}), [component.id, handleReceiveData]);
|
||||
|
||||
// ScreenContext에 데이터 수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataReceiver(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, dataReceiver]);
|
||||
|
||||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||
useEffect(() => {
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||
componentId: component.id,
|
||||
position: splitPanelPosition,
|
||||
});
|
||||
|
||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||
receiverRef.current = dataReceiver;
|
||||
|
||||
return () => {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||
receiverRef.current = null;
|
||||
};
|
||||
}
|
||||
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
|
||||
|
||||
// 🆕 전역 이벤트 리스너 (splitPanelDataTransfer)
|
||||
useEffect(() => {
|
||||
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
||||
const { data, mode, mappingRules } = event.detail;
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
||||
dataCount: data?.length,
|
||||
mode,
|
||||
componentId: component.id,
|
||||
});
|
||||
|
||||
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
if (splitPanelPosition === "right" && data && data.length > 0) {
|
||||
handleReceiveData(data, mappingRules || mode || "append");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
};
|
||||
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
||||
|
||||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||
const handleRepeaterChange = useCallback((newValue: any[]) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
|
||||
// 🆕 groupedData 상태도 업데이트
|
||||
setGroupedData(newValue);
|
||||
|
||||
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
||||
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
||||
// 현재 항목들의 ID 목록
|
||||
const currentIds = newValue
|
||||
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
||||
.filter(Boolean);
|
||||
|
||||
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
||||
const addedIds = splitPanelContext.addedItemIds;
|
||||
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
|
||||
|
||||
if (removedIds.length > 0) {
|
||||
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
||||
splitPanelContext.removeItemIds(removedIds);
|
||||
}
|
||||
|
||||
// 새로 추가된 ID가 있으면 등록
|
||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||
splitPanelContext.addItemIds(newIds);
|
||||
}
|
||||
}
|
||||
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
|
||||
|
||||
return (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={(newValue) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
}}
|
||||
onChange={handleRepeaterChange}
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
menuObjid={menuObjid}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ScreenSplitPanelConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (newConfig: any) => void;
|
||||
}
|
||||
|
||||
export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSplitPanelConfigPanelProps) {
|
||||
// 화면 목록 상태
|
||||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [isLoadingScreens, setIsLoadingScreens] = useState(true);
|
||||
|
||||
// Combobox 상태
|
||||
const [leftOpen, setLeftOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(false);
|
||||
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
screenId: config.screenId || 0,
|
||||
leftScreenId: config.leftScreenId || 0,
|
||||
rightScreenId: config.rightScreenId || 0,
|
||||
splitRatio: config.splitRatio || 50,
|
||||
resizable: config.resizable ?? true,
|
||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||
buttonPosition: config.buttonPosition || "center",
|
||||
...config,
|
||||
});
|
||||
|
||||
// config prop이 변경되면 localConfig 동기화
|
||||
useEffect(() => {
|
||||
console.log("🔄 [ScreenSplitPanelConfigPanel] config prop 변경 감지:", config);
|
||||
setLocalConfig({
|
||||
screenId: config.screenId || 0,
|
||||
leftScreenId: config.leftScreenId || 0,
|
||||
rightScreenId: config.rightScreenId || 0,
|
||||
splitRatio: config.splitRatio || 50,
|
||||
resizable: config.resizable ?? true,
|
||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||
buttonPosition: config.buttonPosition || "center",
|
||||
...config,
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
try {
|
||||
setIsLoadingScreens(true);
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
if (response.data) {
|
||||
setScreens(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingScreens(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
const updateConfig = (key: string, value: any) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
[key]: value,
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
|
||||
console.log("📝 [ScreenSplitPanelConfigPanel] 설정 변경:", {
|
||||
key,
|
||||
value,
|
||||
newConfig,
|
||||
hasOnChange: !!onChange,
|
||||
});
|
||||
|
||||
// 변경 즉시 부모에게 전달
|
||||
if (onChange) {
|
||||
onChange(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="layout" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="layout" className="gap-2">
|
||||
<Layout className="h-4 w-4" />
|
||||
레이아웃
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screens" className="gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
화면 설정
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 레이아웃 탭 */}
|
||||
<TabsContent value="layout" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">분할 비율</CardTitle>
|
||||
<CardDescription className="text-xs">좌측과 우측 패널의 너비 비율을 설정합니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="splitRatio" className="text-xs">
|
||||
좌측 패널 너비 (%)
|
||||
</Label>
|
||||
<span className="text-xs font-medium">{localConfig.splitRatio}%</span>
|
||||
</div>
|
||||
<Input
|
||||
id="splitRatio"
|
||||
type="range"
|
||||
min="20"
|
||||
max="80"
|
||||
step="5"
|
||||
value={localConfig.splitRatio}
|
||||
onChange={(e) => updateConfig("splitRatio", parseInt(e.target.value))}
|
||||
className="h-2"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>20%</span>
|
||||
<span>50%</span>
|
||||
<span>80%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="resizable" className="text-xs font-medium">
|
||||
크기 조절 가능
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">사용자가 패널 크기를 조절할 수 있습니다</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="resizable"
|
||||
checked={localConfig.resizable}
|
||||
onCheckedChange={(checked) => updateConfig("resizable", checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 화면 설정 탭 */}
|
||||
<TabsContent value="screens" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">임베드할 화면 선택</CardTitle>
|
||||
<CardDescription className="text-xs">좌측과 우측에 표시할 화면을 선택합니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoadingScreens ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">화면 목록 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leftScreenId" className="text-xs">
|
||||
좌측 화면 (소스)
|
||||
</Label>
|
||||
<Popover open={leftOpen} onOpenChange={setLeftOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={leftOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
>
|
||||
{localConfig.leftScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName || "화면 선택..."
|
||||
: "화면 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName} ${screen.screenCode}`}
|
||||
onSelect={() => {
|
||||
updateConfig("leftScreenId", screen.screenId);
|
||||
setLeftOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.leftScreenId === screen.screenId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-gray-500">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground text-xs">데이터를 선택할 소스 화면</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rightScreenId" className="text-xs">
|
||||
우측 화면 (타겟)
|
||||
</Label>
|
||||
<Popover open={rightOpen} onOpenChange={setRightOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
>
|
||||
{localConfig.rightScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName ||
|
||||
"화면 선택..."
|
||||
: "화면 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName} ${screen.screenCode}`}
|
||||
onSelect={() => {
|
||||
updateConfig("rightScreenId", screen.screenId);
|
||||
setRightOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.rightScreenId === screen.screenId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-gray-500">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground text-xs">데이터를 받을 타겟 화면</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||
💡 <strong>데이터 전달 방법:</strong> 좌측 화면에 테이블과 버튼을 배치하고, 버튼의 액션을
|
||||
"transferData"로 설정하세요.
|
||||
<br />
|
||||
버튼 설정에서 소스 컴포넌트(테이블), 타겟 화면, 필드 매핑을 지정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 설정 요약 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">현재 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">좌측 화면:</span>
|
||||
<span className="font-medium">
|
||||
{localConfig.leftScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName ||
|
||||
`ID: ${localConfig.leftScreenId}`
|
||||
: "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">우측 화면:</span>
|
||||
<span className="font-medium">
|
||||
{localConfig.rightScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName ||
|
||||
`ID: ${localConfig.rightScreenId}`
|
||||
: "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">분할 비율:</span>
|
||||
<span className="font-medium">
|
||||
{localConfig.splitRatio}% / {100 - localConfig.splitRatio}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">크기 조절:</span>
|
||||
<span className="font-medium">{localConfig.resizable ? "가능" : "불가능"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { ScreenSplitPanel } from "@/components/screen-embedding/ScreenSplitPanel";
|
||||
import { ScreenSplitPanelConfigPanel } from "./ScreenSplitPanelConfigPanel";
|
||||
|
||||
/**
|
||||
* 화면 분할 패널 Renderer
|
||||
* 좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = {
|
||||
id: "screen-split-panel",
|
||||
name: "화면 분할 패널",
|
||||
nameEng: "Screen Split Panel",
|
||||
description: "좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 분할 패널",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "text", // 레이아웃 컴포넌트는 기본 webType 사용
|
||||
component: ScreenSplitPanelRenderer, // 🆕 Renderer 클래스 자체를 등록 (ScreenSplitPanel 아님)
|
||||
configPanel: ScreenSplitPanelConfigPanel, // 설정 패널
|
||||
tags: ["split", "panel", "embed", "data-transfer", "layout"],
|
||||
defaultSize: {
|
||||
width: 1200,
|
||||
height: 600,
|
||||
},
|
||||
defaultConfig: {
|
||||
screenId: 0,
|
||||
leftScreenId: 0,
|
||||
rightScreenId: 0,
|
||||
splitRatio: 50,
|
||||
resizable: true,
|
||||
buttonLabel: "데이터 전달",
|
||||
buttonPosition: "center",
|
||||
},
|
||||
version: "1.0.0",
|
||||
author: "ERP System",
|
||||
documentation: `
|
||||
# 화면 분할 패널
|
||||
|
||||
좌우로 화면을 나누고 각 영역에 다른 화면을 임베딩할 수 있는 레이아웃 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **화면 임베딩**: 좌우 영역에 기존 화면을 임베딩
|
||||
- **데이터 전달**: 좌측 화면에서 선택한 데이터를 우측 화면으로 전달
|
||||
- **다중 컴포넌트 매핑**: 테이블, 입력 필드, 폼 등 다양한 컴포넌트로 데이터 전달 가능
|
||||
- **데이터 변환**: sum, average, concat 등 데이터 변환 함수 지원
|
||||
- **조건부 전달**: 특정 조건을 만족하는 데이터만 전달 가능
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
1. **입고 등록**: 발주 목록(좌) → 입고 품목 입력(우)
|
||||
2. **수주 등록**: 품목 목록(좌) → 수주 상세 입력(우)
|
||||
3. **출고 등록**: 재고 목록(좌) → 출고 품목 입력(우)
|
||||
|
||||
## 설정 방법
|
||||
|
||||
1. 화면 디자이너에서 "화면 분할 패널" 컴포넌트를 드래그하여 배치
|
||||
2. 속성 패널에서 좌측/우측 화면 선택
|
||||
3. 데이터 전달 규칙 설정 (소스 → 타겟 매핑)
|
||||
4. 전달 버튼 설정 (라벨, 위치, 검증 규칙)
|
||||
`,
|
||||
};
|
||||
|
||||
render() {
|
||||
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
|
||||
|
||||
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
|
||||
|
||||
// componentConfig 또는 config 또는 component.componentConfig 사용
|
||||
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
||||
|
||||
console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", {
|
||||
hasComponentConfig: !!componentConfig,
|
||||
hasConfig: !!config,
|
||||
hasComponentComponentConfig: !!component?.componentConfig,
|
||||
finalConfig,
|
||||
splitRatio: finalConfig.splitRatio,
|
||||
leftScreenId: finalConfig.leftScreenId,
|
||||
rightScreenId: finalConfig.rightScreenId,
|
||||
componentType: component?.componentType,
|
||||
componentId: component?.id,
|
||||
});
|
||||
|
||||
// 🆕 formData 별도 로그 (명확한 확인)
|
||||
console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", {
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
formData: formData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", ...style }}>
|
||||
<ScreenSplitPanel
|
||||
screenId={screenId || finalConfig.screenId}
|
||||
config={finalConfig}
|
||||
initialFormData={formData} // 🆕 수정 데이터 전달
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록
|
||||
ScreenSplitPanelRenderer.registerSelf();
|
||||
|
||||
export default ScreenSplitPanelRenderer;
|
||||
|
|
@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
|
|||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
|
|
@ -50,6 +52,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
menuObjid, // 🆕 메뉴 OBJID
|
||||
...props
|
||||
}) => {
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
|
||||
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
|
||||
componentId: component.id,
|
||||
|
|
@ -249,6 +254,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
|
||||
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
|
||||
|
||||
// 📦 DataProvidable 인터페이스 구현 (데이터 전달 시 셀렉트 값 제공)
|
||||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
componentType: "select",
|
||||
|
||||
getSelectedData: () => {
|
||||
// 현재 선택된 값을 배열로 반환
|
||||
const fieldName = component.columnName || "selectedValue";
|
||||
return [{
|
||||
[fieldName]: selectedValue,
|
||||
value: selectedValue,
|
||||
label: selectedLabel,
|
||||
}];
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
// 모든 옵션 반환
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
setSelectedValue("");
|
||||
setSelectedLabel("");
|
||||
if (isMultiple) {
|
||||
setSelectedValues([]);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
screenContext.registerDataProvider(component.id, dataProvider);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, selectedValue, selectedLabel, selectedValues]);
|
||||
|
||||
// 선택된 값에 따른 라벨 업데이트
|
||||
useEffect(() => {
|
||||
const getAllOptions = () => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
|||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||
|
||||
// ========================================
|
||||
// 인터페이스
|
||||
|
|
@ -251,6 +254,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const { userId: authUserId } = useAuth();
|
||||
const currentUserId = userId || authUserId;
|
||||
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||||
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||||
|
||||
// TableOptions Context
|
||||
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||
|
|
@ -316,6 +330,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
|
||||
const filteredData = useMemo(() => {
|
||||
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링
|
||||
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
|
||||
const addedIds = splitPanelContext.addedItemIds;
|
||||
const filtered = data.filter((row) => {
|
||||
const rowId = String(row.id || row.po_item_id || row.item_id || "");
|
||||
return !addedIds.has(rowId);
|
||||
});
|
||||
console.log("🔍 [TableList] 우측 추가 항목 필터링:", {
|
||||
originalCount: data.length,
|
||||
filteredCount: filtered.length,
|
||||
addedIdsCount: addedIds.size,
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
return data;
|
||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
|
@ -359,6 +392,200 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
|
||||
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
||||
useEffect(() => {
|
||||
const linkedFilters = tableConfig.linkedFilters;
|
||||
|
||||
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
||||
const checkLinkedFilters = () => {
|
||||
const newFilterValues: Record<string, any> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
linkedFilters.forEach((filter) => {
|
||||
if (filter.enabled === false) return;
|
||||
|
||||
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
||||
if (sourceProvider) {
|
||||
const selectedData = sourceProvider.getSelectedData();
|
||||
if (selectedData && selectedData.length > 0) {
|
||||
const sourceField = filter.sourceField || "value";
|
||||
const value = selectedData[0][sourceField];
|
||||
|
||||
if (value !== linkedFilterValues[filter.targetColumn]) {
|
||||
newFilterValues[filter.targetColumn] = value;
|
||||
hasChanges = true;
|
||||
} else {
|
||||
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
|
||||
setLinkedFilterValues(newFilterValues);
|
||||
|
||||
// searchValues에 연결된 필터 값 병합
|
||||
setSearchValues(prev => ({
|
||||
...prev,
|
||||
...newFilterValues
|
||||
}));
|
||||
|
||||
// 첫 페이지로 이동
|
||||
setCurrentPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 체크
|
||||
checkLinkedFilters();
|
||||
|
||||
// 주기적으로 체크 (500ms마다)
|
||||
const intervalId = setInterval(checkLinkedFilters, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [screenContext, tableConfig.linkedFilters, linkedFilterValues]);
|
||||
|
||||
// DataProvidable 인터페이스 구현
|
||||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
componentType: "table-list",
|
||||
|
||||
getSelectedData: () => {
|
||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||
const selectedData = filteredData.filter((row) => {
|
||||
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
|
||||
return selectedRows.has(rowId);
|
||||
});
|
||||
return selectedData;
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
// 🆕 필터링된 데이터 반환
|
||||
return filteredData;
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
setSelectedRows(new Set());
|
||||
setIsAllSelected(false);
|
||||
},
|
||||
};
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver: DataReceivable = {
|
||||
componentId: component.id,
|
||||
componentType: "table",
|
||||
|
||||
receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
|
||||
console.log("📥 TableList 데이터 수신:", {
|
||||
componentId: component.id,
|
||||
receivedDataCount: receivedData.length,
|
||||
mode: config.mode,
|
||||
currentDataCount: data.length,
|
||||
});
|
||||
|
||||
try {
|
||||
let newData: any[] = [];
|
||||
|
||||
switch (config.mode) {
|
||||
case "append":
|
||||
// 기존 데이터에 추가
|
||||
newData = [...data, ...receivedData];
|
||||
console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length });
|
||||
break;
|
||||
|
||||
case "replace":
|
||||
// 기존 데이터를 완전히 교체
|
||||
newData = receivedData;
|
||||
console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length });
|
||||
break;
|
||||
|
||||
case "merge":
|
||||
// 기존 데이터와 병합 (ID 기반)
|
||||
const existingMap = new Map(data.map(item => [item.id, item]));
|
||||
receivedData.forEach(item => {
|
||||
if (item.id && existingMap.has(item.id)) {
|
||||
// 기존 데이터 업데이트
|
||||
existingMap.set(item.id, { ...existingMap.get(item.id), ...item });
|
||||
} else {
|
||||
// 새 데이터 추가
|
||||
existingMap.set(item.id || Date.now() + Math.random(), item);
|
||||
}
|
||||
});
|
||||
newData = Array.from(existingMap.values());
|
||||
console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length });
|
||||
break;
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
setData(newData);
|
||||
|
||||
// 총 아이템 수 업데이트
|
||||
setTotalItems(newData.length);
|
||||
|
||||
console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length });
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터 수신 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getData: () => {
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
screenContext.registerDataProvider(component.id, dataProvider);
|
||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(component.id);
|
||||
screenContext.unregisterDataReceiver(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, data, selectedRows]);
|
||||
|
||||
// 분할 패널 컨텍스트에 데이터 수신자로 등록
|
||||
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (splitPanelContext && component.id && currentSplitPosition) {
|
||||
const splitPanelReceiver = {
|
||||
componentId: component.id,
|
||||
componentType: "table-list",
|
||||
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
|
||||
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
|
||||
count: incomingData.length,
|
||||
mode,
|
||||
position: currentSplitPosition,
|
||||
});
|
||||
|
||||
await dataReceiver.receiveData(incomingData, {
|
||||
targetComponentId: component.id,
|
||||
targetComponentType: "table-list",
|
||||
mode,
|
||||
mappingRules: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver);
|
||||
|
||||
return () => {
|
||||
splitPanelContext.unregisterReceiver(currentSplitPosition, component.id);
|
||||
};
|
||||
}
|
||||
}, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]);
|
||||
|
||||
// 테이블 등록 (Context에 등록)
|
||||
const tableId = `table-list-${component.id}`;
|
||||
|
||||
|
|
@ -850,34 +1077,63 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const search = searchTerm || undefined;
|
||||
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
||||
|
||||
const entityJoinColumns = (tableConfig.columns || [])
|
||||
.filter((col) => col.additionalJoinInfo)
|
||||
.map((col) => ({
|
||||
sourceTable: col.additionalJoinInfo!.sourceTable,
|
||||
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
||||
joinAlias: col.additionalJoinInfo!.joinAlias,
|
||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
// 🆕 REST API 데이터 소스 처리
|
||||
const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_");
|
||||
|
||||
let response: any;
|
||||
|
||||
if (isRestApiTable) {
|
||||
// REST API 데이터 소스인 경우
|
||||
const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/);
|
||||
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
|
||||
|
||||
if (connectionId) {
|
||||
console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId });
|
||||
|
||||
// REST API 연결 정보 가져오기 및 데이터 조회
|
||||
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
|
||||
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||
connectionId,
|
||||
undefined, // endpoint - 연결 정보에서 가져옴
|
||||
"response", // jsonPath - 기본값 response
|
||||
);
|
||||
|
||||
response = {
|
||||
data: restApiData.rows || [],
|
||||
total: restApiData.total || restApiData.rows?.length || 0,
|
||||
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
|
||||
};
|
||||
|
||||
console.log("✅ [TableList] REST API 응답:", {
|
||||
dataLength: response.data.length,
|
||||
total: response.total
|
||||
});
|
||||
} else {
|
||||
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
|
||||
}
|
||||
} else {
|
||||
// 일반 DB 테이블인 경우 (기존 로직)
|
||||
const entityJoinColumns = (tableConfig.columns || [])
|
||||
.filter((col) => col.additionalJoinInfo)
|
||||
.map((col) => ({
|
||||
sourceTable: col.additionalJoinInfo!.sourceTable,
|
||||
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
||||
joinAlias: col.additionalJoinInfo!.joinAlias,
|
||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
|
||||
// console.log("🔍 [TableList] API 호출 시작", {
|
||||
// tableName: tableConfig.selectedTable,
|
||||
// page,
|
||||
// pageSize,
|
||||
// sortBy,
|
||||
// sortOrder,
|
||||
// });
|
||||
|
||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page,
|
||||
size: pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||
});
|
||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page,
|
||||
size: pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search: filters,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||
});
|
||||
}
|
||||
|
||||
// 실제 데이터의 item_number만 추출하여 중복 확인
|
||||
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
||||
|
|
@ -1168,31 +1424,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
setIsAllSelected(allRowsSelected && data.length > 0);
|
||||
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
setIsAllSelected(allRowsSelected && filteredData.length > 0);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allKeys = data.map((row, index) => getRowKey(row, index));
|
||||
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
|
||||
const newSelectedRows = new Set(allKeys);
|
||||
setSelectedRows(newSelectedRows);
|
||||
setIsAllSelected(true);
|
||||
|
||||
if (onSelectedRowsChange) {
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
||||
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
selectedRowsData: data,
|
||||
selectedRowsData: filteredData,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 전체 데이터 저장
|
||||
if (tableConfig.selectedTable && data.length > 0) {
|
||||
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
const modalItems = data.map((row, idx) => ({
|
||||
const modalItems = filteredData.map((row, idx) => ({
|
||||
id: getRowKey(row, idx),
|
||||
originalData: row,
|
||||
additionalData: {},
|
||||
|
|
@ -1796,11 +2052,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 데이터 그룹화
|
||||
const groupedData = useMemo((): GroupedData[] => {
|
||||
if (groupByColumns.length === 0 || data.length === 0) return [];
|
||||
if (groupByColumns.length === 0 || filteredData.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
|
||||
data.forEach((item) => {
|
||||
filteredData.forEach((item) => {
|
||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||
const keyParts = groupByColumns.map((col) => {
|
||||
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
||||
|
|
@ -2127,7 +2383,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<SingleTableWithSticky
|
||||
data={data}
|
||||
columns={visibleColumns}
|
||||
|
|
@ -2194,7 +2450,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<div
|
||||
className="flex flex-1 flex-col"
|
||||
style={{
|
||||
marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
|
|
@ -2224,7 +2479,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="sticky z-50"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "-2px",
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
|
|
@ -2499,7 +2754,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
})
|
||||
) : (
|
||||
// 일반 렌더링 (그룹 없음)
|
||||
data.map((row, index) => (
|
||||
filteredData.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1214,6 +1214,114 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🆕 연결된 필터 설정 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">연결된 필터</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
셀렉트박스 등 다른 컴포넌트의 값으로 테이블 데이터를 실시간 필터링합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 연결된 필터 목록 */}
|
||||
<div className="space-y-2">
|
||||
{(config.linkedFilters || []).map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="소스 컴포넌트 ID"
|
||||
value={filter.sourceComponentId || ""}
|
||||
onChange={(e) => {
|
||||
const newFilters = [...(config.linkedFilters || [])];
|
||||
newFilters[index] = { ...filter, sourceComponentId: e.target.value };
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<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>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
const newFilters = [...(config.linkedFilters || [])];
|
||||
newFilters[index] = { ...filter, targetColumn: col.columnName };
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
filter.targetColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{col.label || col.columnName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newFilters = (config.linkedFilters || []).filter((_, i) => i !== index);
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 연결된 필터 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newFilters = [
|
||||
...(config.linkedFilters || []),
|
||||
{ sourceComponentId: "", targetColumn: "", operator: "equals" as const, enabled: true }
|
||||
];
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연결된 필터 추가
|
||||
</Button>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
예: 셀렉트박스(ID: select-basic-123)의 값으로 테이블의 inbound_type 컬럼을 필터링
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -170,6 +170,18 @@ export interface CheckboxConfig {
|
|||
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결된 필터 설정
|
||||
* 다른 컴포넌트(셀렉트박스 등)의 값으로 테이블 데이터를 필터링
|
||||
*/
|
||||
export interface LinkedFilterConfig {
|
||||
sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등)
|
||||
sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value)
|
||||
targetColumn: string; // 필터링할 테이블 컬럼명
|
||||
operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals)
|
||||
enabled?: boolean; // 활성화 여부 (기본: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* TableList 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -231,6 +243,9 @@ export interface TableListConfig extends ComponentConfig {
|
|||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
dataFilter?: DataFilterConfig;
|
||||
|
||||
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
|
||||
linkedFilters?: LinkedFilterConfig[];
|
||||
|
||||
// 이벤트 핸들러
|
||||
onRowClick?: (row: any) => void;
|
||||
onRowDoubleClick?: (row: any) => void;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Settings, Filter, Layers, X } from "lucide-react";
|
||||
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
||||
|
|
@ -13,6 +13,9 @@ import { TableFilter } from "@/types/table-options";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PresetFilter {
|
||||
id: string;
|
||||
|
|
@ -20,6 +23,7 @@ interface PresetFilter {
|
|||
columnLabel: string;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
|
||||
}
|
||||
|
||||
interface TableSearchWidgetProps {
|
||||
|
|
@ -280,6 +284,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
}
|
||||
}
|
||||
|
||||
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
|
||||
if (filter.filterType === "select" && Array.isArray(filterValue)) {
|
||||
filterValue = filterValue.join("|");
|
||||
}
|
||||
|
||||
return {
|
||||
...filter,
|
||||
value: filterValue || "",
|
||||
|
|
@ -289,6 +298,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
// 빈 값 체크
|
||||
if (!f.value) return false;
|
||||
if (typeof f.value === "string" && f.value === "") return false;
|
||||
if (Array.isArray(f.value) && f.value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
@ -343,12 +353,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
case "select": {
|
||||
let options = selectOptions[filter.columnName] || [];
|
||||
|
||||
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
|
||||
if (value && !options.find((opt) => opt.value === value)) {
|
||||
const savedLabel = selectedLabels[filter.columnName] || value;
|
||||
options = [{ value, label: savedLabel }, ...options];
|
||||
}
|
||||
|
||||
// 중복 제거 (value 기준)
|
||||
const uniqueOptions = options.reduce(
|
||||
(acc, option) => {
|
||||
|
|
@ -360,39 +364,86 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
[] as Array<{ value: string; label: string }>,
|
||||
);
|
||||
|
||||
// 항상 다중선택 모드
|
||||
const selectedValues: string[] = Array.isArray(value) ? value : (value ? [value] : []);
|
||||
|
||||
// 선택된 값들의 라벨 표시
|
||||
const getDisplayText = () => {
|
||||
if (selectedValues.length === 0) return column?.columnLabel || "선택";
|
||||
if (selectedValues.length === 1) {
|
||||
const opt = uniqueOptions.find(o => o.value === selectedValues[0]);
|
||||
return opt?.label || selectedValues[0];
|
||||
}
|
||||
return `${selectedValues.length}개 선택됨`;
|
||||
};
|
||||
|
||||
const handleMultiSelectChange = (optionValue: string, checked: boolean) => {
|
||||
let newValues: string[];
|
||||
if (checked) {
|
||||
newValues = [...selectedValues, optionValue];
|
||||
} else {
|
||||
newValues = selectedValues.filter(v => v !== optionValue);
|
||||
}
|
||||
handleFilterChange(filter.columnName, newValues.length > 0 ? newValues : "");
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
// 선택한 값의 라벨 저장
|
||||
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
|
||||
if (selectedOption) {
|
||||
setSelectedLabels((prev) => ({
|
||||
...prev,
|
||||
[filter.columnName]: selectedOption.label,
|
||||
}));
|
||||
}
|
||||
handleFilterChange(filter.columnName, val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"h-9 min-h-9 justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
|
||||
selectedValues.length === 0 && "text-muted-foreground"
|
||||
)}
|
||||
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
>
|
||||
<span className="truncate">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: `${width}px` }}
|
||||
align="start"
|
||||
>
|
||||
<SelectValue placeholder={column?.columnLabel || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">옵션 없음</div>
|
||||
) : (
|
||||
uniqueOptions.map((option, index) => (
|
||||
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{uniqueOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${filter.columnName}-multi-${option.value}-${index}`}
|
||||
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleMultiSelectChange(option.value, !selectedValues.includes(option.value))}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="border-t p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => handleFilterChange(filter.columnName, "")}
|
||||
>
|
||||
선택 초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface PresetFilter {
|
|||
columnLabel: string;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
|
||||
}
|
||||
|
||||
export function TableSearchWidgetConfigPanel({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* 데이터 매핑 유틸리티
|
||||
* 화면 간 데이터 전달 시 매핑 규칙 적용
|
||||
*/
|
||||
|
||||
import type {
|
||||
MappingRule,
|
||||
Condition,
|
||||
TransformFunction,
|
||||
} from "@/types/screen-embedding";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* 매핑 규칙 적용
|
||||
* @param data 배열 또는 단일 객체
|
||||
* @param rules 매핑 규칙 배열
|
||||
* @returns 매핑된 배열
|
||||
*/
|
||||
export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] {
|
||||
// 빈 데이터 처리
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
|
||||
if (dataArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 규칙이 없으면 원본 데이터 반환
|
||||
if (!rules || rules.length === 0) {
|
||||
return dataArray;
|
||||
}
|
||||
|
||||
// 변환 함수가 있는 규칙 확인
|
||||
const hasTransform = rules.some((rule) => rule.transform && rule.transform !== "none");
|
||||
|
||||
if (hasTransform) {
|
||||
// 변환 함수가 있으면 단일 값 또는 집계 결과 반환
|
||||
return [applyTransformRules(dataArray, rules)];
|
||||
}
|
||||
|
||||
// 일반 매핑 (각 행에 대해 매핑)
|
||||
// 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지)
|
||||
return dataArray.map((row) => {
|
||||
// 원본 데이터 복사
|
||||
const mappedRow: any = { ...row };
|
||||
|
||||
for (const rule of rules) {
|
||||
// sourceField와 targetField가 모두 있어야 매핑 적용
|
||||
if (!rule.sourceField || !rule.targetField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceValue = getNestedValue(row, rule.sourceField);
|
||||
const targetValue = sourceValue ?? rule.defaultValue;
|
||||
|
||||
// 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정
|
||||
if (rule.sourceField !== rule.targetField) {
|
||||
delete mappedRow[rule.sourceField];
|
||||
}
|
||||
|
||||
setNestedValue(mappedRow, rule.targetField, targetValue);
|
||||
}
|
||||
|
||||
return mappedRow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 변환 함수 적용
|
||||
*/
|
||||
function applyTransformRules(data: any[], rules: MappingRule[]): any {
|
||||
const result: any = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
const values = data.map((row) => getNestedValue(row, rule.sourceField));
|
||||
const transformedValue = applyTransform(values, rule.transform || "none");
|
||||
|
||||
setNestedValue(result, rule.targetField, transformedValue);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변환 함수 실행
|
||||
*/
|
||||
function applyTransform(values: any[], transform: TransformFunction): any {
|
||||
switch (transform) {
|
||||
case "none":
|
||||
return values;
|
||||
|
||||
case "sum":
|
||||
return values.reduce((sum, val) => sum + (Number(val) || 0), 0);
|
||||
|
||||
case "average":
|
||||
const sum = values.reduce((s, val) => s + (Number(val) || 0), 0);
|
||||
return values.length > 0 ? sum / values.length : 0;
|
||||
|
||||
case "count":
|
||||
return values.length;
|
||||
|
||||
case "min":
|
||||
return Math.min(...values.map((v) => Number(v) || 0));
|
||||
|
||||
case "max":
|
||||
return Math.max(...values.map((v) => Number(v) || 0));
|
||||
|
||||
case "first":
|
||||
return values[0];
|
||||
|
||||
case "last":
|
||||
return values[values.length - 1];
|
||||
|
||||
case "concat":
|
||||
return values.filter((v) => v != null).join("");
|
||||
|
||||
case "join":
|
||||
return values.filter((v) => v != null).join(", ");
|
||||
|
||||
case "custom":
|
||||
// TODO: 커스텀 함수 실행
|
||||
logger.warn("커스텀 변환 함수는 아직 구현되지 않았습니다.");
|
||||
return values;
|
||||
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건에 따른 데이터 필터링
|
||||
*/
|
||||
export function filterDataByCondition(data: any[], condition: Condition): any[] {
|
||||
return data.filter((row) => {
|
||||
const value = getNestedValue(row, condition.field);
|
||||
return evaluateCondition(value, condition.operator, condition.value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 평가
|
||||
*/
|
||||
function evaluateCondition(value: any, operator: string, targetValue: any): boolean {
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return value === targetValue;
|
||||
|
||||
case "notEquals":
|
||||
return value !== targetValue;
|
||||
|
||||
case "contains":
|
||||
return String(value).includes(String(targetValue));
|
||||
|
||||
case "notContains":
|
||||
return !String(value).includes(String(targetValue));
|
||||
|
||||
case "greaterThan":
|
||||
return Number(value) > Number(targetValue);
|
||||
|
||||
case "lessThan":
|
||||
return Number(value) < Number(targetValue);
|
||||
|
||||
case "greaterThanOrEqual":
|
||||
return Number(value) >= Number(targetValue);
|
||||
|
||||
case "lessThanOrEqual":
|
||||
return Number(value) <= Number(targetValue);
|
||||
|
||||
case "in":
|
||||
return Array.isArray(targetValue) && targetValue.includes(value);
|
||||
|
||||
case "notIn":
|
||||
return Array.isArray(targetValue) && !targetValue.includes(value);
|
||||
|
||||
default:
|
||||
logger.warn(`알 수 없는 조건 연산자: ${operator}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체에서 값 가져오기
|
||||
* 예: "user.address.city" -> obj.user.address.city
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
if (!obj || !path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
let value = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
value = value[key];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체에 값 설정
|
||||
* 예: "user.address.city", "Seoul" -> obj.user.address.city = "Seoul"
|
||||
*/
|
||||
function setNestedValue(obj: any, path: string, value: any): void {
|
||||
if (!obj || !path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
const lastKey = keys.pop()!;
|
||||
let current = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!(key in current)) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 결과 검증
|
||||
*/
|
||||
export function validateMappingResult(
|
||||
data: any[],
|
||||
rules: MappingRule[]
|
||||
): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredRules = rules.filter((rule) => rule.required);
|
||||
|
||||
for (const rule of requiredRules) {
|
||||
const hasValue = data.some((row) => {
|
||||
const value = getNestedValue(row, rule.targetField);
|
||||
return value != null && value !== "";
|
||||
});
|
||||
|
||||
if (!hasValue) {
|
||||
errors.push(`필수 필드 누락: ${rule.targetField}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 규칙 미리보기
|
||||
* 실제 데이터 전달 전에 결과를 미리 확인
|
||||
*/
|
||||
export function previewMapping(
|
||||
sampleData: any[],
|
||||
rules: MappingRule[]
|
||||
): { success: boolean; preview: any[]; errors?: string[] } {
|
||||
try {
|
||||
const preview = applyMappingRules(sampleData.slice(0, 5), rules);
|
||||
const validation = validateMappingResult(preview, rules);
|
||||
|
||||
return {
|
||||
success: validation.valid,
|
||||
preview,
|
||||
errors: validation.errors,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
preview: [],
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -864,11 +864,14 @@ export class ImprovedButtonActionExecutor {
|
|||
context: ButtonExecutionContext,
|
||||
): Promise<ExecutionResult> {
|
||||
try {
|
||||
// 기존 ButtonActionExecutor 로직을 여기서 호출하거나
|
||||
// 간단한 액션들을 직접 구현
|
||||
const startTime = performance.now();
|
||||
|
||||
// 임시 구현 - 실제로는 기존 ButtonActionExecutor를 호출해야 함
|
||||
// transferData 액션 처리
|
||||
if (buttonConfig.actionType === "transferData") {
|
||||
return await this.executeTransferDataAction(buttonConfig, formData, context);
|
||||
}
|
||||
|
||||
// 기존 액션들 (임시 구현)
|
||||
const result = {
|
||||
success: true,
|
||||
message: `${buttonConfig.actionType} 액션 실행 완료`,
|
||||
|
|
@ -889,6 +892,43 @@ export class ImprovedButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 액션 실행
|
||||
*/
|
||||
private static async executeTransferDataAction(
|
||||
buttonConfig: ExtendedButtonTypeConfig,
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext,
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const dataTransferConfig = buttonConfig.dataTransfer;
|
||||
|
||||
if (!dataTransferConfig) {
|
||||
throw new Error("데이터 전달 설정이 없습니다.");
|
||||
}
|
||||
|
||||
console.log("📦 데이터 전달 시작:", dataTransferConfig);
|
||||
|
||||
// 1. 화면 컨텍스트에서 소스 컴포넌트 찾기
|
||||
const { ScreenContextProvider } = await import("@/contexts/ScreenContext");
|
||||
// 실제로는 현재 화면의 컨텍스트를 사용해야 하지만, 여기서는 전역적으로 접근할 수 없음
|
||||
// 대신 context에 screenContext를 전달하도록 수정 필요
|
||||
|
||||
throw new Error("데이터 전달 기능은 버튼 컴포넌트에서 직접 구현되어야 합니다.");
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터 전달 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: `데이터 전달 실패: ${error.message}`,
|
||||
executionTime: performance.now() - startTime,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 실행 오류 처리 및 롤백
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 프론트엔드 로거 유틸리티
|
||||
*/
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
class Logger {
|
||||
private isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
private log(level: LogLevel, message: string, data?: any) {
|
||||
if (!this.isDevelopment && level === "debug") {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
console.debug(prefix, message, data || "");
|
||||
break;
|
||||
case "info":
|
||||
console.info(prefix, message, data || "");
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(prefix, message, data || "");
|
||||
break;
|
||||
case "error":
|
||||
console.error(prefix, message, data || "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, data?: any) {
|
||||
this.log("debug", message, data);
|
||||
}
|
||||
|
||||
info(message: string, data?: any) {
|
||||
this.log("info", message, data);
|
||||
}
|
||||
|
||||
warn(message: string, data?: any) {
|
||||
this.log("warn", message, data);
|
||||
}
|
||||
|
||||
error(message: string, data?: any) {
|
||||
this.log("error", message, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* 데이터 전달 시스템 타입 정의
|
||||
* 컴포넌트 간, 화면 간 데이터 전달을 위한 공통 타입들
|
||||
*/
|
||||
|
||||
/**
|
||||
* 데이터 수신 가능한 컴포넌트 타입
|
||||
*/
|
||||
export type DataReceivableComponentType =
|
||||
| "table"
|
||||
| "form"
|
||||
| "input"
|
||||
| "select"
|
||||
| "repeater"
|
||||
| "form-group"
|
||||
| "hidden";
|
||||
|
||||
/**
|
||||
* 데이터 수신 모드
|
||||
*/
|
||||
export type DataReceiveMode =
|
||||
| "append" // 기존 데이터에 추가
|
||||
| "replace" // 기존 데이터를 완전히 교체
|
||||
| "merge"; // 기존 데이터와 병합 (키 기준)
|
||||
|
||||
/**
|
||||
* 변환 함수 타입
|
||||
*/
|
||||
export type TransformFunction =
|
||||
| "sum" // 합계
|
||||
| "average" // 평균
|
||||
| "concat" // 문자열 결합
|
||||
| "first" // 첫 번째 값
|
||||
| "last" // 마지막 값
|
||||
| "count" // 개수
|
||||
| "custom"; // 커스텀 함수
|
||||
|
||||
/**
|
||||
* 조건 연산자
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| "equals"
|
||||
| "contains"
|
||||
| "greaterThan"
|
||||
| "lessThan"
|
||||
| "notEquals";
|
||||
|
||||
/**
|
||||
* 매핑 규칙
|
||||
* 소스 필드에서 타겟 필드로 데이터를 매핑하는 규칙
|
||||
*/
|
||||
export interface MappingRule {
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
transform?: TransformFunction; // 변환 함수
|
||||
defaultValue?: any; // 기본값
|
||||
required?: boolean; // 필수 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 수신자 설정
|
||||
* 데이터를 받을 타겟 컴포넌트의 설정
|
||||
*/
|
||||
export interface DataReceiverConfig {
|
||||
targetComponentId: string; // 타겟 컴포넌트 ID
|
||||
targetComponentType: DataReceivableComponentType; // 타겟 컴포넌트 타입
|
||||
mode: DataReceiveMode; // 수신 모드
|
||||
mappingRules: MappingRule[]; // 매핑 규칙 배열
|
||||
|
||||
// 조건부 전달
|
||||
condition?: {
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any;
|
||||
};
|
||||
|
||||
// 검증 규칙
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정
|
||||
* 버튼 액션에서 사용하는 데이터 전달 설정
|
||||
*/
|
||||
export interface DataTransferConfig {
|
||||
// 소스 설정
|
||||
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
|
||||
sourceComponentType?: string; // 소스 컴포넌트 타입
|
||||
|
||||
// 타겟 설정
|
||||
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
|
||||
|
||||
// 타겟이 컴포넌트인 경우
|
||||
targetComponentId?: string; // 타겟 컴포넌트 ID
|
||||
targetComponentType?: DataReceivableComponentType; // 타겟 컴포넌트 타입
|
||||
|
||||
// 타겟이 화면인 경우
|
||||
targetScreenId?: number; // 타겟 화면 ID
|
||||
|
||||
// 데이터 수신자 (여러 개 가능)
|
||||
dataReceivers: DataReceiverConfig[];
|
||||
|
||||
// 전달 옵션
|
||||
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
|
||||
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
|
||||
confirmMessage?: string; // 확인 메시지 내용
|
||||
|
||||
// 검증
|
||||
validation?: {
|
||||
requireSelection?: boolean; // 선택 필수
|
||||
minSelection?: number; // 최소 선택 개수
|
||||
maxSelection?: number; // 최대 선택 개수
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 결과
|
||||
*/
|
||||
export interface DataTransferResult {
|
||||
success: boolean;
|
||||
transferredCount: number;
|
||||
errors?: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 수신 가능한 컴포넌트 인터페이스
|
||||
* 데이터를 받을 수 있는 컴포넌트가 구현해야 하는 인터페이스
|
||||
*/
|
||||
export interface DataReceivable {
|
||||
componentId: string;
|
||||
componentType: DataReceivableComponentType;
|
||||
|
||||
/**
|
||||
* 데이터를 수신하는 메서드
|
||||
* @param data 전달받은 데이터 배열
|
||||
* @param config 수신 설정
|
||||
*/
|
||||
receiveData(data: any[], config: DataReceiverConfig): Promise<void>;
|
||||
|
||||
/**
|
||||
* 현재 컴포넌트의 데이터를 가져오는 메서드
|
||||
*/
|
||||
getData(): any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 제공 가능한 컴포넌트 인터페이스
|
||||
* 데이터를 제공할 수 있는 컴포넌트가 구현해야 하는 인터페이스
|
||||
*/
|
||||
export interface DataProvidable {
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
|
||||
/**
|
||||
* 선택된 데이터를 가져오는 메서드
|
||||
*/
|
||||
getSelectedData(): any[];
|
||||
|
||||
/**
|
||||
* 모든 데이터를 가져오는 메서드
|
||||
*/
|
||||
getAllData(): any[];
|
||||
|
||||
/**
|
||||
* 선택 초기화 메서드
|
||||
*/
|
||||
clearSelection(): void;
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +52,13 @@ export interface CreateFlowDefinitionRequest {
|
|||
name: string;
|
||||
description?: string;
|
||||
tableName: string;
|
||||
// 데이터 소스 관련
|
||||
dbSourceType?: "internal" | "external" | "restapi";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFlowDefinitionRequest {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,50 @@
|
|||
* 반복 필드 그룹(Repeater) 타입 정의
|
||||
*/
|
||||
|
||||
export type RepeaterFieldType = "text" | "number" | "email" | "tel" | "date" | "select" | "textarea";
|
||||
/**
|
||||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||
*/
|
||||
export type RepeaterFieldType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "textarea" // 텍스트영역
|
||||
| "date" // 날짜
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오
|
||||
| "category" // 카테고리
|
||||
| "entity" // 엔티티 참조
|
||||
| "code" // 공통코드
|
||||
| "image" // 이미지
|
||||
| "direct" // 직접입력
|
||||
| "calculated" // 계산식 필드
|
||||
| string; // 기타 커스텀 타입 허용
|
||||
|
||||
/**
|
||||
* 계산식 연산자
|
||||
*/
|
||||
export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor" | "ceil" | "abs";
|
||||
|
||||
/**
|
||||
* 계산식 정의
|
||||
* 예: { field1: "order_qty", operator: "*", field2: "unit_price" } → order_qty * unit_price
|
||||
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
||||
*/
|
||||
export interface CalculationFormula {
|
||||
field1: string; // 첫 번째 필드명
|
||||
operator: CalculationOperator; // 연산자
|
||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 표시 모드
|
||||
* - input: 입력 필드로 표시 (편집 가능)
|
||||
* - readonly: 읽기 전용 텍스트로 표시
|
||||
* - (카테고리 타입은 자동으로 배지로 표시됨)
|
||||
*/
|
||||
export type RepeaterFieldDisplayMode = "input" | "readonly";
|
||||
|
||||
/**
|
||||
* 반복 그룹 내 개별 필드 정의
|
||||
|
|
@ -13,8 +56,18 @@ export interface RepeaterFieldDefinition {
|
|||
type: RepeaterFieldType; // 입력 타입
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean; // 읽기 전용 여부
|
||||
options?: Array<{ label: string; value: string }>; // select용
|
||||
width?: string; // 필드 너비 (예: "200px", "50%")
|
||||
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
|
||||
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
|
||||
formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용)
|
||||
numberFormat?: {
|
||||
useThousandSeparator?: boolean; // 천 단위 구분자 사용
|
||||
prefix?: string; // 접두사 (예: "₩")
|
||||
suffix?: string; // 접미사 (예: "원")
|
||||
decimalPlaces?: number; // 소수점 자릿수
|
||||
};
|
||||
validation?: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
|
|
@ -30,6 +83,7 @@ export interface RepeaterFieldDefinition {
|
|||
export interface RepeaterFieldGroupConfig {
|
||||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
addButtonText?: string; // 추가 버튼 텍스트
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 1. 화면 임베딩 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 임베딩 모드
|
||||
*/
|
||||
export type EmbeddingMode =
|
||||
| "view" // 읽기 전용
|
||||
| "select" // 선택 모드 (체크박스)
|
||||
| "form" // 폼 입력 모드
|
||||
| "edit"; // 편집 모드
|
||||
|
||||
/**
|
||||
* 임베딩 위치
|
||||
*/
|
||||
export type EmbeddingPosition =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "center";
|
||||
|
||||
/**
|
||||
* 임베딩 설정
|
||||
*/
|
||||
export interface EmbeddingConfig {
|
||||
width?: string; // "50%", "400px"
|
||||
height?: string; // "100%", "600px"
|
||||
resizable?: boolean;
|
||||
multiSelect?: boolean;
|
||||
showToolbar?: boolean;
|
||||
showSearch?: boolean;
|
||||
showPagination?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩
|
||||
*/
|
||||
export interface ScreenEmbedding {
|
||||
id: number;
|
||||
parentScreenId: number;
|
||||
childScreenId: number;
|
||||
position: EmbeddingPosition;
|
||||
mode: EmbeddingMode;
|
||||
config: EmbeddingConfig;
|
||||
companyCode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. 데이터 전달 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 컴포넌트 타입
|
||||
*/
|
||||
export type ComponentType =
|
||||
| "table" // 테이블
|
||||
| "input" // 입력 필드
|
||||
| "select" // 셀렉트 박스
|
||||
| "textarea" // 텍스트 영역
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오 버튼
|
||||
| "date" // 날짜 선택
|
||||
| "repeater" // 리피터 (반복 그룹)
|
||||
| "form-group" // 폼 그룹
|
||||
| "hidden"; // 히든 필드
|
||||
|
||||
/**
|
||||
* 데이터 수신 모드
|
||||
*/
|
||||
export type DataReceiveMode =
|
||||
| "append" // 기존 데이터에 추가
|
||||
| "replace" // 기존 데이터 덮어쓰기
|
||||
| "merge"; // 기존 데이터와 병합 (키 기준)
|
||||
|
||||
/**
|
||||
* 변환 함수
|
||||
*/
|
||||
export type TransformFunction =
|
||||
| "none" // 변환 없음
|
||||
| "sum" // 합계
|
||||
| "average" // 평균
|
||||
| "count" // 개수
|
||||
| "min" // 최소값
|
||||
| "max" // 최대값
|
||||
| "first" // 첫 번째 값
|
||||
| "last" // 마지막 값
|
||||
| "concat" // 문자열 결합
|
||||
| "join" // 배열 결합
|
||||
| "custom"; // 커스텀 함수
|
||||
|
||||
/**
|
||||
* 조건 연산자
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
| "contains"
|
||||
| "notContains"
|
||||
| "greaterThan"
|
||||
| "lessThan"
|
||||
| "greaterThanOrEqual"
|
||||
| "lessThanOrEqual"
|
||||
| "in"
|
||||
| "notIn";
|
||||
|
||||
/**
|
||||
* 매핑 규칙
|
||||
*/
|
||||
export interface MappingRule {
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
transform?: TransformFunction; // 변환 함수
|
||||
transformConfig?: any; // 변환 함수 설정
|
||||
defaultValue?: any; // 기본값
|
||||
required?: boolean; // 필수 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건
|
||||
*/
|
||||
export interface Condition {
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 설정
|
||||
*/
|
||||
export interface ValidationConfig {
|
||||
required?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
customValidation?: string; // JavaScript 함수 문자열
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 수신자
|
||||
*/
|
||||
export interface DataReceiver {
|
||||
targetComponentId: string; // 타겟 컴포넌트 ID
|
||||
targetComponentType: ComponentType;
|
||||
mode: DataReceiveMode;
|
||||
mappingRules: MappingRule[];
|
||||
condition?: Condition; // 조건부 전달
|
||||
validation?: ValidationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 검증 설정
|
||||
*/
|
||||
export interface ButtonValidation {
|
||||
requireSelection: boolean;
|
||||
minSelection?: number;
|
||||
maxSelection?: number;
|
||||
confirmMessage?: string;
|
||||
customValidation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전달 버튼 설정
|
||||
*/
|
||||
export interface TransferButtonConfig {
|
||||
label: string;
|
||||
position: "left" | "right" | "center";
|
||||
icon?: string;
|
||||
variant?: "default" | "outline" | "ghost" | "destructive";
|
||||
size?: "sm" | "default" | "lg";
|
||||
validation?: ButtonValidation;
|
||||
clearAfterTransfer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정
|
||||
*/
|
||||
export interface ScreenDataTransfer {
|
||||
id: number;
|
||||
sourceScreenId: number;
|
||||
targetScreenId: number;
|
||||
sourceComponentId?: string;
|
||||
sourceComponentType?: string;
|
||||
dataReceivers: DataReceiver[];
|
||||
buttonConfig: TransferButtonConfig;
|
||||
companyCode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. 분할 패널 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 레이아웃 설정
|
||||
*/
|
||||
export interface LayoutConfig {
|
||||
splitRatio: number; // 0-100 (좌측 비율)
|
||||
resizable: boolean;
|
||||
minLeftWidth?: number; // 최소 좌측 너비 (px)
|
||||
minRightWidth?: number; // 최소 우측 너비 (px)
|
||||
orientation: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정
|
||||
*/
|
||||
export interface ScreenSplitPanel {
|
||||
id: number;
|
||||
screenId: number;
|
||||
leftEmbeddingId: number;
|
||||
rightEmbeddingId: number;
|
||||
dataTransferId: number;
|
||||
layoutConfig: LayoutConfig;
|
||||
companyCode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// 조인된 데이터
|
||||
leftEmbedding?: ScreenEmbedding;
|
||||
rightEmbedding?: ScreenEmbedding;
|
||||
dataTransfer?: ScreenDataTransfer;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. 컴포넌트 인터페이스
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 데이터 수신 가능 컴포넌트 인터페이스
|
||||
*/
|
||||
export interface DataReceivable {
|
||||
// 컴포넌트 ID
|
||||
componentId: string;
|
||||
|
||||
// 컴포넌트 타입
|
||||
componentType: ComponentType;
|
||||
|
||||
// 데이터 수신
|
||||
receiveData(data: any[], mode: DataReceiveMode): Promise<void>;
|
||||
|
||||
// 현재 데이터 가져오기
|
||||
getData(): any;
|
||||
|
||||
// 데이터 초기화
|
||||
clearData(): void;
|
||||
|
||||
// 검증
|
||||
validate(): boolean;
|
||||
|
||||
// 이벤트 리스너
|
||||
onDataReceived?: (data: any[]) => void;
|
||||
onDataCleared?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 가능 컴포넌트 인터페이스
|
||||
*/
|
||||
export interface Selectable {
|
||||
// 선택된 행/항목 가져오기
|
||||
getSelectedRows(): any[];
|
||||
|
||||
// 선택 초기화
|
||||
clearSelection(): void;
|
||||
|
||||
// 전체 선택
|
||||
selectAll(): void;
|
||||
|
||||
// 선택 이벤트
|
||||
onSelectionChanged?: (selectedRows: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드된 화면 핸들
|
||||
*/
|
||||
export interface EmbeddedScreenHandle {
|
||||
// 선택된 행 가져오기
|
||||
getSelectedRows(): any[];
|
||||
|
||||
// 선택 초기화
|
||||
clearSelection(): void;
|
||||
|
||||
// 데이터 수신
|
||||
receiveData(data: any[], receivers: DataReceiver[]): Promise<void>;
|
||||
|
||||
// 현재 데이터 가져오기
|
||||
getData(): any;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. API 응답 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 응답
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 생성 요청
|
||||
*/
|
||||
export interface CreateScreenEmbeddingRequest {
|
||||
parentScreenId: number;
|
||||
childScreenId: number;
|
||||
position: EmbeddingPosition;
|
||||
mode: EmbeddingMode;
|
||||
config?: EmbeddingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 생성 요청
|
||||
*/
|
||||
export interface CreateScreenDataTransferRequest {
|
||||
sourceScreenId: number;
|
||||
targetScreenId: number;
|
||||
sourceComponentId?: string;
|
||||
sourceComponentType?: string;
|
||||
dataReceivers: DataReceiver[];
|
||||
buttonConfig: TransferButtonConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 생성 요청
|
||||
*/
|
||||
export interface CreateScreenSplitPanelRequest {
|
||||
screenId: number;
|
||||
leftEmbedding: CreateScreenEmbeddingRequest;
|
||||
rightEmbedding: CreateScreenEmbeddingRequest;
|
||||
dataTransfer: CreateScreenDataTransferRequest;
|
||||
layoutConfig: LayoutConfig;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 6. 유틸리티 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 데이터 전달 결과
|
||||
*/
|
||||
export interface DataTransferResult {
|
||||
success: boolean;
|
||||
transferredCount: number;
|
||||
errors?: Array<{
|
||||
componentId: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 결과
|
||||
*/
|
||||
export interface MappingResult {
|
||||
success: boolean;
|
||||
mappedData: any[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 결과
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue