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

This commit is contained in:
kjs 2025-12-02 18:07:24 +09:00
commit 7713d4073c
42 changed files with 8829 additions and 653 deletions

View File

@ -72,6 +72,7 @@ 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"; // 임시 주석
@ -238,6 +239,7 @@ 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);

View File

@ -708,6 +708,12 @@ export class DashboardController {
});
}
// 기상청 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) {
@ -719,8 +725,24 @@ export class DashboardController {
let data = response.data;
const contentType = response.headers["content-type"];
// 기상청 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');
// 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' };
}
}
// 텍스트 응답인 경우 포맷팅
if (typeof data === "string") {
else if (typeof data === "string") {
data = { text: data, contentType };
}

View File

@ -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,12 @@ export class FlowController {
return;
}
// 테이블 이름이 제공된 경우에만 존재 확인
if (tableName) {
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
const tableExists =
await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) {
@ -68,7 +84,17 @@ export class FlowController {
}
const flowDef = await this.flowDefinitionService.create(
{ name, description, tableName, dbSourceType, dbConnectionId },
{
name,
description,
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
},
userId,
userCompanyCode
);

View File

@ -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: "화면 정보가 수정되었습니다." });

View File

@ -870,6 +870,17 @@ export async function addTableData(
const tableManagementService = new TableManagementService();
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);

View File

@ -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 || "구간별 통계 조회에 실패했습니다.",
});
}
};

View File

@ -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 || "운행 취소에 실패했습니다.",
});
}
};

View File

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

View File

@ -1091,4 +1091,150 @@ export class ExternalRestApiConnectionService {
throw new Error("올바르지 않은 인증 타입입니다.");
}
}
/**
* REST API
* REST API의
*/
static async fetchMultipleData(
configs: Array<{
connectionId: number;
endpoint: string;
jsonPath: string;
alias: string;
}>,
userCompanyCode?: string
): Promise<ApiResponse<{
rows: any[];
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
total: number;
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
}>> {
try {
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
// 각 API에서 데이터 조회
const results = await Promise.all(
configs.map(async (config) => {
try {
const result = await this.fetchData(
config.connectionId,
config.endpoint,
config.jsonPath,
userCompanyCode
);
if (result.success && result.data) {
return {
success: true,
connectionId: config.connectionId,
connectionName: result.data.connectionInfo.connectionName,
alias: config.alias,
rows: result.data.rows,
columns: result.data.columns,
};
} else {
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: result.message,
};
}
} catch (error) {
logger.error(`API ${config.connectionId} 조회 오류:`, error);
return {
success: false,
connectionId: config.connectionId,
connectionName: "",
alias: config.alias,
rows: [],
columns: [],
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
})
);
// 성공한 결과만 필터링
const successfulResults = results.filter(r => r.success);
if (successfulResults.length === 0) {
return {
success: false,
message: "모든 REST API 조회에 실패했습니다.",
error: {
code: "ALL_APIS_FAILED",
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
},
};
}
// 컬럼 병합 (별칭 적용)
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
for (const result of successfulResults) {
for (const col of result.columns) {
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
mergedColumns.push({
columnName: prefixedColumnName,
columnLabel: `${col.columnLabel} (${result.connectionName})`,
dataType: col.dataType,
sourceApi: result.connectionName,
});
}
}
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
const mergedRows: any[] = [];
for (let i = 0; i < maxRows; i++) {
const mergedRow: any = {};
for (const result of successfulResults) {
const row = result.rows[i] || {};
for (const [key, value] of Object.entries(row)) {
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
mergedRow[prefixedKey] = value;
}
}
mergedRows.push(mergedRow);
}
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
return {
success: true,
data: {
rows: mergedRows,
columns: mergedColumns,
total: mergedRows.length,
sources: successfulResults.map(r => ({
connectionId: r.connectionId,
connectionName: r.connectionName,
rowCount: r.rows.length,
})),
},
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("다중 REST API 데이터 조회 오류:", error);
return {
success: false,
message: "다중 REST API 데이터 조회에 실패했습니다.",
error: {
code: "MULTI_FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
}

View File

@ -27,13 +27,21 @@ export class FlowDefinitionService {
tableName: request.tableName,
dbSourceType: request.dbSourceType,
dbConnectionId: request.dbConnectionId,
restApiConnectionId: request.restApiConnectionId,
restApiEndpoint: request.restApiEndpoint,
restApiJsonPath: request.restApiJsonPath,
restApiConnections: request.restApiConnections,
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,
rest_api_connections, company_code, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`;
@ -43,6 +51,10 @@ export class FlowDefinitionService {
request.tableName || null,
request.dbSourceType || "internal",
request.dbConnectionId || null,
request.restApiConnectionId || null,
request.restApiEndpoint || null,
request.restApiJsonPath || "response",
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
companyCode,
userId,
];
@ -199,6 +211,19 @@ export class FlowDefinitionService {
* DB FlowDefinition
*/
private mapToFlowDefinition(row: any): FlowDefinition {
// rest_api_connections 파싱 (JSONB → 배열)
let restApiConnections = undefined;
if (row.rest_api_connections) {
try {
restApiConnections = typeof row.rest_api_connections === 'string'
? JSON.parse(row.rest_api_connections)
: row.rest_api_connections;
} catch (e) {
console.warn("Failed to parse rest_api_connections:", e);
restApiConnections = [];
}
}
return {
id: row.id,
name: row.name,
@ -206,6 +231,12 @@ 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,
// 다중 REST API 관련 필드
restApiConnections: restApiConnections,
companyCode: row.company_code || "*",
isActive: row.is_active,
createdBy: row.created_by,

View File

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

View File

@ -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은 트랜잭션 종료 시 자동으로 해제됨

View File

@ -1516,6 +1516,26 @@ export class TableManagementService {
columnName
);
// 🆕 배열 처리: IN 절 사용
if (Array.isArray(value)) {
if (value.length === 0) {
// 빈 배열이면 항상 false 조건
return {
whereClause: `1 = 0`,
values: [],
paramCount: 0,
};
}
// IN 절로 여러 값 검색
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
return {
whereClause: `${columnName} IN (${placeholders})`,
values: value,
paramCount: value.length,
};
}
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
// 엔티티 타입이 아니면 기본 검색
return {
@ -4070,4 +4090,22 @@ export class TableManagementService {
throw error;
}
}
/**
*
*/
async hasColumn(tableName: string, columnName: string): Promise<boolean> {
try {
const result = await query<any>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
return result.length > 0;
} catch (error) {
logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error);
return false;
}
}
}

View File

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

View File

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

View File

@ -2,14 +2,38 @@
*
*/
// 다중 REST API 연결 설정
export interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string; // 컬럼 접두어 (예: "api1_")
}
// 다중 외부 DB 연결 설정
export interface ExternalDbConnectionConfig {
connectionId: number;
connectionName: string;
dbType: string;
tableName: string;
alias: string; // 컬럼 접두어 (예: "db1_")
}
// 플로우 정의
export interface FlowDefinition {
id: number;
name: string;
description?: string;
tableName: string;
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
// REST API 관련 필드 (단일)
restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우)
restApiEndpoint?: string; // REST API 엔드포인트
restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data)
// 다중 REST API 관련 필드
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
companyCode: string; // 회사 코드 (* = 공통)
isActive: boolean;
createdBy?: string;
@ -22,8 +46,14 @@ export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID
// REST API 관련 필드 (단일)
restApiConnectionId?: number; // REST API 연결 ID
restApiEndpoint?: string; // REST API 엔드포인트
restApiJsonPath?: string; // JSON 응답에서 데이터 경로
// 다중 REST API 관련 필드
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
}

View File

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

View File

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

View File

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

View File

@ -319,6 +319,10 @@ export default function FlowEditorPage() {
flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달
flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달
flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달
flowRestApiConnectionId={flowDefinition?.restApiConnectionId} // REST API 연결 ID 전달
flowRestApiEndpoint={flowDefinition?.restApiEndpoint} // REST API 엔드포인트 전달
flowRestApiJsonPath={flowDefinition?.restApiJsonPath} // REST API JSON 경로 전달
flowRestApiConnections={flowDefinition?.restApiConnections} // 다중 REST API 설정 전달
onClose={() => setSelectedStep(null)}
onUpdate={loadFlowData}
/>

View File

@ -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,42 @@ 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("response");
// 다중 REST API 선택 상태
interface RestApiConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string; // 컬럼 접두어 (예: "api1_")
}
const [selectedRestApis, setSelectedRestApis] = useState<RestApiConfig[]>([]);
const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드
// 다중 외부 DB 선택 상태
interface ExternalDbConfig {
connectionId: number;
connectionName: string;
dbType: string;
tableName: string;
alias: string; // 컬럼 접두어 (예: "db1_")
}
const [selectedExternalDbs, setSelectedExternalDbs] = useState<ExternalDbConfig[]>([]);
const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드
const [multiDbTableLists, setMultiDbTableLists] = useState<Record<number, string[]>>({}); // 각 DB별 테이블 목록
// 생성 폼 상태
const [formData, setFormData] = useState({
name: "",
@ -135,75 +165,288 @@ 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]);
// 다중 외부 DB 추가
const addExternalDbConfig = async (connectionId: number) => {
const connection = externalConnections.find(c => c.id === connectionId);
if (!connection) return;
// 이미 추가된 경우 스킵
if (selectedExternalDbs.some(db => db.connectionId === connectionId)) {
toast({
title: "이미 추가됨",
description: "해당 외부 DB가 이미 추가되어 있습니다.",
variant: "destructive",
});
return;
}
// 해당 DB의 테이블 목록 로드
try {
const data = await ExternalDbConnectionAPI.getTables(connectionId);
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);
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
}
const newConfig: ExternalDbConfig = {
connectionId,
connectionName: connection.connection_name,
dbType: connection.db_type,
tableName: "",
alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성
};
setSelectedExternalDbs([...selectedExternalDbs, newConfig]);
};
// 다중 외부 DB 삭제
const removeExternalDbConfig = (connectionId: number) => {
setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId));
};
// 다중 외부 DB 설정 업데이트
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
setSelectedExternalDbs(selectedExternalDbs.map(db =>
db.connectionId === connectionId ? { ...db, [field]: value } : db
));
};
// 다중 REST API 추가
const addRestApiConfig = (connectionId: number) => {
const connection = restApiConnections.find(c => c.id === connectionId);
if (!connection) return;
// 이미 추가된 경우 스킵
if (selectedRestApis.some(api => api.connectionId === connectionId)) {
toast({
title: "이미 추가됨",
description: "해당 REST API가 이미 추가되어 있습니다.",
variant: "destructive",
});
return;
}
// 연결 테이블의 기본값 사용
const newConfig: RestApiConfig = {
connectionId,
connectionName: connection.connection_name,
endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트
jsonPath: "response", // 기본값
alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성
};
setSelectedRestApis([...selectedRestApis, newConfig]);
};
// 다중 REST API 삭제
const removeRestApiConfig = (connectionId: number) => {
setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId));
};
// 다중 REST API 설정 업데이트
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
setSelectedRestApis(selectedRestApis.map(api =>
api.connectionId === connectionId ? { ...api, [field]: value } : api
));
};
// 플로우 생성
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_") || isMultiRestApi;
const isMultiMode = isMultiRestApi || isMultiExternalDb;
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode });
toast({
title: "입력 오류",
description: "플로우 이름과 테이블 이름은 필수입니다.",
description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive",
});
return;
}
// 다중 REST API 모드인 경우 검증
if (isMultiRestApi) {
if (selectedRestApis.length === 0) {
toast({
title: "입력 오류",
description: "최소 하나의 REST API를 추가해주세요.",
variant: "destructive",
});
return;
}
// 각 API의 엔드포인트 검증
const missingEndpoint = selectedRestApis.find(api => !api.endpoint);
if (missingEndpoint) {
toast({
title: "입력 오류",
description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`,
variant: "destructive",
});
return;
}
} else if (isMultiExternalDb) {
// 다중 외부 DB 모드인 경우 검증
if (selectedExternalDbs.length === 0) {
toast({
title: "입력 오류",
description: "최소 하나의 외부 DB를 추가해주세요.",
variant: "destructive",
});
return;
}
// 각 DB의 테이블 선택 검증
const missingTable = selectedExternalDbs.find(db => !db.tableName);
if (missingTable) {
toast({
title: "입력 오류",
description: `${missingTable.connectionName}의 테이블을 선택해주세요.`,
variant: "destructive",
});
return;
}
} else if (isRestApi && !restApiEndpoint) {
// 단일 REST API인 경우 엔드포인트 검증
toast({
title: "입력 오류",
description: "REST API 엔드포인트는 필수입니다.",
variant: "destructive",
});
return;
}
try {
// DB 소스 정보 추가
const requestData = {
// 데이터 소스 타입 및 ID 파싱
let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal";
let dbConnectionId: number | undefined = undefined;
let restApiConnectionId: number | undefined = undefined;
if (isMultiRestApi) {
dbSourceType = "multi_restapi";
} else if (isMultiExternalDb) {
dbSourceType = "multi_external_db";
} else 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 === "multi_restapi") {
requestData.restApiConnections = selectedRestApis;
// 다중 REST API는 첫 번째 API의 ID를 기본으로 사용
requestData.restApiConnectionId = selectedRestApis[0]?.connectionId;
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
// 가상 테이블명: 모든 연결 ID를 조합
requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`;
} else if (dbSourceType === "multi_external_db") {
// 다중 외부 DB인 경우
requestData.externalDbConnections = selectedExternalDbs;
// 첫 번째 DB의 ID를 기본으로 사용
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
// 가상 테이블명: 모든 연결 ID와 테이블명 조합
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`;
} else if (dbSourceType === "restapi") {
// 단일 REST API인 경우
requestData.restApiConnectionId = restApiConnectionId;
requestData.restApiEndpoint = restApiEndpoint;
requestData.restApiJsonPath = restApiJsonPath || "response";
// 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 +455,12 @@ export default function FlowManagementPage() {
setIsCreateDialogOpen(false);
setFormData({ name: "", description: "", tableName: "" });
setSelectedDbSource("internal");
setRestApiEndpoint("");
setRestApiJsonPath("response");
setSelectedRestApis([]);
setSelectedExternalDbs([]);
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
loadFlows();
} else {
toast({
@ -415,125 +664,373 @@ 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={isMultiRestApi ? "multi_restapi" : isMultiExternalDb ? "multi_external_db" : selectedDbSource}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
if (value === "multi_restapi") {
setIsMultiRestApi(true);
setIsMultiExternalDb(false);
setSelectedDbSource("internal");
} else if (value === "multi_external_db") {
setIsMultiExternalDb(true);
setIsMultiRestApi(false);
setSelectedDbSource("internal");
} else {
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
setSelectedDbSource(value);
}
// 소스 변경 시 초기화
setFormData({ ...formData, tableName: "" });
setRestApiEndpoint("");
setRestApiJsonPath("response");
setSelectedRestApis([]);
setSelectedExternalDbs([]);
}}
>
<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-muted-foreground text-xs">
-- --
</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-muted-foreground text-xs">
-- REST API --
</SelectItem>
{restApiConnections.map((conn) => (
<SelectItem key={`api_${conn.id}`} value={`restapi_${conn.id}`}>
{conn.connection_name} (REST API)
</SelectItem>
))}
</>
)}
{/* 다중 연결 옵션 */}
{(externalConnections.length > 0 || restApiConnections.length > 0) && (
<>
<SelectItem value="__divider_multi__" disabled className="text-muted-foreground text-xs">
-- ( ) --
</SelectItem>
{externalConnections.length > 0 && (
<SelectItem value="multi_external_db">
DB ( )
</SelectItem>
)}
{restApiConnections.length > 0 && (
<SelectItem value="multi_restapi">
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)}
{/* 다중 REST API 선택 UI */}
{isMultiRestApi && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm">REST API </Label>
<Select
value=""
onValueChange={(value) => {
if (value) {
addRestApiConfig(parseInt(value));
}
}}
>
{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>
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="API 추가..." />
</SelectTrigger>
<SelectContent>
{restApiConnections
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id))
.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRestApis.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm">
REST API를
</p>
</div>
) : (
<div className="space-y-2">
{selectedRestApis.map((api) => (
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{api.connectionName}</span>
<span className="text-muted-foreground text-xs">
({api.endpoint || "기본 엔드포인트"})
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeRestApiConfig(api.connectionId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px] sm:text-xs">
REST API들의 .
</p>
</div>
)}
{/* 다중 외부 DB 선택 UI */}
{isMultiExternalDb && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> DB </Label>
<Select
value=""
onValueChange={(value) => {
if (value) {
addExternalDbConfig(parseInt(value));
}
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="DB 추가..." />
</SelectTrigger>
<SelectContent>
{externalConnections
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id))
.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedExternalDbs.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm">
DB를
</p>
</div>
) : (
<div className="space-y-3">
{selectedExternalDbs.map((db) => (
<div key={db.connectionId} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{db.connectionName} ({db.dbType?.toUpperCase()})
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeExternalDbConfig(db.connectionId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Select
value={db.tableName}
onValueChange={(value) => updateExternalDbConfig(db.connectionId, "tableName", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{(multiDbTableLists[db.connectionId] || []).map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Input
value={db.alias}
onChange={(e) => updateExternalDbConfig(db.connectionId, "alias", e.target.value)}
placeholder="db1_"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px] sm:text-xs">
DB들의 . DB별 .
</p>
</div>
)}
{/* 단일 REST API인 경우 엔드포인트 설정 */}
{!isMultiRestApi && 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 - 다중 선택 모드가 아닌 경우만) */}
{!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
<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">

View File

@ -62,6 +62,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false);
@ -131,12 +134,37 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, description, size, urlParams, editData, splitPanelParentData } = event.detail;
const {
screenId,
title,
description,
size,
urlParams,
editData,
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
} = event.detail;
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
screenId,
title,
selectedData: eventSelectedData,
selectedIds,
});
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
if (eventSelectedData && Array.isArray(eventSelectedData)) {
setSelectedData(eventSelectedData);
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
} else {
setSelectedData([]);
}
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
@ -157,10 +185,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
const parentData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: (splitPanelContext?.getMappedParentData() || {});
const parentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: splitPanelContext?.getMappedParentData() || {};
if (Object.keys(parentData).length > 0) {
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
setFormData(parentData);
@ -199,6 +228,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setScreenData(null);
setFormData({});
setOriginalData(null); // 🆕 원본 데이터 초기화
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
console.log("🔄 연속 모드 초기화: false");
@ -664,6 +694,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
groupedData={selectedData}
userId={userId}
userName={userName}
companyCode={user?.companyCode}

View File

@ -34,7 +34,7 @@ interface Vehicle {
driver: string;
lat: number;
lng: number;
status: "active" | "inactive" | "maintenance" | "warning";
status: "active" | "inactive" | "maintenance" | "warning" | "off";
speed: number;
destination: string;
}
@ -88,24 +88,45 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
const statusCol = element.chartConfig.statusColumn || "status";
// DB 데이터를 Vehicle 형식으로 변환
const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({
id: row.id || row.vehicle_number || `V${index + 1}`,
name: row[labelCol] || `차량 ${index + 1}`,
driver: row.driver_name || row.driver || "미배정",
lat: parseFloat(row[latCol]),
lng: parseFloat(row[lngCol]),
status:
row[statusCol] === "warning"
? "warning"
: row[statusCol] === "active"
? "active"
: row[statusCol] === "maintenance"
? "maintenance"
: "inactive",
speed: parseFloat(row.speed) || 0,
destination: row.destination || "대기 중",
}));
console.log("🗺️ [VehicleMapOnlyWidget] 원본 데이터:", result.data.rows);
console.log("🗺️ [VehicleMapOnlyWidget] 컬럼 매핑:", { latCol, lngCol, labelCol, statusCol });
const vehiclesFromDB: Vehicle[] = result.data.rows
.map((row: any, index: number) => {
const lat = parseFloat(row[latCol]);
const lng = parseFloat(row[lngCol]);
console.log(`🗺️ [VehicleMapOnlyWidget] 차량 ${index + 1}:`, {
id: row.id || row.vehicle_number,
latRaw: row[latCol],
lngRaw: row[lngCol],
latParsed: lat,
lngParsed: lng,
status: row[statusCol],
});
return {
id: row.id || row.vehicle_number || `V${index + 1}`,
name: row[labelCol] || `차량 ${index + 1}`,
driver: row.driver_name || row.driver || "미배정",
lat,
lng,
status:
row[statusCol] === "warning"
? "warning"
: row[statusCol] === "active"
? "active"
: row[statusCol] === "maintenance"
? "maintenance"
: "inactive",
speed: parseFloat(row.speed) || 0,
destination: row.destination || "대기 중",
};
})
// 유효한 위도/경도가 있는 차량만 필터링
.filter((v: Vehicle) => !isNaN(v.lat) && !isNaN(v.lng) && v.lat !== 0 && v.lng !== 0);
console.log("🗺️ [VehicleMapOnlyWidget] 유효한 차량 수:", vehiclesFromDB.length);
setVehicles(vehiclesFromDB);
setLastUpdate(new Date());
setIsLoading(false);

View File

@ -14,13 +14,27 @@ import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { FlowConditionGroup, FlowCondition, ConditionOperator } from "@/types/flow";
import { getTableColumns } from "@/lib/api/tableManagement";
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
import { cn } from "@/lib/utils";
// 다중 REST API 연결 설정
interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string;
}
interface FlowConditionBuilderProps {
flowId: number;
tableName?: string; // 조회할 테이블명
dbSourceType?: "internal" | "external"; // DB 소스 타입
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // DB 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID
restApiConnectionId?: number; // REST API 연결 ID (단일)
restApiEndpoint?: string; // REST API 엔드포인트 (단일)
restApiJsonPath?: string; // REST API JSON 경로 (단일)
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
condition?: FlowConditionGroup;
onChange: (condition: FlowConditionGroup | undefined) => void;
}
@ -45,6 +59,10 @@ export function FlowConditionBuilder({
tableName,
dbSourceType = "internal",
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections,
condition,
onChange,
}: FlowConditionBuilderProps) {
@ -65,9 +83,10 @@ export function FlowConditionBuilder({
}
}, [condition]);
// 테이블 컬럼 로드 - 내부/외부 DB 모두 지원
// 테이블 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
useEffect(() => {
if (!tableName) {
// REST API인 경우 tableName이 없어도 진행 가능
if (!tableName && dbSourceType !== "restapi" && dbSourceType !== "multi_restapi") {
setColumns([]);
return;
}
@ -79,8 +98,106 @@ export function FlowConditionBuilder({
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections,
});
// 다중 REST API인 경우
if (dbSourceType === "multi_restapi" && restApiConnections && restApiConnections.length > 0) {
try {
console.log("🌐 [FlowConditionBuilder] 다중 REST API 컬럼 로드 시작:", restApiConnections);
// 각 API에서 컬럼 정보 수집
const allColumns: any[] = [];
for (const config of restApiConnections) {
try {
const effectiveJsonPath = (!config.jsonPath || config.jsonPath === "data") ? "response" : config.jsonPath;
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
config.connectionId,
config.endpoint,
effectiveJsonPath,
);
if (restApiData.columns && restApiData.columns.length > 0) {
// 별칭 적용
const prefixedColumns = restApiData.columns.map((col) => ({
column_name: config.alias ? `${config.alias}${col.columnName}` : col.columnName,
data_type: col.dataType || "varchar",
displayName: `${col.columnLabel || col.columnName} (${config.connectionName})`,
sourceApi: config.connectionName,
}));
allColumns.push(...prefixedColumns);
}
} catch (apiError) {
console.warn(`API ${config.connectionId} 컬럼 로드 실패:`, apiError);
}
}
console.log("✅ [FlowConditionBuilder] 다중 REST API 컬럼 로드 완료:", allColumns.length, "items");
setColumns(allColumns);
} catch (multiApiError) {
console.error("❌ 다중 REST API 컬럼 로드 실패:", multiApiError);
setColumns([]);
}
return;
}
// 단일 REST API인 경우 (dbSourceType이 restapi이거나 tableName이 _restapi_로 시작)
const isRestApi = dbSourceType === "restapi" || tableName?.startsWith("_restapi_");
// tableName에서 REST API 연결 ID 추출 (restApiConnectionId가 없는 경우)
let effectiveRestApiConnectionId = restApiConnectionId;
if (isRestApi && !effectiveRestApiConnectionId && tableName) {
const match = tableName.match(/_restapi_(\d+)/);
if (match) {
effectiveRestApiConnectionId = parseInt(match[1]);
console.log("🔍 tableName에서 REST API 연결 ID 추출:", effectiveRestApiConnectionId);
}
}
if (isRestApi && effectiveRestApiConnectionId) {
try {
// jsonPath가 "data"이거나 없으면 "response"로 변경 (thiratis API 응답 구조에 맞춤)
const effectiveJsonPath = (!restApiJsonPath || restApiJsonPath === "data") ? "response" : restApiJsonPath;
console.log("🌐 [FlowConditionBuilder] REST API 컬럼 로드 시작:", {
connectionId: effectiveRestApiConnectionId,
endpoint: restApiEndpoint,
jsonPath: restApiJsonPath,
effectiveJsonPath,
});
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
effectiveRestApiConnectionId,
restApiEndpoint,
effectiveJsonPath,
);
console.log("✅ [FlowConditionBuilder] REST API columns response:", restApiData);
if (restApiData.columns && restApiData.columns.length > 0) {
const columnList = restApiData.columns.map((col) => ({
column_name: col.columnName,
data_type: col.dataType || "varchar",
displayName: col.columnLabel || col.columnName,
}));
console.log("✅ Setting REST API columns:", columnList.length, "items", columnList);
setColumns(columnList);
} else {
console.warn("❌ No columns in REST API response");
setColumns([]);
}
} catch (restApiError) {
console.error("❌ REST API 컬럼 로드 실패:", restApiError);
setColumns([]);
}
return;
}
// 외부 DB인 경우
if (dbSourceType === "external" && dbConnectionId) {
const token = localStorage.getItem("authToken");
@ -148,7 +265,7 @@ export function FlowConditionBuilder({
};
loadColumns();
}, [tableName, dbSourceType, dbConnectionId]);
}, [tableName, dbSourceType, dbConnectionId, restApiConnectionId, restApiEndpoint, restApiJsonPath]);
// 조건 변경 시 부모에 전달
useEffect(() => {

View File

@ -30,12 +30,25 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
// 다중 REST API 연결 설정
interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string;
}
interface FlowStepPanelProps {
step: FlowStep;
flowId: number;
flowTableName?: string; // 플로우 정의에서 선택한 테이블명
flowDbSourceType?: "internal" | "external"; // 플로우의 DB 소스 타입
flowDbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // 플로우의 DB 소스 타입
flowDbConnectionId?: number; // 플로우의 외부 DB 연결 ID
flowRestApiConnectionId?: number; // 플로우의 REST API 연결 ID (단일)
flowRestApiEndpoint?: string; // REST API 엔드포인트 (단일)
flowRestApiJsonPath?: string; // REST API JSON 경로 (단일)
flowRestApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
onClose: () => void;
onUpdate: () => void;
}
@ -46,6 +59,10 @@ export function FlowStepPanel({
flowTableName,
flowDbSourceType = "internal",
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiEndpoint,
flowRestApiJsonPath,
flowRestApiConnections,
onClose,
onUpdate,
}: FlowStepPanelProps) {
@ -56,6 +73,9 @@ export function FlowStepPanel({
flowTableName,
flowDbSourceType,
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiEndpoint,
flowRestApiJsonPath,
final: step.tableName || flowTableName || "",
});
@ -315,10 +335,11 @@ export function FlowStepPanel({
setFormData(newFormData);
}, [step.id, flowTableName]); // flowTableName도 의존성 추가
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 모두 지원
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
useEffect(() => {
const loadColumns = async () => {
if (!formData.tableName) {
// 다중 REST API인 경우 tableName 없이도 컬럼 로드 가능
if (!formData.tableName && flowDbSourceType !== "multi_restapi") {
setColumns([]);
return;
}
@ -329,8 +350,74 @@ export function FlowStepPanel({
tableName: formData.tableName,
flowDbSourceType,
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiConnections,
});
// 다중 REST API인 경우
if (flowDbSourceType === "multi_restapi" && flowRestApiConnections && flowRestApiConnections.length > 0) {
console.log("🌐 다중 REST API 컬럼 로드 시작");
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const allColumns: any[] = [];
for (const config of flowRestApiConnections) {
try {
const effectiveJsonPath = (!config.jsonPath || config.jsonPath === "data") ? "response" : config.jsonPath;
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
config.connectionId,
config.endpoint,
effectiveJsonPath,
);
if (restApiData.columns && restApiData.columns.length > 0) {
const prefixedColumns = restApiData.columns.map((col) => ({
column_name: config.alias ? `${config.alias}${col.columnName}` : col.columnName,
data_type: col.dataType || "varchar",
displayName: `${col.columnLabel || col.columnName} (${config.connectionName})`,
}));
allColumns.push(...prefixedColumns);
}
} catch (apiError) {
console.warn(`API ${config.connectionId} 컬럼 로드 실패:`, apiError);
}
}
console.log("✅ 다중 REST API 컬럼 로드 완료:", allColumns.length, "items");
setColumns(allColumns);
return;
}
// 단일 REST API인 경우
const isRestApi = flowDbSourceType === "restapi" || formData.tableName?.startsWith("_restapi_");
if (isRestApi && flowRestApiConnectionId) {
console.log("🌐 단일 REST API 컬럼 로드 시작");
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const effectiveJsonPath = (!flowRestApiJsonPath || flowRestApiJsonPath === "data") ? "response" : flowRestApiJsonPath;
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
flowRestApiConnectionId,
flowRestApiEndpoint,
effectiveJsonPath,
);
if (restApiData.columns && restApiData.columns.length > 0) {
const columnList = restApiData.columns.map((col) => ({
column_name: col.columnName,
data_type: col.dataType || "varchar",
displayName: col.columnLabel || col.columnName,
}));
console.log("✅ REST API 컬럼 로드 완료:", columnList.length, "items");
setColumns(columnList);
} else {
setColumns([]);
}
return;
}
// 외부 DB인 경우
if (flowDbSourceType === "external" && flowDbConnectionId) {
const token = localStorage.getItem("authToken");
@ -399,7 +486,7 @@ export function FlowStepPanel({
};
loadColumns();
}, [formData.tableName, flowDbSourceType, flowDbConnectionId]);
}, [formData.tableName, flowDbSourceType, flowDbConnectionId, flowRestApiConnectionId, flowRestApiEndpoint, flowRestApiJsonPath, flowRestApiConnections]);
// formData의 최신 값을 항상 참조하기 위한 ref
const formDataRef = useRef(formData);
@ -661,6 +748,10 @@ export function FlowStepPanel({
tableName={formData.tableName}
dbSourceType={flowDbSourceType}
dbConnectionId={flowDbConnectionId}
restApiConnectionId={flowRestApiConnectionId}
restApiEndpoint={flowRestApiEndpoint}
restApiJsonPath={flowRestApiJsonPath}
restApiConnections={flowRestApiConnections}
condition={formData.conditionJson}
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
/>
@ -852,7 +943,7 @@ export function FlowStepPanel({
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "rest_api"}
>
{opt.label}
</SelectItem>
@ -1044,6 +1135,132 @@ export function FlowStepPanel({
)}
</div>
)}
{/* REST API 연동 설정 */}
{formData.integrationType === "rest_api" && (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>REST API </Label>
<Select
value={formData.integrationConfig?.connectionId?.toString() || ""}
onValueChange={(value) => {
const connectionId = parseInt(value);
setFormData({
...formData,
integrationConfig: {
type: "rest_api",
connectionId,
operation: "update",
endpoint: "",
method: "POST",
bodyTemplate: "{}",
} as any,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="REST API 연결 선택" />
</SelectTrigger>
<SelectContent>
{flowRestApiConnections && flowRestApiConnections.length > 0 ? (
flowRestApiConnections.map((api) => (
<SelectItem key={api.connectionId} value={api.connectionId.toString()}>
{api.connectionName}
</SelectItem>
))
) : flowRestApiConnectionId ? (
<SelectItem value={flowRestApiConnectionId.toString()}>
REST API
</SelectItem>
) : (
<SelectItem value="" disabled>
REST API가
</SelectItem>
)}
</SelectContent>
</Select>
</div>
{formData.integrationConfig?.connectionId && (
<>
<div>
<Label>HTTP </Label>
<Select
value={(formData.integrationConfig as any).method || "POST"}
onValueChange={(value) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
method: value,
} as any,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={(formData.integrationConfig as any).endpoint || ""}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
endpoint: e.target.value,
} as any,
})
}
placeholder="/api/update"
/>
<p className="text-muted-foreground mt-1 text-xs">
API
</p>
</div>
<div>
<Label> (JSON)</Label>
<Textarea
value={(formData.integrationConfig as any).bodyTemplate || "{}"}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
bodyTemplate: e.target.value,
} as any,
})
}
placeholder='{"id": "{{dataId}}", "status": "approved"}'
rows={4}
className="font-mono text-sm"
/>
</div>
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 릿 :
<br /> {`{{dataId}}`} - ID
<br /> {`{{currentUser}}`} -
<br /> {`{{currentTimestamp}}`} -
</p>
</div>
</>
)}
</div>
)}
</CardContent>
</Card>

View File

@ -838,18 +838,53 @@ 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,
// 전체 selectedScreen 객체도 출력
fullScreen: selectedScreen,
});
// REST API 데이터 소스인 경우
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
// 1. dataSourceType이 "restapi"인 경우
// 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우
// 3. restApiConnectionId가 있는 경우
const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
selectedScreen?.tableName?.startsWith("restapi_") ||
selectedScreen?.tableName?.startsWith("_restapi_") ||
!!selectedScreen?.restApiConnectionId;
console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi });
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,
@ -861,10 +896,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 데이터 소스 로드 완료:", {
@ -4256,8 +4298,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 통합 패널 */}
{panelStates.unified?.isOpen && (
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm">
<div className="border-border flex items-center justify-between border-b px-4 py-3">
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm overflow-hidden">
<div className="border-border flex items-center justify-between border-b px-4 py-3 shrink-0">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
onClick={() => closePanel("unified")}
@ -4266,7 +4308,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-2 gap-1">
<TabsTrigger value="components" className="text-xs">

View File

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

View File

@ -503,7 +503,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="empty_vehicle"></SelectItem>
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
<SelectItem value="operation_control"> </SelectItem>
</SelectContent>
</Select>
@ -1664,190 +1664,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 위치정보 가져오기 설정 */}
{(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
{/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">🚛 </h4>
{/* 테이블 선택 */}
<div>
<Label htmlFor="geolocation-table"> </Label>
<Select
value={config.action?.geolocationTableName || currentTableName || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-lat-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lat-field"
placeholder="latitude"
value={config.action?.geolocationLatField || "latitude"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-lng-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lng-field"
placeholder="longitude"
value={config.action?.geolocationLngField || "longitude"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-accuracy-field"> ()</Label>
<Input
id="geolocation-accuracy-field"
placeholder="accuracy"
value={config.action?.geolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-timestamp-field"> ()</Label>
<Input
id="geolocation-timestamp-field"
placeholder="location_time"
value={config.action?.geolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-high-accuracy"> </Label>
<p className="text-xs text-muted-foreground">GPS ( )</p>
</div>
<Switch
id="geolocation-high-accuracy"
checked={config.action?.geolocationHighAccuracy !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
/>
</div>
{/* 자동 저장 옵션 */}
<div className="border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-auto-save">DB </Label>
<p className="text-xs text-muted-foreground"> DB에 </p>
</div>
<Switch
id="geolocation-auto-save"
checked={config.action?.geolocationAutoSave === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
/>
</div>
{config.action?.geolocationAutoSave && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> (WHERE )</Label>
<Input
placeholder="user_id"
value={config.action?.geolocationKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationKeySourceField || "__userId__"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 추가 필드 변경 (status 등) */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="status"
value={config.action?.geolocationExtraField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="inactive"
value={config.action?.geolocationExtraValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
status .
</p>
</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> HTTPS .
</p>
</div>
... UI ...
</div>
)}
)} */}
{/* 운행알림 및 종료 설정 */}
{(component.componentConfig?.action?.type || "save") === "operation_control" && (

View File

@ -31,7 +31,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View File

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

View File

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

View File

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

View File

@ -1,10 +1,63 @@
# RepeatScreenModal 컴포넌트 v3
# RepeatScreenModal 컴포넌트 v3.1
## 개요
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
## v3 주요 변경사항
## v3.1 주요 변경사항 (2025-11-28)
### 1. 외부 테이블 데이터 소스
테이블 행에서 **외부 테이블의 데이터를 조회**하여 표시할 수 있습니다.
```
예시: 수주 관리에서 출하 계획 이력 조회
┌─────────────────────────────────────────────────────────────────┐
│ 카드: 품목 A │
├─────────────────────────────────────────────────────────────────┤
│ [행 1] 헤더: 품목코드, 품목명 │
├─────────────────────────────────────────────────────────────────┤
│ [행 2] 테이블: shipment_plan 테이블에서 조회 │
│ → sales_order_id로 조인하여 출하 계획 이력 표시 │
└─────────────────────────────────────────────────────────────────┘
```
### 2. 테이블 행 CRUD
테이블 행에서 **행 추가/수정/삭제** 기능을 지원합니다.
- **추가**: 새 행 추가 버튼으로 빈 행 생성
- **수정**: 편집 가능한 컬럼 직접 수정
- **삭제**: 행 삭제 (확인 팝업 옵션)
### 3. Footer 버튼 영역
모달 하단에 **커스터마이징 가능한 버튼 영역**을 제공합니다.
```
┌─────────────────────────────────────────────────────────────────┐
│ 카드 내용... │
├─────────────────────────────────────────────────────────────────┤
│ [초기화] [취소] [저장] │
└─────────────────────────────────────────────────────────────────┘
```
### 4. 집계 연산식 지원
집계 행에서 **컬럼 간 사칙연산**을 지원합니다.
```typescript
// 예: 미출하 수량 = 수주수량 - 출하수량
{
sourceType: "formula",
formula: "{order_qty} - {ship_qty}",
label: "미출하 수량"
}
```
---
## v3 주요 변경사항 (기존)
### 자유 레이아웃 시스템
@ -33,29 +86,7 @@
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
### 자유로운 조합
```
예시 1: 헤더 + 집계 + 테이블 (출하계획)
├── [행 1] 헤더: 품목코드, 품목명
├── [행 2] 집계: 총수주잔량, 현재고
└── [행 3] 테이블: 수주별 출하계획
예시 2: 집계만
└── [행 1] 집계: 총매출, 총비용, 순이익
예시 3: 테이블만
└── [행 1] 테이블: 품목 목록
예시 4: 테이블 2개
├── [행 1] 테이블: 입고 내역
└── [행 2] 테이블: 출고 내역
예시 5: 헤더 + 헤더 + 필드
├── [행 1] 헤더: 기본 정보 (읽기전용)
├── [행 2] 헤더: 상세 정보 (읽기전용)
└── [행 3] 필드: 입력 필드 (편집가능)
```
---
## 설정 방법
@ -107,13 +138,34 @@
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
- **스타일**: 배경색, 폰트 크기
#### 테이블 행 설정
#### 테이블 행 설정 (v3.1 확장)
- **테이블 제목**: 선택사항
- **헤더 표시**: 테이블 헤더 표시 여부
- **외부 테이블 데이터 소스**: (v3.1 신규)
- 소스 테이블: 조회할 외부 테이블
- 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키
- 정렬: 정렬 컬럼 및 방향
- **CRUD 설정**: (v3.1 신규)
- 추가: 새 행 추가 허용
- 수정: 행 수정 허용
- 삭제: 행 삭제 허용 (확인 팝업 옵션)
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
### 5. Footer 탭 (v3.1 신규)
- **Footer 사용**: Footer 영역 활성화
- **위치**: 컨텐츠 아래 / 하단 고정 (sticky)
- **정렬**: 왼쪽 / 가운데 / 오른쪽
- **버튼 설정**:
- 라벨: 버튼 텍스트
- 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀
- 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트
- 아이콘: 저장 / X / 초기화 / 없음
---
## 데이터 흐름
```
@ -125,16 +177,22 @@
4. 각 그룹에 대해 집계값 계산
5. 카드 렌더링 (contentRows 기반)
5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1)
6. 사용자 편집
6. 카드 렌더링 (contentRows 기반)
7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장
7. 사용자 편집 (CRUD 포함)
8. Footer 버튼 또는 기본 저장 버튼으로 저장
9. 기본 데이터 + 외부 테이블 데이터 일괄 저장
```
---
## 사용 예시
### 출하계획 등록
### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD)
```typescript
{
@ -167,40 +225,185 @@
type: "aggregation",
aggregationLayout: "horizontal",
aggregationFields: [
{ aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
{ aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" }
{ sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
{ sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" }
]
},
{
id: "row-3",
type: "table",
tableTitle: "수주 목록",
tableTitle: "출하 계획 이력",
showTableHeader: true,
// 외부 테이블에서 데이터 조회
tableDataSource: {
enabled: true,
sourceTable: "shipment_plan",
joinConditions: [
{ sourceKey: "sales_order_id", referenceKey: "id" }
],
orderBy: { column: "created_date", direction: "desc" }
},
// CRUD 설정
tableCrud: {
allowCreate: true,
allowUpdate: true,
allowDelete: true,
newRowDefaults: {
sales_order_id: "{id}",
status: "READY"
},
deleteConfirm: { enabled: true }
},
tableColumns: [
{ id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false },
{ id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false },
{ id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false },
{
id: "tc4",
field: "plan_qty",
label: "출하계획",
type: "number",
editable: true,
targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true }
}
{ id: "tc1", field: "plan_date", label: "계획일", type: "date", editable: true },
{ id: "tc2", field: "plan_qty", label: "계획수량", type: "number", editable: true },
{ id: "tc3", field: "status", label: "상태", type: "text", editable: false },
{ id: "tc4", field: "memo", label: "비고", type: "text", editable: true }
]
}
]
],
// Footer 설정
footerConfig: {
enabled: true,
position: "sticky",
alignment: "right",
buttons: [
{ id: "btn-cancel", label: "취소", action: "cancel", variant: "outline" },
{ id: "btn-save", label: "저장", action: "save", variant: "default", icon: "save" }
]
}
}
```
---
## 타입 정의 (v3.1)
### TableDataSourceConfig
```typescript
interface TableDataSourceConfig {
enabled: boolean; // 외부 데이터 소스 사용 여부
sourceTable: string; // 조회할 테이블
joinConditions: JoinCondition[]; // 조인 조건
orderBy?: {
column: string; // 정렬 컬럼
direction: "asc" | "desc"; // 정렬 방향
};
limit?: number; // 최대 행 수
}
interface JoinCondition {
sourceKey: string; // 외부 테이블의 조인 키
referenceKey: string; // 카드 데이터의 참조 키
referenceType?: "card" | "row"; // 참조 소스
}
```
### TableCrudConfig
```typescript
interface TableCrudConfig {
allowCreate: boolean; // 행 추가 허용
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
newRowDefaults?: Record<string, string>; // 신규 행 기본값 ({field} 형식 지원)
deleteConfirm?: {
enabled: boolean; // 삭제 확인 팝업
message?: string; // 확인 메시지
};
targetTable?: string; // 저장 대상 테이블
}
```
### FooterConfig
```typescript
interface FooterConfig {
enabled: boolean; // Footer 사용 여부
buttons?: FooterButtonConfig[];
position?: "sticky" | "static";
alignment?: "left" | "center" | "right";
}
interface FooterButtonConfig {
id: string;
label: string;
action: "save" | "cancel" | "close" | "reset" | "custom";
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost";
icon?: string;
disabled?: boolean;
customAction?: {
type: string;
config?: Record<string, any>;
};
}
```
### AggregationDisplayConfig (v3.1 확장)
```typescript
interface AggregationDisplayConfig {
// 값 소스 타입
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
// aggregation: 기존 집계 결과 참조
aggregationResultField?: string;
// formula: 컬럼 간 연산
formula?: string; // 예: "{order_qty} - {ship_qty}"
// external: 외부 테이블 조회 (향후 구현)
externalSource?: ExternalValueSource;
// externalFormula: 외부 테이블 + 연산 (향후 구현)
externalSources?: ExternalValueSource[];
externalFormula?: string;
// 표시 설정
label: string;
icon?: string;
backgroundColor?: string;
textColor?: string;
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl";
format?: "number" | "currency" | "percent";
decimalPlaces?: number;
}
```
---
## 레거시 호환
v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다.
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
---
## 주의사항
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.
4. **외부 테이블 CRUD**: 외부 테이블 데이터 소스가 설정된 테이블에서만 CRUD가 동작합니다.
5. **연산식**: 사칙연산(+, -, *, /)과 괄호만 지원됩니다. 복잡한 함수는 지원하지 않습니다.
---
## 변경 이력
### v3.1 (2025-11-28)
- 외부 테이블 데이터 소스 기능 추가
- 테이블 행 CRUD (추가/수정/삭제) 기능 추가
- Footer 버튼 영역 기능 추가
- 집계 연산식 (formula) 지원 추가
- 다단계 조인 타입 정의 추가 (향후 구현 예정)
### v3.0
- 자유 레이아웃 시스템 도입
- contentRows 기반 행 타입 선택 방식
- 헤더/필드/집계/테이블 4가지 행 타입 지원
### v2.0
- simple 모드 / withTable 모드 구분
- cardLayout / tableLayout 분리

View File

@ -23,6 +23,9 @@ export interface RepeatScreenModalProps {
// === 🆕 v3: 자유 레이아웃 ===
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
// === 🆕 v3.1: Footer 버튼 설정 ===
footerConfig?: FooterConfig; // Footer 영역 설정
// === (레거시 호환) ===
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
@ -33,6 +36,34 @@ export interface RepeatScreenModalProps {
onChange?: (newData: any[]) => void;
}
/**
* 🆕 v3.1: Footer
*/
export interface FooterConfig {
enabled: boolean; // Footer 사용 여부
buttons?: FooterButtonConfig[]; // Footer 버튼들
position?: "sticky" | "static"; // sticky: 하단 고정, static: 컨텐츠 아래
alignment?: "left" | "center" | "right"; // 버튼 정렬
}
/**
* 🆕 v3.1: Footer
*/
export interface FooterButtonConfig {
id: string; // 버튼 고유 ID
label: string; // 버튼 라벨
action: "save" | "cancel" | "close" | "reset" | "custom"; // 액션 타입
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일
icon?: string; // 아이콘 (lucide 아이콘명)
disabled?: boolean; // 비활성화 여부
// custom 액션일 때
customAction?: {
type: string; // 커스텀 액션 타입
config?: Record<string, any>; // 커스텀 설정
};
}
/**
*
*/
@ -79,28 +110,206 @@ export interface CardContentRowConfig {
tableTitle?: string; // 테이블 제목
showTableHeader?: boolean; // 테이블 헤더 표시 여부
tableMaxHeight?: string; // 테이블 최대 높이
// 🆕 v3.1: 테이블 외부 데이터 소스
tableDataSource?: TableDataSourceConfig; // 외부 테이블에서 데이터 조회
// 🆕 v3.1: 테이블 CRUD 설정
tableCrud?: TableCrudConfig; // 행 추가/수정/삭제 설정
}
/**
* 🆕 v3.1: 테이블
*
*/
export interface TableDataSourceConfig {
enabled: boolean; // 외부 데이터 소스 사용 여부
sourceTable: string; // 조회할 테이블 (예: "shipment_plan")
// 조인 설정
joinConditions: JoinCondition[]; // 조인 조건 (복합 키 지원)
// 🆕 v3.3: 추가 조인 테이블 설정 (소스 테이블에 없는 컬럼 조회)
additionalJoins?: AdditionalJoinConfig[];
// 🆕 v3.4: 필터 조건 설정 (그룹 내 특정 조건으로 필터링)
filterConfig?: TableFilterConfig;
// 정렬 설정
orderBy?: {
column: string; // 정렬 컬럼
direction: "asc" | "desc"; // 정렬 방향
};
// 제한
limit?: number; // 최대 행 수
}
/**
* 🆕 v3.4: 테이블
*
*/
export interface TableFilterConfig {
enabled: boolean; // 필터 사용 여부
filterField: string; // 필터링할 필드 (예: "order_no")
filterType: "equals" | "notEquals"; // equals: 같은 값만, notEquals: 다른 값만
referenceField: string; // 비교 기준 필드 (formData 또는 카드 대표 데이터에서)
referenceSource: "formData" | "representativeData"; // 비교 값 소스
}
/**
* 🆕 v3.3: 추가
*
*/
export interface AdditionalJoinConfig {
id: string; // 조인 설정 고유 ID
joinTable: string; // 조인할 테이블 (예: "sales_order_mng")
joinType: "left" | "inner"; // 조인 타입
sourceKey: string; // 소스 테이블의 조인 키 (예: "sales_order_id")
targetKey: string; // 조인 테이블의 키 (예: "id")
alias?: string; // 테이블 별칭 (예: "so")
selectColumns?: string[]; // 가져올 컬럼 목록 (비어있으면 전체)
}
/**
* 🆕 v3.1: 조인
*/
export interface JoinCondition {
sourceKey: string; // 외부 테이블의 조인 키 (예: "sales_order_id")
referenceKey: string; // 현재 카드 데이터의 참조 키 (예: "id")
referenceType?: "card" | "row"; // card: 카드 대표 데이터, row: 각 행 데이터 (기본: card)
}
/**
* 🆕 v3.1: 테이블 CRUD
*/
export interface TableCrudConfig {
allowCreate: boolean; // 행 추가 허용
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
// 🆕 v3.5: 테이블 영역 저장 버튼
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
// 신규 행 기본값
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
// 삭제 확인
deleteConfirm?: {
enabled: boolean; // 삭제 확인 팝업 표시 여부
message?: string; // 확인 메시지
};
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
}
/**
* 🆕 v3: 집계
*/
export interface AggregationDisplayConfig {
aggregationResultField: string; // 그룹핑 설정의 resultField 참조
// 값 소스 타입
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
// === sourceType: "aggregation" (기존 그룹핑 집계 결과 참조) ===
aggregationResultField?: string; // 그룹핑 설정의 resultField 참조
// === sourceType: "formula" (컬럼 간 연산) ===
formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}")
// === sourceType: "external" (외부 테이블 조회) ===
externalSource?: ExternalValueSource;
// === sourceType: "externalFormula" (외부 테이블 + 연산) ===
externalSources?: ExternalValueSource[]; // 여러 외부 소스
externalFormula?: string; // 외부 값들을 조합한 연산식 (예: "{inv_qty} + {prod_qty}")
// 표시 설정
label: string; // 표시 라벨
icon?: string; // 아이콘 (lucide 아이콘명)
backgroundColor?: string; // 배경색
textColor?: string; // 텍스트 색상
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
format?: "number" | "currency" | "percent"; // 숫자 포맷
decimalPlaces?: number; // 소수점 자릿수
}
/**
* 🆕 v3.1: 외부
*/
export interface ExternalValueSource {
alias: string; // 연산식에서 사용할 별칭 (예: "inv_qty")
sourceTable: string; // 조회할 테이블
sourceColumn: string; // 조회할 컬럼
aggregationType?: "sum" | "count" | "avg" | "min" | "max" | "first"; // 집계 타입 (기본: first)
// 조인 설정 (다단계 조인 지원)
joins: ChainedJoinConfig[];
}
/**
* 🆕 v3.1: 다단계
*/
export interface ChainedJoinConfig {
step: number; // 조인 순서 (1, 2, 3...)
sourceTable: string; // 조인할 테이블
joinConditions: {
sourceKey: string; // 조인 테이블의 키
referenceKey: string; // 참조 키 (이전 단계 결과 또는 카드 데이터)
referenceFrom?: "card" | "previousStep"; // 참조 소스 (기본: card, step > 1이면 previousStep)
}[];
selectColumns?: string[]; // 이 단계에서 선택할 컬럼
}
/**
*
* 🆕 v3.2: 다중 (formula)
* 🆕 v3.9: 연관
*/
export interface AggregationConfig {
sourceField: string; // 원본 필드 (예: "balance_qty")
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
// === 집계 소스 타입 ===
sourceType: "column" | "formula"; // column: 테이블 컬럼 집계, formula: 연산식 (가상 집계)
// === sourceType: "column" (테이블 컬럼 집계) ===
sourceTable?: string; // 집계할 테이블 (기본: dataSource.sourceTable, 외부 테이블도 가능)
sourceField?: string; // 원본 필드 (예: "balance_qty")
type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
// === sourceType: "formula" (가상 집계 - 연산식) ===
// 연산식 문법:
// - {resultField}: 다른 집계 결과 참조 (예: {total_balance})
// - {테이블.컬럼}: 테이블의 컬럼 직접 참조 (예: {sales_order_mng.order_qty})
// - SUM({컬럼}): 기본 테이블 행들의 합계
// - SUM_EXT({컬럼}): 외부 테이블 행들의 합계 (externalTableData)
// - 산술 연산: +, -, *, /, ()
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
// === 공통 ===
resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량")
// === 🆕 v3.9: 저장 설정 ===
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
}
/**
* 🆕 v3.9: 집계
*
*/
export interface AggregationSaveConfig {
enabled: boolean; // 저장 활성화 여부
autoSave: boolean; // 자동 저장 (레이아웃에 없어도 저장)
// 저장 대상
targetTable: string; // 저장할 테이블 (예: "sales_order_mng")
targetColumn: string; // 저장할 컬럼 (예: "plan_qty_total")
// 조인 키 (어떤 레코드를 업데이트할지)
joinKey: {
sourceField: string; // 현재 카드의 조인 키 (예: "id" 또는 "sales_order_id")
targetField: string; // 대상 테이블의 키 (예: "id")
};
}
/**
@ -120,7 +329,7 @@ export interface TableLayoutConfig {
*/
export interface TableColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명
field: string; // 필드명 (소스 테이블 컬럼 또는 조인 테이블 컬럼)
label: string; // 헤더 라벨
type: "text" | "number" | "date" | "select" | "badge"; // 타입
width?: string; // 너비 (예: "100px", "20%")
@ -128,6 +337,10 @@ export interface TableColumnConfig {
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];

View File

@ -25,3 +25,4 @@ if (process.env.NODE_ENV === "development") {
SectionPaperRenderer.enableHotReload();
}

View File

@ -65,3 +65,4 @@ export function useCalculation(calculationRules: CalculationRule[] = []) {
};
}

View File

@ -1138,14 +1138,50 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const hasFilters = Object.keys(filters).length > 0;
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,
@ -1156,7 +1192,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// });
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
@ -1205,6 +1241,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
totalItems: response.total || 0,
}
);
}
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);

View File

@ -5,6 +5,7 @@ import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
import { apiClient } from "@/lib/api/client";
import type { ExtendedControlContext } from "@/types/control-management";
/**
@ -24,7 +25,7 @@ export type ButtonActionType =
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합
| "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경)
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
@ -163,6 +164,10 @@ export interface ButtonActionConfig {
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
// 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용)
emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true)
emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
@ -353,8 +358,8 @@ export class ButtonActionExecutor {
case "transferData":
return await this.handleTransferData(config, context);
case "empty_vehicle":
return await this.handleEmptyVehicle(config, context);
// case "empty_vehicle":
// return await this.handleEmptyVehicle(config, context);
case "operation_control":
return await this.handleOperationControl(config, context);
@ -684,11 +689,122 @@ export class ButtonActionExecutor {
}
}
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
data: dataWithUserInfo,
});
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
const repeatScreenModalKeys = Object.keys(context.formData).filter((key) =>
key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations"
);
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
if (shouldSkipMainSave) {
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
} else {
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
data: dataWithUserInfo,
});
}
if (repeatScreenModalKeys.length > 0) {
console.log("📦 [handleSave] RepeatScreenModal 데이터 저장 시작:", repeatScreenModalKeys);
// 🆕 formData에서 채번 규칙으로 생성된 값들 추출 (예: shipment_plan_no)
const numberingFields: Record<string, any> = {};
for (const [fieldKey, value] of Object.entries(context.formData)) {
// _numberingRuleId로 끝나는 키가 있으면 해당 필드는 채번 규칙 값
if (context.formData[`${fieldKey}_numberingRuleId`]) {
numberingFields[fieldKey] = value;
}
}
console.log("📦 [handleSave] 채번 규칙 필드:", numberingFields);
for (const key of repeatScreenModalKeys) {
const targetTable = key.replace("_repeatScreenModal_", "");
const rows = context.formData[key] as any[];
if (!Array.isArray(rows) || rows.length === 0) continue;
console.log(`📦 [handleSave] ${targetTable} 테이블 저장:`, rows);
for (const row of rows) {
const { _isNew, _targetTable, id, ...dataToSave } = row;
// 사용자 정보 추가 + 채번 규칙 값 병합
const dataWithMeta = {
...dataToSave,
...numberingFields, // 채번 규칙 값 (shipment_plan_no 등)
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
};
try {
if (_isNew) {
// INSERT
console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta);
const insertResult = await apiClient.post(
`/table-management/tables/${targetTable}/add`,
dataWithMeta
);
console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data);
} else if (id) {
// UPDATE
const originalData = { id };
const updatedData = { ...dataWithMeta, id };
console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData });
const updateResult = await apiClient.put(
`/table-management/tables/${targetTable}/edit`,
{ originalData, updatedData }
);
console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data);
}
} catch (error: any) {
console.error(`❌ [handleSave] ${targetTable} 저장 실패:`, error.response?.data || error.message);
// 개별 실패는 전체 저장을 중단하지 않음
}
}
}
}
// 🆕 v3.9: RepeatScreenModal 집계 저장 처리
const aggregationConfigs = context.formData._repeatScreenModal_aggregations as Array<{
resultField: string;
aggregatedValue: number;
targetTable: string;
targetColumn: string;
joinKey: { sourceField: string; targetField: string };
sourceValue: any;
}>;
if (aggregationConfigs && aggregationConfigs.length > 0) {
console.log("📊 [handleSave] 집계 저장 시작:", aggregationConfigs);
for (const config of aggregationConfigs) {
const { targetTable, targetColumn, joinKey, aggregatedValue, sourceValue } = config;
try {
const originalData = { [joinKey.targetField]: sourceValue };
const updatedData = {
[targetColumn]: aggregatedValue,
[joinKey.targetField]: sourceValue,
};
console.log(`📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`);
const updateResult = await apiClient.put(
`/table-management/tables/${targetTable}/edit`,
{ originalData, updatedData }
);
console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data);
} catch (error: any) {
console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message);
}
}
}
}
if (!saveResult.success) {
@ -3529,6 +3645,7 @@ export class ButtonActionExecutor {
/**
* ( )
* + vehicles latitude/longitude도
*/
private static async saveLocationToHistory(
tripId: string | null,
@ -3545,13 +3662,15 @@ export class ButtonActionExecutor {
try {
const { apiClient } = await import("@/lib/api/client");
const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords;
const locationData = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
altitude: position.coords.altitude,
speed: position.coords.speed,
heading: position.coords.heading,
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus,
departure,
@ -3564,6 +3683,7 @@ export class ButtonActionExecutor {
console.log("📍 [saveLocationToHistory] 위치 저장:", locationData);
// 1. vehicle_location_history에 저장
const response = await apiClient.post(`/dynamic-form/location-history`, locationData);
if (response.data?.success) {
@ -3572,6 +3692,41 @@ export class ButtonActionExecutor {
console.warn("⚠️ 위치 이력 저장 실패:", response.data);
}
// 2. vehicles 테이블의 latitude/longitude도 업데이트 (실시간 위치 반영)
if (this.trackingContext && this.trackingConfig) {
const keyField = this.trackingConfig.trackingStatusKeyField || "user_id";
const keySourceField = this.trackingConfig.trackingStatusKeySourceField || "__userId__";
const keyValue = resolveSpecialKeyword(keySourceField, this.trackingContext);
const vehiclesTableName = this.trackingConfig.trackingStatusTableName || "vehicles";
if (keyValue) {
try {
// latitude 업데이트
await apiClient.put(`/dynamic-form/update-field`, {
tableName: vehiclesTableName,
keyField,
keyValue,
updateField: "latitude",
updateValue: latitude,
});
// longitude 업데이트
await apiClient.put(`/dynamic-form/update-field`, {
tableName: vehiclesTableName,
keyField,
keyValue,
updateField: "longitude",
updateValue: longitude,
});
console.log(`✅ vehicles 테이블 위치 업데이트: (${latitude.toFixed(6)}, ${longitude.toFixed(6)})`);
} catch (vehicleUpdateError) {
// 컬럼이 없으면 조용히 무시
console.warn("⚠️ vehicles 테이블 위치 업데이트 실패 (무시):", vehicleUpdateError);
}
}
}
resolve();
} catch (error) {
console.error("❌ 위치 이력 저장 오류:", error);
@ -3701,13 +3856,18 @@ export class ButtonActionExecutor {
}
}
// 공차 추적용 watchId 저장
private static emptyVehicleWatchId: number | null = null;
private static emptyVehicleTripId: string | null = null;
/**
*
* - + (: status inactive)
* - (vehicle_location_history에 )
*/
private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
console.log("📍 공차등록 액션 실행:", { config, context });
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
@ -3736,7 +3896,7 @@ export class ButtonActionExecutor {
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy } = position.coords;
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
@ -3805,8 +3965,15 @@ export class ButtonActionExecutor {
}
}
console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`);
// 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록)
if (config.emptyVehicleTracking !== false) {
await this.startEmptyVehicleTracking(config, context, {
latitude, longitude, accuracy, speed, heading, altitude
});
}
toast.success(config.successMessage || "위치 정보가 저장되었습니다.");
toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
@ -3823,7 +3990,7 @@ export class ButtonActionExecutor {
return true;
} catch (error: any) {
console.error("❌ 위치정보 가져오기 실패:", error);
console.error("❌ 공차등록 실패:", error);
toast.dismiss();
// GeolocationPositionError 처리
@ -3849,6 +4016,122 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static async startEmptyVehicleTracking(
config: ButtonActionConfig,
context: ButtonActionContext,
initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null }
): Promise<void> {
try {
// 기존 추적이 있으면 중지
if (this.emptyVehicleWatchId !== null) {
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
this.emptyVehicleWatchId = null;
}
const { apiClient } = await import("@/lib/api/client");
// Trip ID 생성 (공차용)
const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
this.emptyVehicleTripId = tripId;
const userId = context.userId || "";
const companyCode = context.companyCode || "";
const departure = context.formData?.departure || "";
const arrival = context.formData?.arrival || "";
const departureName = context.formData?.departure_name || "";
const destinationName = context.formData?.destination_name || "";
// 시작 위치 기록
try {
await apiClient.post("/dynamic-form/location-history", {
tripId,
userId,
latitude: initialPosition.latitude,
longitude: initialPosition.longitude,
accuracy: initialPosition.accuracy,
speed: initialPosition.speed,
heading: initialPosition.heading,
altitude: initialPosition.altitude,
tripStatus: "empty_start", // 공차 시작
departure,
arrival,
departureName,
destinationName,
companyCode,
});
console.log("📍 공차 시작 위치 기록 완료:", tripId);
} catch (err) {
console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err);
}
// 추적 간격 (기본 10초)
const trackingInterval = config.emptyVehicleTrackingInterval || 10000;
// watchPosition으로 연속 추적
this.emptyVehicleWatchId = navigator.geolocation.watchPosition(
async (position) => {
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
try {
await apiClient.post("/dynamic-form/location-history", {
tripId: this.emptyVehicleTripId,
userId,
latitude,
longitude,
accuracy,
speed,
heading,
altitude,
tripStatus: "empty_tracking", // 공차 추적 중
departure,
arrival,
departureName,
destinationName,
companyCode,
});
console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) });
} catch (err) {
console.warn("⚠️ 공차 위치 기록 실패:", err);
}
},
(error) => {
console.error("❌ 공차 위치 추적 오류:", error.message);
},
{
enableHighAccuracy: true,
timeout: trackingInterval,
maximumAge: 0,
}
);
console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId });
} catch (error) {
console.error("❌ 공차 위치 추적 시작 실패:", error);
}
}
/**
* ( )
*/
public static stopEmptyVehicleTracking(): void {
if (this.emptyVehicleWatchId !== null) {
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId });
this.emptyVehicleWatchId = null;
this.emptyVehicleTripId = null;
}
}
/**
* Trip ID
*/
public static getEmptyVehicleTripId(): string | null {
return this.emptyVehicleTripId;
}
/**
* (: 출발지 )
*/
@ -3913,7 +4196,13 @@ export class ButtonActionExecutor {
*/
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
if (this.emptyVehicleWatchId !== null) {
this.stopEmptyVehicleTracking();
console.log("🛑 공차 추적 종료 후 운행 시작");
}
// 🆕 연속 위치 추적 모드 처리
if (config.updateWithTracking) {

View File

@ -33,6 +33,28 @@ export interface FlowConditionGroup {
conditions: FlowCondition[];
}
// ============================================
// 다중 REST API 연결 설정
// ============================================
export interface RestApiConnectionConfig {
connectionId: number;
connectionName: string;
endpoint: string;
jsonPath: string;
alias: string; // 컬럼 접두어 (예: "api1_")
}
// ============================================
// 다중 외부 DB 연결 설정
// ============================================
export interface ExternalDbConnectionConfig {
connectionId: number;
connectionName: string;
dbType: string;
tableName: string;
alias: string; // 컬럼 접두어 (예: "db1_")
}
// ============================================
// 플로우 정의
// ============================================
@ -41,6 +63,17 @@ export interface FlowDefinition {
name: string;
description?: string;
tableName: string;
// 데이터 소스 관련
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db";
dbConnectionId?: number;
// REST API 관련 (단일)
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
// 다중 REST API 관련
restApiConnections?: RestApiConnectionConfig[];
// 다중 외부 DB 관련
externalDbConnections?: ExternalDbConnectionConfig[];
isActive: boolean;
createdAt: string;
updatedAt: string;
@ -52,6 +85,17 @@ export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
// 데이터 소스 관련
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db";
dbConnectionId?: number;
// REST API 관련 (단일)
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
// 다중 REST API 관련
restApiConnections?: RestApiConnectionConfig[];
// 다중 외부 DB 관련
externalDbConnections?: ExternalDbConnectionConfig[];
}
export interface UpdateFlowDefinitionRequest {

View File

@ -126,7 +126,7 @@ export const OPERATION_OPTIONS = [
export const INTEGRATION_TYPE_OPTIONS = [
{ value: "internal", label: "내부 DB (기본)" },
{ value: "external_db", label: "외부 DB 연동" },
{ value: "rest_api", label: "REST API (추후 지원)" },
{ value: "rest_api", label: "REST API 연동" },
{ value: "webhook", label: "Webhook (추후 지원)" },
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
] as const;