Compare commits

..

No commits in common. "7713d4073cd9dc8275864892fae7a87021586106" and "44c76d80b701d5ff28326f8b32e16087bcc0facc" have entirely different histories.

53 changed files with 818 additions and 9709 deletions

View File

@ -72,7 +72,6 @@ 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"; // 임시 주석
@ -239,7 +238,6 @@ 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,12 +708,6 @@ 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) {
@ -725,24 +719,8 @@ 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' };
}
}
// 텍스트 응답인 경우 포맷팅
else if (typeof data === "string") {
if (typeof data === "string") {
data = { text: data, contentType };
}

View File

@ -32,32 +32,10 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
const companyCode = req.user!.companyCode;
// 검색 필드 파싱
const requestedFields = searchFields
const fields = searchFields
? (searchFields as string).split(",").map((f) => f.trim())
: [];
// 🆕 테이블의 실제 컬럼 목록 조회
const pool = getPool();
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 🆕 존재하는 컬럼만 필터링
const fields = requestedFields.filter((field) => {
if (existingColumns.has(field)) {
return true;
} else {
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
return false;
}
});
const existingColumnsArray = Array.from(existingColumns);
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
// WHERE 조건 생성
const whereConditions: string[] = [];
const params: any[] = [];
@ -65,57 +43,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
// 멀티테넌시 필터링
if (companyCode !== "*") {
// 🆕 company_code 컬럼이 있는 경우에만 필터링
if (existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 검색 조건
if (searchText) {
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
let searchableFields = fields;
if (searchableFields.length === 0) {
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
const defaultSearchColumns = [
'name', 'code', 'description', 'title', 'label',
'item_name', 'item_code', 'item_number',
'equipment_name', 'equipment_code',
'inspection_item', 'consumable_name', // 소모품명 추가
'supplier_name', 'customer_name', 'product_name',
];
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
}
if (searchableFields.length > 0) {
const searchConditions = searchableFields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
if (searchText && fields.length > 0) {
const searchConditions = fields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
// 검색어 파라미터 추가
searchableFields.forEach(() => {
params.push(`%${searchText}%`);
});
}
// 검색어 파라미터 추가
fields.forEach(() => {
params.push(`%${searchText}%`);
});
}
// 추가 필터 조건 (존재하는 컬럼만)
// 추가 필터 조건
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
if (existingColumns.has(key)) {
whereConditions.push(`${key} = $${paramIndex}`);
params.push(value);
paramIndex++;
} else {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
}
whereConditions.push(`${key} = $${paramIndex}`);
params.push(value);
paramIndex++;
}
// 페이징
@ -125,7 +78,8 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 쿼리 실행 (pool은 위에서 이미 선언됨)
// 쿼리 실행
const pool = getPool();
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = `
SELECT * FROM ${tableName} ${whereClause}

View File

@ -32,17 +32,8 @@ export class FlowController {
*/
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
// REST API 관련 필드
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
const { name, description, tableName, dbSourceType, dbConnectionId } =
req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
@ -52,9 +43,6 @@ export class FlowController {
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
userCompanyCode,
});
@ -66,12 +54,8 @@ export class FlowController {
return;
}
// 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_")) {
// 테이블 이름이 제공된 경우에만 존재 확인
if (tableName) {
const tableExists =
await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) {
@ -84,17 +68,7 @@ export class FlowController {
}
const flowDef = await this.flowDefinitionService.create(
{
name,
description,
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
},
{ name, description, tableName, dbSourceType, dbConnectionId },
userId,
userCompanyCode
);

View File

@ -148,42 +148,11 @@ export const updateScreenInfo = async (
try {
const { id } = req.params;
const { companyCode } = req.user as any;
const {
screenName,
tableName,
description,
isActive,
// REST API 관련 필드 추가
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
console.log("화면 정보 수정 요청:", {
screenId: id,
dataSourceType,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
});
const { screenName, tableName, description, isActive } = req.body;
await screenManagementService.updateScreenInfo(
parseInt(id),
{
screenName,
tableName,
description,
isActive,
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
},
{ screenName, tableName, description, isActive },
companyCode
);
res.json({ success: true, message: "화면 정보가 수정되었습니다." });

View File

@ -870,17 +870,6 @@ 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

@ -1,206 +0,0 @@
/**
*
*/
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

@ -1,301 +0,0 @@
/**
*
*/
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

@ -1,71 +0,0 @@
/**
*
*/
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,150 +1091,4 @@ 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,21 +27,13 @@ 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,
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)
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)
RETURNING *
`;
@ -51,10 +43,6 @@ 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,
];
@ -211,19 +199,6 @@ 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,
@ -231,12 +206,6 @@ 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,24 +47,9 @@ export class RiskAlertService {
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
// 텍스트 응답 파싱 (인코딩 자동 감지)
// 텍스트 응답 파싱 (EUC-KR 인코딩)
const iconv = require('iconv-lite');
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 인코딩으로 디코딩');
}
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
const lines = responseText.split('\n');

View File

@ -326,19 +326,7 @@ export class ScreenManagementService {
*/
async updateScreenInfo(
screenId: number,
updateData: {
screenName: string;
tableName?: string;
description?: string;
isActive: string;
// REST API 관련 필드 추가
dataSourceType?: string;
dbSourceType?: string;
dbConnectionId?: number;
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
},
updateData: { screenName: string; tableName?: string; description?: string; isActive: string },
userCompanyCode: string
): Promise<void> {
// 권한 확인
@ -360,43 +348,24 @@ export class ScreenManagementService {
throw new Error("이 화면을 수정할 권한이 없습니다.");
}
// 화면 정보 업데이트 (REST API 필드 포함)
// 화면 정보 업데이트 (tableName 포함)
await query(
`UPDATE screen_definitions
SET screen_name = $1,
table_name = $2,
description = $3,
is_active = $4,
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`,
updated_date = $5
WHERE screen_id = $6`,
[
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,
});
}
/**
@ -2047,40 +2016,37 @@ 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 screen_code LIKE $1
ORDER BY screen_code DESC`,
[`${companyCode}_%`]
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${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;
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
const newCode = `${companyCode}_${nextNumber}`;
console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
const paddedNumber = nextNumber.toString().padStart(3, "0");
const newCode = `${companyCode}_${paddedNumber}`;
console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber})`);
return newCode;
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨

View File

@ -1516,26 +1516,6 @@ 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 {
@ -4090,22 +4070,4 @@ 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

@ -1,403 +0,0 @@
/**
*
*/
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

@ -1,456 +0,0 @@
/**
*
*/
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,38 +2,14 @@
*
*/
// 다중 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" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
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;
@ -46,14 +22,8 @@ export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
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

@ -1,176 +0,0 @@
/**
*
*/
/**
* 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

@ -1,30 +0,0 @@
"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

@ -1,29 +0,0 @@
"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,10 +319,6 @@ 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,7 +34,6 @@ 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();
@ -53,42 +52,13 @@ export default function FlowManagementPage() {
);
const [loadingTables, setLoadingTables] = useState(false);
const [openTableCombobox, setOpenTableCombobox] = useState(false);
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
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: "",
@ -165,288 +135,75 @@ 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(() => {
// REST API인 경우 테이블 목록 로드 불필요
if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) {
if (selectedDbSource === "internal" || !selectedDbSource) {
setExternalTableList([]);
return;
}
// 외부 DB인 경우
if (selectedDbSource.startsWith("external_db_")) {
const connectionId = selectedDbSource.replace("external_db_", "");
const loadExternalTables = async () => {
try {
setLoadingExternalTables(true);
const token = localStorage.getItem("authToken");
const loadExternalTables = async () => {
try {
setLoadingExternalTables(true);
const token = localStorage.getItem("authToken");
const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/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);
} else {
setExternalTableList([]);
}
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([]);
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
} else {
setExternalTableList([]);
} finally {
setLoadingExternalTables(false);
}
};
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);
setExternalTableList([]);
} finally {
setLoadingExternalTables(false);
}
} 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
));
};
loadExternalTables();
}, [selectedDbSource]);
// 플로우 생성
const handleCreate = async () => {
console.log("🚀 handleCreate called with formData:", formData);
// 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 });
if (!formData.name || !formData.tableName) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName });
toast({
title: "입력 오류",
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 엔드포인트는 필수입니다.",
description: "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive",
});
return;
}
try {
// 데이터 소스 타입 및 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> = {
// DB 소스 정보 추가
const requestData = {
...formData,
dbSourceType,
dbConnectionId,
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
};
// 다중 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 as Parameters<typeof createFlowDefinition>[0]);
const response = await createFlowDefinition(requestData);
if (response.success && response.data) {
toast({
title: "생성 완료",
@ -455,12 +212,6 @@ export default function FlowManagementPage() {
setIsCreateDialogOpen(false);
setFormData({ name: "", description: "", tableName: "" });
setSelectedDbSource("internal");
setRestApiEndpoint("");
setRestApiJsonPath("response");
setSelectedRestApis([]);
setSelectedExternalDbs([]);
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
loadFlows();
} else {
toast({
@ -664,373 +415,125 @@ 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={isMultiRestApi ? "multi_restapi" : isMultiExternalDb ? "multi_external_db" : selectedDbSource}
value={selectedDbSource.toString()}
onValueChange={(value) => {
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);
}
// 소스 변경 시 초기화
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
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>
{/* 외부 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>
)}
</>
)}
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 다중 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));
}
}}
{/* 테이블 선택 */}
<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)}
>
<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>
)}
{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,9 +62,6 @@ 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);
@ -134,37 +131,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const {
screenId,
title,
description,
size,
urlParams,
editData,
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
} = event.detail;
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
screenId,
title,
selectedData: eventSelectedData,
selectedIds,
});
const { screenId, title, description, size, urlParams, editData } = event.detail;
// 🆕 모달 열린 시간 기록
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);
@ -183,13 +155,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
const parentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: splitPanelContext?.getMappedParentData() || {};
const parentData = splitPanelContext?.getMappedParentData() || {};
if (Object.keys(parentData).length > 0) {
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
setFormData(parentData);
@ -228,7 +194,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setScreenData(null);
setFormData({});
setOriginalData(null); // 🆕 원본 데이터 초기화
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
console.log("🔄 연속 모드 초기화: false");
@ -694,7 +659,6 @@ 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" | "off";
status: "active" | "inactive" | "maintenance" | "warning";
speed: number;
destination: string;
}
@ -88,45 +88,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
const statusCol = element.chartConfig.statusColumn || "status";
// DB 데이터를 Vehicle 형식으로 변환
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);
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] 유효한 차량 수:", vehiclesFromDB.length);
setVehicles(vehiclesFromDB);
setLastUpdate(new Date());
setIsLoading(false);

View File

@ -14,27 +14,13 @@ 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" | "restapi" | "multi_restapi"; // DB 소스 타입
dbSourceType?: "internal" | "external"; // 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;
}
@ -59,10 +45,6 @@ export function FlowConditionBuilder({
tableName,
dbSourceType = "internal",
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections,
condition,
onChange,
}: FlowConditionBuilderProps) {
@ -83,10 +65,9 @@ export function FlowConditionBuilder({
}
}, [condition]);
// 테이블 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
// 테이블 컬럼 로드 - 내부/외부 DB 모두 지원
useEffect(() => {
// REST API인 경우 tableName이 없어도 진행 가능
if (!tableName && dbSourceType !== "restapi" && dbSourceType !== "multi_restapi") {
if (!tableName) {
setColumns([]);
return;
}
@ -98,106 +79,8 @@ 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");
@ -265,7 +148,7 @@ export function FlowConditionBuilder({
};
loadColumns();
}, [tableName, dbSourceType, dbConnectionId, restApiConnectionId, restApiEndpoint, restApiJsonPath]);
}, [tableName, dbSourceType, dbConnectionId]);
// 조건 변경 시 부모에 전달
useEffect(() => {

View File

@ -30,25 +30,12 @@ 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" | "restapi" | "multi_restapi"; // 플로우의 DB 소스 타입
flowDbSourceType?: "internal" | "external"; // 플로우의 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;
}
@ -59,10 +46,6 @@ export function FlowStepPanel({
flowTableName,
flowDbSourceType = "internal",
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiEndpoint,
flowRestApiJsonPath,
flowRestApiConnections,
onClose,
onUpdate,
}: FlowStepPanelProps) {
@ -73,9 +56,6 @@ export function FlowStepPanel({
flowTableName,
flowDbSourceType,
flowDbConnectionId,
flowRestApiConnectionId,
flowRestApiEndpoint,
flowRestApiJsonPath,
final: step.tableName || flowTableName || "",
});
@ -335,11 +315,10 @@ export function FlowStepPanel({
setFormData(newFormData);
}, [step.id, flowTableName]); // flowTableName도 의존성 추가
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 모두 지원
useEffect(() => {
const loadColumns = async () => {
// 다중 REST API인 경우 tableName 없이도 컬럼 로드 가능
if (!formData.tableName && flowDbSourceType !== "multi_restapi") {
if (!formData.tableName) {
setColumns([]);
return;
}
@ -350,74 +329,8 @@ 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");
@ -486,7 +399,7 @@ export function FlowStepPanel({
};
loadColumns();
}, [formData.tableName, flowDbSourceType, flowDbConnectionId, flowRestApiConnectionId, flowRestApiEndpoint, flowRestApiJsonPath, flowRestApiConnections]);
}, [formData.tableName, flowDbSourceType, flowDbConnectionId]);
// formData의 최신 값을 항상 참조하기 위한 ref
const formDataRef = useRef(formData);
@ -748,10 +661,6 @@ 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 })}
/>
@ -943,7 +852,7 @@ export function FlowStepPanel({
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "rest_api"}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
>
{opt.label}
</SelectItem>
@ -1135,132 +1044,6 @@ 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

@ -91,21 +91,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
}
}, [initialFormData]);
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
useEffect(() => {
// 우측 화면인 경우에만 적용
if (position !== "right" || !splitPanelContext) return;
const mappedData = splitPanelContext.getMappedParentData();
if (Object.keys(mappedData).length > 0) {
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
setFormData((prev) => ({
...prev,
...mappedData,
}));
}
}, [position, splitPanelContext, splitPanelContext?.selectedLeftData]);
// 선택 변경 이벤트 전파
useEffect(() => {
onSelectionChanged?.(selectedRows);

View File

@ -127,7 +127,6 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
leftScreenId={config?.leftScreenId || null}
rightScreenId={config?.rightScreenId || null}
parentDataMapping={config?.parentDataMapping || []}
linkedFilters={config?.linkedFilters || []}
>
<div className="flex h-full">
{/* 좌측 패널 */}

View File

@ -54,7 +54,6 @@ import { SaveModal } from "./SaveModal";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
interface FileInfo {
@ -106,7 +105,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
@ -577,72 +575,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(true);
try {
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
filter.targetColumn === component.tableName
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
const tableSpecificFilters: Record<string, any> = {};
for (const [key, value] of Object.entries(linkedFilterValues)) {
// key가 "테이블명.컬럼명" 형식인 경우
if (key.includes(".")) {
const [tableName, columnName] = key.split(".");
if (tableName === component.tableName) {
tableSpecificFilters[columnName] = value;
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
}
} else {
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
tableSpecificFilters[key] = value;
}
}
linkedFilterValues = tableSpecificFilters;
}
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
console.log("⚠️ [InteractiveDataTable] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
setData([]);
setTotal(0);
setTotalPages(0);
setCurrentPage(1);
setLoading(false);
return;
}
// 검색 파라미터와 연결 필터 병합
const mergedSearchParams = {
...searchParams,
...linkedFilterValues,
};
console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName,
page,
pageSize,
linkedFilterValues,
mergedSearchParams,
});
console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize });
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
search: mergedSearchParams,
search: searchParams,
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
});
@ -742,7 +680,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false);
}
},
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
[component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가
);
// 현재 사용자 정보 로드

View File

@ -19,7 +19,6 @@ import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -79,7 +78,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth();
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
const userName = externalUserName || authUserName;
@ -118,30 +116,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 🆕 분할 패널에서 매핑된 부모 데이터 가져오기
const splitPanelMappedData = React.useMemo(() => {
if (splitPanelContext) {
return splitPanelContext.getMappedParentData();
}
return {};
}, [splitPanelContext, splitPanelContext?.selectedLeftData]);
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합)
const formData = React.useMemo(() => {
const baseData = externalFormData || localFormData;
// 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만)
if (Object.keys(splitPanelMappedData).length > 0) {
const merged = { ...baseData };
for (const [key, value] of Object.entries(splitPanelMappedData)) {
// 기존 값이 없거나 빈 값인 경우에만 매핑 데이터 적용
if (merged[key] === undefined || merged[key] === null || merged[key] === "") {
merged[key] = value;
}
}
return merged;
}
return baseData;
}, [externalFormData, localFormData, splitPanelMappedData]);
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
const formData = externalFormData || localFormData;
// formData 업데이트 함수
const updateFormData = useCallback(

View File

@ -838,53 +838,18 @@ 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 데이터 소스인 경우
// 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)) {
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
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(
connectionId,
selectedScreen?.restApiEndpoint,
selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경
selectedScreen.restApiConnectionId,
selectedScreen.restApiEndpoint,
selectedScreen.restApiJsonPath || "data",
);
// REST API 응답에서 컬럼 정보 생성
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
tableName: `restapi_${connectionId}`,
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
@ -896,17 +861,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}));
const tableInfo: TableInfo = {
tableName: `restapi_${connectionId}`,
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
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 데이터 소스 로드 완료:", {
@ -4298,8 +4256,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 overflow-hidden">
<div className="border-border flex items-center justify-between border-b px-4 py-3 shrink-0">
<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">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
onClick={() => closePanel("unified")}
@ -4308,7 +4266,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
<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,7 +41,6 @@ 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";
@ -133,18 +132,10 @@ 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);
@ -281,19 +272,11 @@ 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);
@ -315,50 +298,14 @@ 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, updateData);
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
// 선택된 테이블의 라벨 찾기
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
@ -371,11 +318,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
? {
...s,
screenName: editFormData.screenName,
tableName: updateData.tableName,
tableName: editFormData.tableName,
tableLabel: tableLabel,
description: editFormData.description,
isActive: editFormData.isActive,
dataSourceType: editFormData.dataSourceType,
}
: s,
),
@ -1270,184 +1216,65 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
placeholder="화면명을 입력하세요"
/>
</div>
{/* 데이터 소스 타입 선택 */}
<div className="space-y-2">
<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"
<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}
>
<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>
</>
)}
{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>
<div className="space-y-2">
<Label htmlFor="edit-description"></Label>
<Textarea
@ -1478,14 +1305,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
</Button>
<Button
onClick={handleEditSave}
disabled={
!editFormData.screenName.trim() ||
(editFormData.dataSourceType === "database" && !editFormData.tableName.trim()) ||
(editFormData.dataSourceType === "restapi" && !editFormData.restApiConnectionId)
}
>
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim() || !editFormData.tableName.trim()}>
</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,12 +1664,190 @@ 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">
... UI ...
<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>
</div>
)} */}
)}
{/* 운행알림 및 종료 설정 */}
{(component.componentConfig?.action?.type || "save") === "operation_control" && (

View File

@ -52,12 +52,23 @@ export const CategoryValueAddDialog: React.FC<
const [description, setDescription] = useState("");
const [color, setColor] = useState("none");
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
const generateCode = (): string => {
// 항상 CATEGORY_TIMESTAMP_RANDOM 형식으로 고유 코드 생성
const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `CATEGORY_${timestamp}${random}`;
// 라벨에서 코드 자동 생성
const generateCode = (label: string): string => {
// 한글을 영문으로 변환하거나, 영문/숫자만 추출하여 대문자로
const cleaned = label
.replace(/[^a-zA-Z0-9가-힣\s]/g, "") // 특수문자 제거
.trim()
.toUpperCase();
// 영문이 있으면 영문만, 없으면 타임스탬프 기반
const englishOnly = cleaned.replace(/[^A-Z0-9\s]/g, "").replace(/\s+/g, "_");
if (englishOnly.length > 0) {
return englishOnly.substring(0, 20); // 최대 20자
}
// 영문이 없으면 CATEGORY_TIMESTAMP 형식
return `CATEGORY_${Date.now().toString().slice(-6)}`;
};
const handleSubmit = () => {
@ -65,7 +76,7 @@ export const CategoryValueAddDialog: React.FC<
return;
}
const valueCode = generateCode();
const valueCode = generateCode(valueLabel);
onAdd({
tableName: "", // CategoryValueManager에서 오버라이드됨

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-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",
"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",
className,
)}
{...props}

View File

@ -1,660 +0,0 @@
"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

@ -1,531 +0,0 @@
"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

@ -26,15 +26,6 @@ export interface ParentDataMapping {
targetColumn: string; // 우측 화면 저장 시 사용할 컬럼명 (예: equipment_code)
}
/**
*
*
*/
export interface LinkedFilter {
sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code)
targetColumn: string; // 우측 화면 필터링에 사용할 컬럼명 (예: equipment_code)
}
/**
*
*/
@ -82,12 +73,6 @@ interface SplitPanelContextValue {
// 🆕 매핑된 부모 데이터 가져오기 (우측 화면 저장 시 사용)
getMappedParentData: () => Record<string, any>;
// 🆕 연결 필터 설정 (좌측 선택 → 우측 테이블 필터링)
linkedFilters: LinkedFilter[];
// 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용)
getLinkedFilterValues: () => Record<string, any>;
}
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
@ -97,7 +82,6 @@ interface SplitPanelProviderProps {
leftScreenId: number | null;
rightScreenId: number | null;
parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정
linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정
children: React.ReactNode;
}
@ -109,7 +93,6 @@ export function SplitPanelProvider({
leftScreenId,
rightScreenId,
parentDataMapping = [],
linkedFilters = [],
children,
}: SplitPanelProviderProps) {
// 좌측/우측 화면의 데이터 수신자 맵
@ -287,68 +270,26 @@ export function SplitPanelProvider({
/**
* 🆕
*
*
* :
* 1. ( )
* 2. ( )
*/
const getMappedParentData = useCallback((): Record<string, any> => {
if (!selectedLeftData) {
if (!selectedLeftData || parentDataMapping.length === 0) {
return {};
}
const mappedData: Record<string, any> = {};
// 1단계: 좌측 데이터의 모든 컬럼을 자동으로 전달 (동일 컬럼명 자동 매핑)
for (const [key, value] of Object.entries(selectedLeftData)) {
if (value !== undefined && value !== null) {
mappedData[key] = value;
}
}
// 2단계: 명시적 매핑이 있으면 추가 적용 (다른 컬럼명으로 변환)
for (const mapping of parentDataMapping) {
const value = selectedLeftData[mapping.sourceColumn];
if (value !== undefined && value !== null) {
// 소스와 타겟이 다른 경우에만 추가 매핑
if (mapping.sourceColumn !== mapping.targetColumn) {
mappedData[mapping.targetColumn] = value;
logger.debug(`[SplitPanelContext] 명시적 매핑: ${mapping.sourceColumn}${mapping.targetColumn} = ${value}`);
}
mappedData[mapping.targetColumn] = value;
logger.debug(`[SplitPanelContext] 부모 데이터 매핑: ${mapping.sourceColumn}${mapping.targetColumn} = ${value}`);
}
}
logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, {
autoMappedKeys: Object.keys(selectedLeftData),
explicitMappings: parentDataMapping.length,
finalKeys: Object.keys(mappedData),
});
logger.info(`[SplitPanelContext] 매핑된 부모 데이터:`, mappedData);
return mappedData;
}, [selectedLeftData, parentDataMapping]);
/**
* 🆕
*
*/
const getLinkedFilterValues = useCallback((): Record<string, any> => {
if (!selectedLeftData || linkedFilters.length === 0) {
return {};
}
const filterValues: Record<string, any> = {};
for (const filter of linkedFilters) {
const value = selectedLeftData[filter.sourceColumn];
if (value !== undefined && value !== null && value !== "") {
filterValues[filter.targetColumn] = value;
logger.debug(`[SplitPanelContext] 연결 필터: ${filter.sourceColumn}${filter.targetColumn} = ${value}`);
}
}
logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues);
return filterValues;
}, [selectedLeftData, linkedFilters]);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<SplitPanelContextValue>(() => ({
splitPanelId,
@ -369,9 +310,6 @@ export function SplitPanelProvider({
setSelectedLeftData: handleSetSelectedLeftData,
parentDataMapping,
getMappedParentData,
// 🆕 연결 필터 관련
linkedFilters,
getLinkedFilterValues,
}), [
splitPanelId,
leftScreenId,
@ -389,8 +327,6 @@ export function SplitPanelProvider({
handleSetSelectedLeftData,
parentDataMapping,
getMappedParentData,
linkedFilters,
getLinkedFilterValues,
]);
return (

View File

@ -1,368 +0,0 @@
/**
* 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

@ -374,11 +374,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
height: component.size?.height ? `${component.size.height}px` : component.style?.height,
};
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
const useConfigTableName = componentType === "entity-search-input" ||
componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table";
const rendererProps = {
component,
isSelected,
@ -401,8 +396,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
formData,
onFormDataChange,
onChange: handleChange, // 개선된 onChange 핸들러 전달
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName,
tableName,
menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보

View File

@ -693,21 +693,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
});
// 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
// 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴
// (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록)
let splitPanelParentData: Record<string, any> | undefined;
if (splitPanelContext) {
// 우측 화면이거나, 탭 안의 화면(splitPanelPosition이 undefined)인 경우 모두 처리
// 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨)
if (splitPanelPosition !== "left") {
splitPanelParentData = splitPanelContext.getMappedParentData();
if (Object.keys(splitPanelParentData).length > 0) {
console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", {
splitPanelParentData,
splitPanelPosition,
isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안
});
}
if (splitPanelContext && splitPanelPosition === "right") {
splitPanelParentData = splitPanelContext.getMappedParentData();
if (Object.keys(splitPanelParentData).length > 0) {
console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", splitPanelParentData);
}
}

View File

@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { CardDisplayConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
@ -8,9 +8,6 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useModalDataStore } from "@/stores/modalDataStore";
export interface CardDisplayComponentProps extends ComponentRendererProps {
config?: CardDisplayConfig;
@ -41,18 +38,13 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
tableColumns = [],
...props
}) => {
// 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음)
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const splitPanelPosition = screenContext?.splitPanelPosition;
// 테이블 데이터 상태 관리
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
// 선택된 카드 상태
const [selectedCardId, setSelectedCardId] = useState<string | number | null>(null);
// 상세보기 모달 상태
const [viewModalOpen, setViewModalOpen] = useState(false);
@ -207,132 +199,38 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
const displayData = useMemo(() => {
// console.log("📋 CardDisplay: displayData 결정 중", {
// dataSource: componentConfig.dataSource,
// loadedTableDataLength: loadedTableData.length,
// tableDataLength: tableData.length,
// staticDataLength: componentConfig.staticData?.length || 0,
// });
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
if (loadedTableData.length > 0) {
// console.log("📋 CardDisplay: 로드된 테이블 데이터 사용", loadedTableData.slice(0, 2));
return loadedTableData;
}
// props로 전달받은 테이블 데이터가 있으면 사용
if (tableData.length > 0) {
// console.log("📋 CardDisplay: props 테이블 데이터 사용", tableData.slice(0, 2));
return tableData;
}
if (componentConfig.staticData && componentConfig.staticData.length > 0) {
// console.log("📋 CardDisplay: 정적 데이터 사용", componentConfig.staticData.slice(0, 2));
return componentConfig.staticData;
}
// 데이터가 없으면 빈 배열 반환
// console.log("📋 CardDisplay: 표시할 데이터가 없음");
return [];
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
// 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언)
const getCardKey = useCallback((data: any, index: number): string => {
return String(data.id || data.objid || data.ID || index);
}, []);
// 카드 선택 핸들러 (테이블 리스트와 동일한 로직)
const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(cardKey);
} else {
newSelectedRows.delete(cardKey);
}
setSelectedRows(newSelectedRows);
// 선택된 카드 데이터 계산
const selectedRowsData = displayData.filter((item, index) =>
newSelectedRows.has(getCardKey(item, index))
);
// onFormDataChange 호출
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData,
});
}
// modalDataStore에 선택된 데이터 저장
const tableNameToUse = componentConfig.dataSource?.tableName || tableName;
if (tableNameToUse && selectedRowsData.length > 0) {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getCardKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableNameToUse, modalItems);
console.log("✅ [CardDisplay] modalDataStore에 데이터 저장:", {
dataSourceId: tableNameToUse,
count: modalItems.length,
});
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
console.log("🗑️ [CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
if (splitPanelContext && splitPanelPosition === "left") {
if (checked) {
splitPanelContext.setSelectedLeftData(data);
console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 저장:", {
data,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else if (newSelectedRows.size === 0) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 초기화");
}
}
}, [selectedRows, displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
const handleCardClick = useCallback((data: any, index: number) => {
const cardKey = getCardKey(data, index);
const isCurrentlySelected = selectedRows.has(cardKey);
// 선택 토글
handleCardSelection(cardKey, data, !isCurrentlySelected);
if (componentConfig.onCardClick) {
componentConfig.onCardClick(data);
}
}, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]);
// DataProvidable 인터페이스 구현 (테이블 리스트와 동일)
const dataProvider = useMemo(() => ({
componentId: component.id,
componentType: "card-display" as const,
getSelectedData: () => {
const selectedData = displayData.filter((item, index) =>
selectedRows.has(getCardKey(item, index))
);
return selectedData;
},
getAllData: () => {
return displayData;
},
clearSelection: () => {
setSelectedRows(new Set());
},
}), [component.id, displayData, selectedRows, getCardKey]);
// ScreenContext에 데이터 제공자로 등록
useEffect(() => {
if (screenContext && component.id) {
screenContext.registerDataProvider(component.id, dataProvider);
return () => {
screenContext.unregisterDataProvider(component.id);
};
}
}, [screenContext, component.id, dataProvider]);
// 로딩 중인 경우 로딩 표시
if (loading) {
return (
@ -425,6 +323,20 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
onClick?.();
};
const handleCardClick = (data: any) => {
const cardId = data.id || data.objid || data.ID;
// 이미 선택된 카드를 다시 클릭하면 선택 해제
if (selectedCardId === cardId) {
setSelectedCardId(null);
} else {
setSelectedCardId(cardId);
}
if (componentConfig.onCardClick) {
componentConfig.onCardClick(data);
}
};
// DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용)
const safeDomProps = filterDOMProps(props);
@ -513,12 +425,12 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
? getColumnValue(data, componentConfig.columnMapping.imageColumn)
: data.avatar || data.image || "";
const cardKey = getCardKey(data, index);
const isCardSelected = selectedRows.has(cardKey);
const cardId = data.id || data.objid || data.ID || index;
const isCardSelected = selectedCardId === cardId;
return (
<div
key={cardKey}
key={cardId}
style={{
...cardStyle,
borderColor: isCardSelected ? "#000" : "#e5e7eb",
@ -528,7 +440,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
: "0 1px 3px rgba(0, 0, 0, 0.08)",
}}
className="card-hover group cursor-pointer transition-all duration-150"
onClick={() => handleCardClick(data, index)}
onClick={() => handleCardClick(data)}
>
{/* 카드 이미지 */}
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (

View File

@ -1,63 +1,10 @@
# RepeatScreenModal 컴포넌트 v3.1
# RepeatScreenModal 컴포넌트 v3
## 개요
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
## 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 주요 변경사항 (기존)
## v3 주요 변경사항
### 자유 레이아웃 시스템
@ -86,7 +33,29 @@
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
---
### 자유로운 조합
```
예시 1: 헤더 + 집계 + 테이블 (출하계획)
├── [행 1] 헤더: 품목코드, 품목명
├── [행 2] 집계: 총수주잔량, 현재고
└── [행 3] 테이블: 수주별 출하계획
예시 2: 집계만
└── [행 1] 집계: 총매출, 총비용, 순이익
예시 3: 테이블만
└── [행 1] 테이블: 품목 목록
예시 4: 테이블 2개
├── [행 1] 테이블: 입고 내역
└── [행 2] 테이블: 출고 내역
예시 5: 헤더 + 헤더 + 필드
├── [행 1] 헤더: 기본 정보 (읽기전용)
├── [행 2] 헤더: 상세 정보 (읽기전용)
└── [행 3] 필드: 입력 필드 (편집가능)
```
## 설정 방법
@ -138,34 +107,13 @@
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
- **스타일**: 배경색, 폰트 크기
#### 테이블 행 설정 (v3.1 확장)
#### 테이블 행 설정
- **테이블 제목**: 선택사항
- **헤더 표시**: 테이블 헤더 표시 여부
- **외부 테이블 데이터 소스**: (v3.1 신규)
- 소스 테이블: 조회할 외부 테이블
- 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키
- 정렬: 정렬 컬럼 및 방향
- **CRUD 설정**: (v3.1 신규)
- 추가: 새 행 추가 허용
- 수정: 행 수정 허용
- 삭제: 행 삭제 허용 (확인 팝업 옵션)
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
### 5. Footer 탭 (v3.1 신규)
- **Footer 사용**: Footer 영역 활성화
- **위치**: 컨텐츠 아래 / 하단 고정 (sticky)
- **정렬**: 왼쪽 / 가운데 / 오른쪽
- **버튼 설정**:
- 라벨: 버튼 텍스트
- 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀
- 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트
- 아이콘: 저장 / X / 초기화 / 없음
---
## 데이터 흐름
```
@ -177,22 +125,16 @@
4. 각 그룹에 대해 집계값 계산
5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1)
5. 카드 렌더링 (contentRows 기반)
6. 카드 렌더링 (contentRows 기반)
6. 사용자 편집
7. 사용자 편집 (CRUD 포함)
8. Footer 버튼 또는 기본 저장 버튼으로 저장
9. 기본 데이터 + 외부 테이블 데이터 일괄 저장
7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장
```
---
## 사용 예시
### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD)
### 출하계획 등록
```typescript
{
@ -225,185 +167,40 @@
type: "aggregation",
aggregationLayout: "horizontal",
aggregationFields: [
{ sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
{ sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" }
{ aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
{ aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" }
]
},
{
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: "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 }
{ 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 }
}
]
}
],
// 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,9 +23,6 @@ export interface RepeatScreenModalProps {
// === 🆕 v3: 자유 레이아웃 ===
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
// === 🆕 v3.1: Footer 버튼 설정 ===
footerConfig?: FooterConfig; // Footer 영역 설정
// === (레거시 호환) ===
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
@ -36,34 +33,6 @@ 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>; // 커스텀 설정
};
}
/**
*
*/
@ -110,206 +79,28 @@ 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 {
// 값 소스 타입
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}")
// 표시 설정
aggregationResultField: string; // 그룹핑 설정의 resultField 참조
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 {
// === 집계 소스 타입 ===
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})")
// === 공통 ===
sourceField: string; // 원본 필드 (예: "balance_qty")
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
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")
};
}
/**
@ -329,7 +120,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%")
@ -337,10 +128,6 @@ export interface TableColumnConfig {
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];

View File

@ -15,7 +15,7 @@ import { getTableColumns } from "@/lib/api/tableManagement";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { ParentDataMapping, LinkedFilter } from "@/contexts/SplitPanelContext";
import type { ParentDataMapping } from "@/contexts/SplitPanelContext";
interface ScreenSplitPanelConfigPanelProps {
config: any;
@ -33,15 +33,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
// 좌측 화면의 테이블 컬럼 목록
const [leftScreenColumns, setLeftScreenColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [isLoadingLeftColumns, setIsLoadingLeftColumns] = useState(false);
// 우측 화면의 테이블 컬럼 목록 (테이블별로 그룹화)
const [rightScreenTables, setRightScreenTables] = useState<Array<{
tableName: string;
screenName: string;
columns: Array<{ columnName: string; columnLabel: string }>
}>>([]);
const [isLoadingRightColumns, setIsLoadingRightColumns] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [localConfig, setLocalConfig] = useState({
screenId: config.screenId || 0,
@ -52,7 +44,6 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[],
linkedFilters: config.linkedFilters || [] as LinkedFilter[],
...config,
});
@ -68,7 +59,6 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [],
linkedFilters: config.linkedFilters || [],
...config,
});
}, [config]);
@ -82,7 +72,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
}
try {
setIsLoadingLeftColumns(true);
setIsLoadingColumns(true);
// 좌측 화면 정보 조회
const screenData = await screenApi.getScreen(localConfig.leftScreenId);
@ -106,126 +96,13 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
console.error("좌측 화면 컬럼 로드 실패:", error);
setLeftScreenColumns([]);
} finally {
setIsLoadingLeftColumns(false);
setIsLoadingColumns(false);
}
};
loadLeftScreenColumns();
}, [localConfig.leftScreenId]);
// 우측 화면이 변경되면 해당 화면 및 임베드된 화면들의 테이블 컬럼 로드
useEffect(() => {
const loadRightScreenColumns = async () => {
if (!localConfig.rightScreenId) {
setRightScreenTables([]);
return;
}
try {
setIsLoadingRightColumns(true);
const tables: Array<{ tableName: string; screenName: string; columns: Array<{ columnName: string; columnLabel: string }> }> = [];
// 우측 화면 정보 조회
const screenData = await screenApi.getScreen(localConfig.rightScreenId);
// 1. 메인 화면의 테이블 (있는 경우)
if (screenData?.tableName) {
const columnsResponse = await getTableColumns(screenData.tableName);
if (columnsResponse.success && columnsResponse.data?.columns) {
tables.push({
tableName: screenData.tableName,
screenName: screenData.screenName || "메인 화면",
columns: columnsResponse.data.columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
})),
});
}
}
// 2. 레이아웃에서 임베드된 화면들의 테이블 찾기 (탭, 분할 패널 등)
const layoutData = await screenApi.getLayout(localConfig.rightScreenId);
const components = layoutData?.components || [];
if (components.length > 0) {
const embeddedScreenIds = new Set<number>();
// 컴포넌트에서 임베드된 화면 ID 수집
const findEmbeddedScreens = (comps: any[]) => {
for (const comp of comps) {
const config = comp.componentConfig || {};
// TabsWidget의 탭들
if (comp.componentType === "tabs-widget" && config.tabs) {
for (const tab of config.tabs) {
if (tab.screenId) {
embeddedScreenIds.add(tab.screenId);
console.log("🔍 탭에서 화면 발견:", tab.screenId, tab.screenName);
}
}
}
// ScreenSplitPanel
if (comp.componentType === "screen-split-panel") {
if (config.leftScreenId) embeddedScreenIds.add(config.leftScreenId);
if (config.rightScreenId) embeddedScreenIds.add(config.rightScreenId);
}
// EmbeddedScreen
if (comp.componentType === "embedded-screen" && config.screenId) {
embeddedScreenIds.add(config.screenId);
}
// 중첩된 컴포넌트 검색
if (comp.children) {
findEmbeddedScreens(comp.children);
}
}
};
findEmbeddedScreens(components);
console.log("📋 발견된 임베드 화면 ID:", Array.from(embeddedScreenIds));
// 임베드된 화면들의 테이블 컬럼 로드
for (const embeddedScreenId of embeddedScreenIds) {
try {
const embeddedScreen = await screenApi.getScreen(embeddedScreenId);
if (embeddedScreen?.tableName) {
// 이미 추가된 테이블인지 확인
if (!tables.find(t => t.tableName === embeddedScreen.tableName)) {
const columnsResponse = await getTableColumns(embeddedScreen.tableName);
if (columnsResponse.success && columnsResponse.data?.columns) {
tables.push({
tableName: embeddedScreen.tableName,
screenName: embeddedScreen.screenName || `화면 ${embeddedScreenId}`,
columns: columnsResponse.data.columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
})),
});
console.log("✅ 테이블 추가:", embeddedScreen.tableName);
}
}
}
} catch (err) {
console.warn(`임베드된 화면 ${embeddedScreenId} 로드 실패:`, err);
}
}
}
setRightScreenTables(tables);
console.log("📋 우측 화면 테이블 로드 완료:", tables.map(t => t.tableName));
} catch (error) {
console.error("우측 화면 컬럼 로드 실패:", error);
setRightScreenTables([]);
} finally {
setIsLoadingRightColumns(false);
}
};
loadRightScreenColumns();
}, [localConfig.rightScreenId]);
// 화면 목록 로드
useEffect(() => {
const loadScreens = async () => {
@ -291,51 +168,21 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
updateConfig("parentDataMapping", newMappings);
};
// 연결 필터 추가
const addLinkedFilter = () => {
const newFilter: LinkedFilter = {
sourceColumn: "",
targetColumn: "",
};
const newFilters = [...(localConfig.linkedFilters || []), newFilter];
updateConfig("linkedFilters", newFilters);
};
// 연결 필터 수정
const updateLinkedFilter = (index: number, field: keyof LinkedFilter, value: string) => {
const newFilters = [...(localConfig.linkedFilters || [])];
newFilters[index] = {
...newFilters[index],
[field]: value,
};
updateConfig("linkedFilters", newFilters);
};
// 연결 필터 삭제
const removeLinkedFilter = (index: number) => {
const newFilters = (localConfig.linkedFilters || []).filter((_: any, i: number) => i !== index);
updateConfig("linkedFilters", newFilters);
};
return (
<div className="space-y-4">
<Tabs defaultValue="layout" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="layout" className="gap-1 text-xs">
<Layout className="h-3 w-3" />
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="layout" className="gap-2">
<Layout className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="screens" className="gap-1 text-xs">
<Database className="h-3 w-3" />
<TabsTrigger value="screens" className="gap-2">
<Database className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="linkedFilter" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="dataMapping" className="gap-1 text-xs">
<ArrowRight className="h-3 w-3" />
<TabsTrigger value="dataMapping" className="gap-2">
<Link2 className="h-4 w-4" />
</TabsTrigger>
</TabsList>
@ -538,141 +385,6 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</Card>
</TabsContent>
{/* 연결 필터 탭 */}
<TabsContent value="linkedFilter" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription className="text-xs">
, .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!localConfig.leftScreenId || !localConfig.rightScreenId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
"화면" / .
</p>
</div>
) : isLoadingLeftColumns || isLoadingRightColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
{leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "}
{rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}
</p>
</div>
) : (
<>
{/* 연결 필터 설명 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs text-blue-800 dark:text-blue-200">
: 좌측에서 .
<br />
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">equipment_code</code>
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">equipment_code</code>
</p>
</div>
{/* 필터 목록 */}
<div className="space-y-3">
{(localConfig.linkedFilters || []).map((filter: LinkedFilter, index: number) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeLinkedFilter(index)}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={filter.sourceColumn}
onValueChange={(value) => updateLinkedFilter(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 rotate-90 text-gray-400" />
</div>
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={filter.targetColumn}
onValueChange={(value) => updateLinkedFilter(index, "targetColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블.컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightScreenTables.map((table) => (
<React.Fragment key={table.tableName}>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-gray-800">
{table.screenName} ({table.tableName})
</div>
{table.columns.map((col) => (
<SelectItem
key={`${table.tableName}.${col.columnName}`}
value={`${table.tableName}.${col.columnName}`}
className="text-xs"
>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
{/* 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={addLinkedFilter}
className="w-full text-xs"
>
<Plus className="mr-2 h-3 w-3" />
</Button>
{/* 현재 설정 표시 */}
<Separator />
<div className="text-xs text-muted-foreground">
{(localConfig.linkedFilters || []).length > 0
? `${localConfig.linkedFilters.length}개 필터 설정됨`
: "필터 없음 - 우측 화면에 모든 데이터가 표시됩니다"}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
{/* 데이터 전달 탭 */}
<TabsContent value="dataMapping" className="space-y-4">
<Card>
@ -683,105 +395,69 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!localConfig.leftScreenId || !localConfig.rightScreenId ? (
{!localConfig.leftScreenId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
"화면 설정" / .
"화면 설정" .
</p>
</div>
) : isLoadingLeftColumns || isLoadingRightColumns ? (
) : isLoadingColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (
) : leftScreenColumns.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
{leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "}
{rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}
.
</p>
</div>
) : (
<>
{/* 우측 화면 테이블 목록 표시 */}
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950">
<p className="text-xs font-medium text-green-800 dark:text-green-200 mb-1">
({rightScreenTables.length}):
</p>
<ul className="text-xs text-green-700 dark:text-green-300 space-y-0.5">
{rightScreenTables.map((table) => (
<li key={table.tableName}> {table.screenName}: <code className="bg-green-100 dark:bg-green-900 px-1 rounded">{table.tableName}</code></li>
))}
</ul>
</div>
{/* 매핑 목록 */}
<div className="space-y-3">
{(localConfig.parentDataMapping || []).map((mapping: ParentDataMapping, index: number) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeParentDataMapping(index)}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => updateParentDataMapping(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 rotate-90 text-gray-400" />
</div>
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={mapping.targetColumn}
onValueChange={(value) => updateParentDataMapping(index, "targetColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블.컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightScreenTables.map((table) => (
<React.Fragment key={table.tableName}>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-gray-800">
{table.screenName} ({table.tableName})
</div>
{table.columns.map((col) => (
<SelectItem
key={`${table.tableName}.${col.columnName}`}
value={col.columnName}
className="text-xs pl-4"
>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
<div key={index} className="flex items-center gap-2 rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-xs text-gray-600"> ()</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => updateParentDataMapping(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="mt-5 h-4 w-4 text-gray-400" />
<div className="flex-1">
<Label className="text-xs text-gray-600"> ( )</Label>
<Input
value={mapping.targetColumn}
onChange={(e) => updateParentDataMapping(index, "targetColumn", e.target.value)}
placeholder="저장할 컬럼명"
className="h-8 text-xs"
/>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeParentDataMapping(index)}
className="h-8 w-8 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
@ -797,23 +473,23 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</Button>
{/* 자동 매핑 안내 */}
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950">
<p className="text-xs text-green-800 dark:text-green-200">
<strong> :</strong> .
<br />
(: equipment_code) .
</p>
</div>
{/* 수동 매핑 안내 */}
{/* 안내 메시지 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs text-blue-800 dark:text-blue-200">
<strong> ():</strong>
<strong> :</strong>
<br />
.
좌측: 설비 (equipment_mng)
<br />
: 좌측 <code className="bg-blue-100 px-1 rounded">user_id</code> <code className="bg-blue-100 px-1 rounded">created_by</code>
우측: 점검항목
<br />
<br />
:
<br />
- 소스: equipment_code 타겟: equipment_code
<br />
<br />
,
equipment_code가 .
</p>
</div>
</>

View File

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

View File

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

View File

@ -1075,113 +1075,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const sortBy = sortColumn || undefined;
const sortOrder = sortDirection;
const search = searchTerm || undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", {
hasSplitPanelContext: !!splitPanelContext,
tableName: tableConfig.selectedTable,
selectedLeftData: splitPanelContext?.selectedLeftData,
linkedFilters: splitPanelContext?.linkedFilters,
});
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") ||
filter.targetColumn === tableConfig.selectedTable
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters);
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
for (const [key, value] of Object.entries(allLinkedFilters)) {
if (key.includes(".")) {
const [tableName, columnName] = key.split(".");
if (tableName === tableConfig.selectedTable) {
linkedFilterValues[columnName] = value;
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
}
} else {
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
linkedFilterValues[key] = value;
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
}
}
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 검색 필터와 연결 필터 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
};
const hasFilters = Object.keys(filters).length > 0;
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
// 🆕 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,
}));
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,
@ -1192,12 +1095,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// });
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: hasFilters ? filters : undefined,
search: filters,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
@ -1241,7 +1144,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
totalItems: response.total || 0,
}
);
}
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
@ -1262,7 +1164,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchTerm,
searchValues,
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회
]);
const fetchTableDataDebounced = useCallback(
@ -2226,7 +2127,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]);

View File

@ -5,7 +5,6 @@ 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";
/**
@ -25,7 +24,7 @@ export type ButtonActionType =
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
| "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경)
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
@ -164,10 +163,6 @@ export interface ButtonActionConfig {
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
// 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용)
emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true)
emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
@ -358,8 +353,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);
@ -564,7 +559,8 @@ export class ButtonActionExecutor {
// });
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
console.log("🔍 채번 규칙 할당 체크 시작");
// console.log("🔍 채번 규칙 할당 체크 시작");
// console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
const fieldsWithNumbering: Record<string, string> = {};
@ -573,39 +569,23 @@ export class ButtonActionExecutor {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumbering[fieldName] = value as string;
console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
// console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
}
}
console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
// 사용자 입력 값 유지 (재할당하지 않음)
// 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로
// 저장 시점에는 사용자가 수정한 값을 그대로 사용
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`${fieldName} 새 코드 할당: ${formData[fieldName]}${newCode}`);
formData[fieldName] = newCode;
} else {
console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error);
}
} catch (allocateError) {
console.error(`${fieldName} 코드 할당 오류:`, allocateError);
// 오류 시 기존 값 유지
}
}
console.log(" 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering));
console.log(" 사용자 입력 값 유지 (재할당 하지 않음)");
}
console.log("✅ 채번 규칙 할당 완료");
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
// console.log("✅ 채번 규칙 할당 완료");
// console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
const splitPanelData = context.splitPanelParentData || {};
@ -689,122 +669,11 @@ export class ButtonActionExecutor {
}
}
// 🆕 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);
}
}
}
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
data: dataWithUserInfo,
});
}
if (!saveResult.success) {
@ -1341,7 +1210,6 @@ export class ButtonActionExecutor {
// 🆕 선택된 행 데이터 수집
const selectedData = context.selectedRowsData || [];
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
@ -1353,8 +1221,6 @@ export class ButtonActionExecutor {
// 🆕 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
splitPanelParentData: context.splitPanelParentData || {},
},
});
@ -3645,7 +3511,6 @@ export class ButtonActionExecutor {
/**
* ( )
* + vehicles latitude/longitude도
*/
private static async saveLocationToHistory(
tripId: string | null,
@ -3662,15 +3527,13 @@ export class ButtonActionExecutor {
try {
const { apiClient } = await import("@/lib/api/client");
const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords;
const locationData = {
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
altitude: position.coords.altitude,
speed: position.coords.speed,
heading: position.coords.heading,
tripId,
tripStatus,
departure,
@ -3683,7 +3546,6 @@ 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) {
@ -3692,41 +3554,6 @@ 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);
@ -3856,18 +3683,13 @@ 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) {
@ -3896,7 +3718,7 @@ export class ButtonActionExecutor {
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
const { latitude, longitude, accuracy } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
@ -3965,15 +3787,8 @@ 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("위치 정보 저장에 실패했습니다.");
@ -3990,7 +3805,7 @@ export class ButtonActionExecutor {
return true;
} catch (error: any) {
console.error("❌ 공차등록 실패:", error);
console.error("❌ 위치정보 가져오기 실패:", error);
toast.dismiss();
// GeolocationPositionError 처리
@ -4016,122 +3831,6 @@ 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;
}
/**
* (: 출발지 )
*/
@ -4196,13 +3895,7 @@ export class ButtonActionExecutor {
*/
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
if (this.emptyVehicleWatchId !== null) {
this.stopEmptyVehicleTracking();
console.log("🛑 공차 추적 종료 후 운행 시작");
}
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
// 🆕 연속 위치 추적 모드 처리
if (config.updateWithTracking) {

View File

@ -33,28 +33,6 @@ 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_")
}
// ============================================
// 플로우 정의
// ============================================
@ -63,17 +41,6 @@ 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;
@ -85,17 +52,6 @@ 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;