Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
cebba2cdc9
|
|
@ -72,6 +72,8 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -237,7 +239,9 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||||
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
|
||||||
|
|
@ -708,6 +708,12 @@ export class DashboardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||||
|
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
||||||
|
if (isKmaApi) {
|
||||||
|
requestConfig.responseType = 'arraybuffer';
|
||||||
|
}
|
||||||
|
|
||||||
const response = await axios(requestConfig);
|
const response = await axios(requestConfig);
|
||||||
|
|
||||||
if (response.status >= 400) {
|
if (response.status >= 400) {
|
||||||
|
|
@ -719,8 +725,24 @@ export class DashboardController {
|
||||||
let data = response.data;
|
let data = response.data;
|
||||||
const contentType = response.headers["content-type"];
|
const contentType = response.headers["content-type"];
|
||||||
|
|
||||||
|
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||||
|
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||||
|
const iconv = require('iconv-lite');
|
||||||
|
const buffer = Buffer.from(data);
|
||||||
|
const utf8Text = buffer.toString('utf-8');
|
||||||
|
|
||||||
|
// UTF-8로 정상 디코딩되었는지 확인
|
||||||
|
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||||
|
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||||
|
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
||||||
|
} else {
|
||||||
|
// EUC-KR로 디코딩
|
||||||
|
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
||||||
|
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
||||||
|
}
|
||||||
|
}
|
||||||
// 텍스트 응답인 경우 포맷팅
|
// 텍스트 응답인 경우 포맷팅
|
||||||
if (typeof data === "string") {
|
else if (typeof data === "string") {
|
||||||
data = { text: data, contentType };
|
data = { text: data, contentType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1428,10 +1428,51 @@ export async function deleteMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
||||||
|
const menuObjid = Number(menuId);
|
||||||
|
|
||||||
|
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 삭제
|
// Raw Query를 사용한 메뉴 삭제
|
||||||
const [deletedMenu] = await query<any>(
|
const [deletedMenu] = await query<any>(
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
[Number(menuId)]
|
[menuObjid]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||||
|
|
|
||||||
|
|
@ -384,4 +384,69 @@ export class AuthController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/signup
|
||||||
|
* 공차중계 회원가입 API
|
||||||
|
*/
|
||||||
|
static async signup(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||||
|
|
||||||
|
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
||||||
|
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
||||||
|
|
||||||
|
// 입력값 검증
|
||||||
|
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 입력값이 누락되었습니다.",
|
||||||
|
error: {
|
||||||
|
code: "INVALID_INPUT",
|
||||||
|
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회원가입 처리
|
||||||
|
const signupResult = await AuthService.signupDriver({
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
licenseNumber,
|
||||||
|
vehicleNumber,
|
||||||
|
vehicleType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signupResult.success) {
|
||||||
|
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "회원가입이 완료되었습니다.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SIGNUP_FAILED",
|
||||||
|
details: signupResult.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("공차중계 회원가입 API 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SIGNUP_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
// 공차중계 운전자 컨트롤러
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export class DriverController {
|
||||||
|
/**
|
||||||
|
* GET /api/driver/profile
|
||||||
|
* 운전자 프로필 조회
|
||||||
|
*/
|
||||||
|
static async getProfile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정보 조회
|
||||||
|
const userResult = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name
|
||||||
|
FROM user_info
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult[0];
|
||||||
|
|
||||||
|
// 공차중계 사용자가 아닌 경우
|
||||||
|
if (user.signup_type !== "DRIVER") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차량 정보 조회
|
||||||
|
const vehicleResult = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
vehicle_number, vehicle_type, driver_name, driver_phone, status
|
||||||
|
FROM vehicles
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null;
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userId: user.user_id,
|
||||||
|
userName: user.user_name,
|
||||||
|
phoneNumber: user.cell_phone,
|
||||||
|
licenseNumber: user.license_number,
|
||||||
|
vehicleNumber: user.vehicle_number,
|
||||||
|
vehicleType: vehicle?.vehicle_type || null,
|
||||||
|
vehicleStatus: vehicle?.status || null,
|
||||||
|
branchName: user.branch_name || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("운전자 프로필 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "프로필 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/driver/profile
|
||||||
|
* 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종)
|
||||||
|
*/
|
||||||
|
static async updateProfile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body;
|
||||||
|
|
||||||
|
// 공차중계 사용자 확인
|
||||||
|
const userCheck = await query<any>(
|
||||||
|
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userCheck.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userCheck[0].signup_type !== "DRIVER") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldVehicleNumber = userCheck[0].vehicle_number;
|
||||||
|
|
||||||
|
// 차량번호 변경 시 중복 확인
|
||||||
|
if (vehicleNumber && vehicleNumber !== oldVehicleNumber) {
|
||||||
|
const duplicateCheck = await query<any>(
|
||||||
|
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`,
|
||||||
|
[vehicleNumber, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateCheck.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 차량번호입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_info 업데이트
|
||||||
|
await query(
|
||||||
|
`UPDATE user_info SET
|
||||||
|
user_name = COALESCE($1, user_name),
|
||||||
|
cell_phone = COALESCE($2, cell_phone),
|
||||||
|
license_number = COALESCE($3, license_number),
|
||||||
|
vehicle_number = COALESCE($4, vehicle_number),
|
||||||
|
branch_name = COALESCE($5, branch_name)
|
||||||
|
WHERE user_id = $6`,
|
||||||
|
[userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// vehicles 테이블 업데이트
|
||||||
|
await query(
|
||||||
|
`UPDATE vehicles SET
|
||||||
|
vehicle_number = COALESCE($1, vehicle_number),
|
||||||
|
vehicle_type = COALESCE($2, vehicle_type),
|
||||||
|
driver_name = COALESCE($3, driver_name),
|
||||||
|
driver_phone = COALESCE($4, driver_phone),
|
||||||
|
branch_name = COALESCE($5, branch_name),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $6`,
|
||||||
|
[vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`운전자 프로필 수정 완료: ${userId}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "프로필이 수정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("운전자 프로필 수정 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "프로필 수정 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/driver/status
|
||||||
|
* 차량 상태 변경 (대기/정비만 가능)
|
||||||
|
*/
|
||||||
|
static async updateStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
// 허용된 상태값만 (대기: off, 정비: maintenance)
|
||||||
|
const allowedStatuses = ["off", "maintenance"];
|
||||||
|
if (!status || !allowedStatuses.includes(status)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차중계 사용자 확인
|
||||||
|
const userCheck = await query<any>(
|
||||||
|
`SELECT signup_type FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vehicles 테이블 상태 업데이트
|
||||||
|
const updateResult = await query(
|
||||||
|
`UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`,
|
||||||
|
[status, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`차량 상태 변경: ${userId} -> ${status}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("차량 상태 변경 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상태 변경 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/driver/vehicle
|
||||||
|
* 차량 삭제 (user_id = NULL 처리, 기록 보존)
|
||||||
|
*/
|
||||||
|
static async deleteVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차중계 사용자 확인
|
||||||
|
const userCheck = await query<any>(
|
||||||
|
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존)
|
||||||
|
await query(
|
||||||
|
`UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// user_info에서 vehicle_number를 NULL로 변경
|
||||||
|
await query(
|
||||||
|
`UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`차량 삭제 완료 (기록 보존): ${userId}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "차량이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("차량 삭제 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "차량 삭제 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/driver/vehicle
|
||||||
|
* 새 차량 등록
|
||||||
|
*/
|
||||||
|
static async registerVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vehicleNumber, vehicleType, branchName } = req.body;
|
||||||
|
|
||||||
|
if (!vehicleNumber) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "차량번호는 필수입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차중계 사용자 확인
|
||||||
|
const userCheck = await query<any>(
|
||||||
|
`SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공차중계 사용자만 접근할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 차량이 있는지 확인
|
||||||
|
if (userCheck[0].vehicle_number) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차량번호 중복 확인
|
||||||
|
const duplicateCheck = await query<any>(
|
||||||
|
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`,
|
||||||
|
[vehicleNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateCheck.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 차량번호입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName = userCheck[0].user_name;
|
||||||
|
const userPhone = userCheck[0].cell_phone;
|
||||||
|
// 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용)
|
||||||
|
const userCompanyCode = companyCode || userCheck[0].company_code;
|
||||||
|
|
||||||
|
// vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`,
|
||||||
|
[vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// user_info에 vehicle_number 업데이트
|
||||||
|
await query(
|
||||||
|
`UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`,
|
||||||
|
[vehicleNumber, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "차량이 등록되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("차량 등록 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "차량 등록 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/driver/account
|
||||||
|
* 회원 탈퇴 (차량 정보 포함 삭제)
|
||||||
|
*/
|
||||||
|
static async deleteAccount(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차중계 사용자 확인
|
||||||
|
const userCheck = await query<any>(
|
||||||
|
`SELECT signup_type FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userCheck.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userCheck[0].signup_type !== "DRIVER") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공차중계 사용자만 탈퇴할 수 있습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vehicles 테이블에서 삭제
|
||||||
|
await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]);
|
||||||
|
|
||||||
|
// user_info 테이블에서 삭제
|
||||||
|
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
|
||||||
|
|
||||||
|
logger.info(`회원 탈퇴 완료: ${userId}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "회원 탈퇴가 완료되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("회원 탈퇴 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회원 탈퇴 처리 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
// 검색 필드 파싱
|
// 검색 필드 파싱
|
||||||
const fields = searchFields
|
const requestedFields = searchFields
|
||||||
? (searchFields as string).split(",").map((f) => f.trim())
|
? (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 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
||||||
// 멀티테넌시 필터링
|
// 멀티테넌시 필터링
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
||||||
params.push(companyCode);
|
if (existingColumns.has("company_code")) {
|
||||||
paramIndex++;
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건
|
// 검색 조건
|
||||||
if (searchText && fields.length > 0) {
|
if (searchText) {
|
||||||
const searchConditions = fields.map((field) => {
|
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
||||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
let searchableFields = fields;
|
||||||
paramIndex++;
|
if (searchableFields.length === 0) {
|
||||||
return condition;
|
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
|
||||||
});
|
const defaultSearchColumns = [
|
||||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
'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 ")})`);
|
||||||
|
|
||||||
// 검색어 파라미터 추가
|
// 검색어 파라미터 추가
|
||||||
fields.forEach(() => {
|
searchableFields.forEach(() => {
|
||||||
params.push(`%${searchText}%`);
|
params.push(`%${searchText}%`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추가 필터 조건
|
// 추가 필터 조건 (존재하는 컬럼만)
|
||||||
const additionalFilter = JSON.parse(filterCondition as string);
|
const additionalFilter = JSON.parse(filterCondition as string);
|
||||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||||
whereConditions.push(`${key} = $${paramIndex}`);
|
if (existingColumns.has(key)) {
|
||||||
params.push(value);
|
whereConditions.push(`${key} = $${paramIndex}`);
|
||||||
paramIndex++;
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
} else {
|
||||||
|
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이징
|
// 페이징
|
||||||
|
|
@ -78,8 +125,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// 쿼리 실행
|
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||||
const pool = getPool();
|
|
||||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT * FROM ${tableName} ${whereClause}
|
SELECT * FROM ${tableName} ${whereClause}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,17 @@ export class FlowController {
|
||||||
*/
|
*/
|
||||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
const {
|
||||||
req.body;
|
name,
|
||||||
|
description,
|
||||||
|
tableName,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
// REST API 관련 필드
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
} = req.body;
|
||||||
const userId = (req as any).user?.userId || "system";
|
const userId = (req as any).user?.userId || "system";
|
||||||
const userCompanyCode = (req as any).user?.companyCode;
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
|
@ -43,6 +52,9 @@ export class FlowController {
|
||||||
tableName,
|
tableName,
|
||||||
dbSourceType,
|
dbSourceType,
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
userCompanyCode,
|
userCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,8 +66,12 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 이름이 제공된 경우에만 존재 확인
|
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
||||||
if (tableName) {
|
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
|
||||||
|
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
|
||||||
|
|
||||||
|
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
|
||||||
|
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
|
||||||
const tableExists =
|
const tableExists =
|
||||||
await this.flowDefinitionService.checkTableExists(tableName);
|
await this.flowDefinitionService.checkTableExists(tableName);
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
|
|
@ -68,7 +84,17 @@ export class FlowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowDef = await this.flowDefinitionService.create(
|
const flowDef = await this.flowDefinitionService.create(
|
||||||
{ name, description, tableName, dbSourceType, dbConnectionId },
|
{
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tableName,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||||
|
},
|
||||||
userId,
|
userId,
|
||||||
userCompanyCode
|
userCompanyCode
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -148,11 +148,42 @@ export const updateScreenInfo = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode } = req.user as any;
|
const { companyCode } = req.user as any;
|
||||||
const { screenName, tableName, description, isActive } = req.body;
|
const {
|
||||||
|
screenName,
|
||||||
|
tableName,
|
||||||
|
description,
|
||||||
|
isActive,
|
||||||
|
// REST API 관련 필드 추가
|
||||||
|
dataSourceType,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
console.log("화면 정보 수정 요청:", {
|
||||||
|
screenId: id,
|
||||||
|
dataSourceType,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
});
|
||||||
|
|
||||||
await screenManagementService.updateScreenInfo(
|
await screenManagementService.updateScreenInfo(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
{ screenName, tableName, description, isActive },
|
{
|
||||||
|
screenName,
|
||||||
|
tableName,
|
||||||
|
description,
|
||||||
|
isActive,
|
||||||
|
dataSourceType,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
},
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||||
|
|
@ -294,6 +325,53 @@ export const getDeletedScreens = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
|
export const bulkDeleteScreens = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { screenIds, deleteReason, force } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(screenIds) || screenIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "삭제할 화면 ID 목록이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await screenManagementService.bulkDeleteScreens(
|
||||||
|
screenIds,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
deleteReason,
|
||||||
|
force || false
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
||||||
|
if (result.skippedCount > 0) {
|
||||||
|
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
result: {
|
||||||
|
deletedCount: result.deletedCount,
|
||||||
|
skippedCount: result.skippedCount,
|
||||||
|
errors: result.errors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("활성 화면 일괄 삭제 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "일괄 삭제에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 휴지통 화면 일괄 영구 삭제
|
// 휴지통 화면 일괄 영구 삭제
|
||||||
export const bulkPermanentDeleteScreens = async (
|
export const bulkPermanentDeleteScreens = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -870,6 +870,17 @@ export async function addTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
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);
|
await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 리포트 컨트롤러
|
||||||
|
*/
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
|
import { vehicleReportService } from "../services/vehicleReportService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 통계 조회
|
||||||
|
* GET /api/vehicle/reports/daily
|
||||||
|
*/
|
||||||
|
export const getDailyReport = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { startDate, endDate, userId, vehicleId } = req.query;
|
||||||
|
|
||||||
|
console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate });
|
||||||
|
|
||||||
|
const result = await vehicleReportService.getDailyReport(companyCode, {
|
||||||
|
startDate: startDate as string,
|
||||||
|
endDate: endDate as string,
|
||||||
|
userId: userId as string,
|
||||||
|
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getDailyReport] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "일별 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주별 통계 조회
|
||||||
|
* GET /api/vehicle/reports/weekly
|
||||||
|
*/
|
||||||
|
export const getWeeklyReport = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { year, month, userId, vehicleId } = req.query;
|
||||||
|
|
||||||
|
console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month });
|
||||||
|
|
||||||
|
const result = await vehicleReportService.getWeeklyReport(companyCode, {
|
||||||
|
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||||
|
month: month ? parseInt(month as string) : new Date().getMonth() + 1,
|
||||||
|
userId: userId as string,
|
||||||
|
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getWeeklyReport] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "주별 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
* GET /api/vehicle/reports/monthly
|
||||||
|
*/
|
||||||
|
export const getMonthlyReport = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { year, userId, vehicleId } = req.query;
|
||||||
|
|
||||||
|
console.log("📊 [getMonthlyReport] 요청:", { companyCode, year });
|
||||||
|
|
||||||
|
const result = await vehicleReportService.getMonthlyReport(companyCode, {
|
||||||
|
year: year ? parseInt(year as string) : new Date().getFullYear(),
|
||||||
|
userId: userId as string,
|
||||||
|
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getMonthlyReport] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "월별 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약 통계 조회 (대시보드용)
|
||||||
|
* GET /api/vehicle/reports/summary
|
||||||
|
*/
|
||||||
|
export const getSummaryReport = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { period } = req.query; // today, week, month, year
|
||||||
|
|
||||||
|
console.log("📊 [getSummaryReport] 요청:", { companyCode, period });
|
||||||
|
|
||||||
|
const result = await vehicleReportService.getSummaryReport(
|
||||||
|
companyCode,
|
||||||
|
(period as string) || "today"
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getSummaryReport] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "요약 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운전자별 통계 조회
|
||||||
|
* GET /api/vehicle/reports/by-driver
|
||||||
|
*/
|
||||||
|
export const getDriverReport = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { startDate, endDate, limit } = req.query;
|
||||||
|
|
||||||
|
console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate });
|
||||||
|
|
||||||
|
const result = await vehicleReportService.getDriverReport(companyCode, {
|
||||||
|
startDate: startDate as string,
|
||||||
|
endDate: endDate as string,
|
||||||
|
limit: limit ? parseInt(limit as string) : 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getDriverReport] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "운전자별 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구간별 통계 조회
|
||||||
|
* GET /api/vehicle/reports/by-route
|
||||||
|
*/
|
||||||
|
export const getRouteReport = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { startDate, endDate, limit } = req.query;
|
||||||
|
|
||||||
|
console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate });
|
||||||
|
|
||||||
|
const result = await vehicleReportService.getRouteReport(companyCode, {
|
||||||
|
startDate: startDate as string,
|
||||||
|
endDate: endDate as string,
|
||||||
|
limit: limit ? parseInt(limit as string) : 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getRouteReport] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "구간별 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 이력 컨트롤러
|
||||||
|
*/
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
|
import { vehicleTripService } from "../services/vehicleTripService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 시작
|
||||||
|
* POST /api/vehicle/trip/start
|
||||||
|
*/
|
||||||
|
export const startTrip = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body;
|
||||||
|
|
||||||
|
console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival });
|
||||||
|
|
||||||
|
if (latitude === undefined || longitude === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await vehicleTripService.startTrip({
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
vehicleId,
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [startTrip] 성공:", result);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "운행이 시작되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [startTrip] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "운행 시작에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 종료
|
||||||
|
* POST /api/vehicle/trip/end
|
||||||
|
*/
|
||||||
|
export const endTrip = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { tripId, latitude, longitude } = req.body;
|
||||||
|
|
||||||
|
console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId });
|
||||||
|
|
||||||
|
if (!tripId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tripId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latitude === undefined || longitude === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await vehicleTripService.endTrip({
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [endTrip] 성공:", result);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "운행이 종료되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [endTrip] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "운행 종료에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 기록 추가 (연속 추적)
|
||||||
|
* POST /api/vehicle/trip/location
|
||||||
|
*/
|
||||||
|
export const addTripLocation = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { tripId, latitude, longitude, accuracy, speed } = req.body;
|
||||||
|
|
||||||
|
if (!tripId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tripId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latitude === undefined || longitude === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "위치 정보(latitude, longitude)가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await vehicleTripService.addLocation({
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy,
|
||||||
|
speed,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [addTripLocation] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "위치 기록에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 이력 목록 조회
|
||||||
|
* GET /api/vehicle/trips
|
||||||
|
*/
|
||||||
|
export const getTripList = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query;
|
||||||
|
|
||||||
|
console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate });
|
||||||
|
|
||||||
|
const result = await vehicleTripService.getTripList(companyCode, {
|
||||||
|
userId: userId as string,
|
||||||
|
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
|
||||||
|
status: status as string,
|
||||||
|
startDate: startDate as string,
|
||||||
|
endDate: endDate as string,
|
||||||
|
departure: departure as string,
|
||||||
|
arrival: arrival as string,
|
||||||
|
limit: limit ? parseInt(limit as string) : 50,
|
||||||
|
offset: offset ? parseInt(offset as string) : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
total: result.total,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getTripList] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "운행 이력 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 상세 조회 (경로 포함)
|
||||||
|
* GET /api/vehicle/trips/:tripId
|
||||||
|
*/
|
||||||
|
export const getTripDetail = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId });
|
||||||
|
|
||||||
|
const result = await vehicleTripService.getTripDetail(tripId, companyCode);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "운행 정보를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getTripDetail] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "운행 상세 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 운행 조회 (현재 진행 중)
|
||||||
|
* GET /api/vehicle/trip/active
|
||||||
|
*/
|
||||||
|
export const getActiveTrip = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
|
||||||
|
const result = await vehicleTripService.getActiveTrip(userId, companyCode);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
hasActiveTrip: !!result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getActiveTrip] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "활성 운행 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 취소
|
||||||
|
* POST /api/vehicle/trip/cancel
|
||||||
|
*/
|
||||||
|
export const cancelTrip = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { tripId } = req.body;
|
||||||
|
|
||||||
|
if (!tripId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tripId가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await vehicleTripService.cancelTrip(tripId, companyCode);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "취소할 운행을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "운행이 취소되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [cancelTrip] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "운행 취소에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
|
||||||
*/
|
*/
|
||||||
router.post("/refresh", AuthController.refreshToken);
|
router.post("/refresh", AuthController.refreshToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/signup
|
||||||
|
* 공차중계 회원가입 API
|
||||||
|
*/
|
||||||
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
// 공차중계 운전자 API 라우터
|
||||||
|
import { Router } from "express";
|
||||||
|
import { DriverController } from "../controllers/driverController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 필요
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/driver/profile
|
||||||
|
* 운전자 프로필 조회
|
||||||
|
*/
|
||||||
|
router.get("/profile", DriverController.getProfile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/driver/profile
|
||||||
|
* 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종)
|
||||||
|
*/
|
||||||
|
router.put("/profile", DriverController.updateProfile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/driver/status
|
||||||
|
* 차량 상태 변경 (대기/정비만)
|
||||||
|
*/
|
||||||
|
router.put("/status", DriverController.updateStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/driver/vehicle
|
||||||
|
* 차량 삭제 (기록 보존)
|
||||||
|
*/
|
||||||
|
router.delete("/vehicle", DriverController.deleteVehicle);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/driver/vehicle
|
||||||
|
* 새 차량 등록
|
||||||
|
*/
|
||||||
|
router.post("/vehicle", DriverController.registerVehicle);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/driver/account
|
||||||
|
* 회원 탈퇴
|
||||||
|
*/
|
||||||
|
router.delete("/account", DriverController.deleteAccount);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -97,6 +97,8 @@ router.post(
|
||||||
const data: ExternalRestApiConnection = {
|
const data: ExternalRestApiConnection = {
|
||||||
...req.body,
|
...req.body,
|
||||||
created_by: req.user?.userId || "system",
|
created_by: req.user?.userId || "system",
|
||||||
|
// 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정)
|
||||||
|
company_code: req.body.company_code || req.user?.companyCode || "*",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
updateScreen,
|
updateScreen,
|
||||||
updateScreenInfo,
|
updateScreenInfo,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
|
bulkDeleteScreens,
|
||||||
checkScreenDependencies,
|
checkScreenDependencies,
|
||||||
restoreScreen,
|
restoreScreen,
|
||||||
permanentDeleteScreen,
|
permanentDeleteScreen,
|
||||||
|
|
@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
|
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 이력 및 리포트 라우트
|
||||||
|
*/
|
||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
startTrip,
|
||||||
|
endTrip,
|
||||||
|
addTripLocation,
|
||||||
|
getTripList,
|
||||||
|
getTripDetail,
|
||||||
|
getActiveTrip,
|
||||||
|
cancelTrip,
|
||||||
|
} from "../controllers/vehicleTripController";
|
||||||
|
import {
|
||||||
|
getDailyReport,
|
||||||
|
getWeeklyReport,
|
||||||
|
getMonthlyReport,
|
||||||
|
getSummaryReport,
|
||||||
|
getDriverReport,
|
||||||
|
getRouteReport,
|
||||||
|
} from "../controllers/vehicleReportController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// === 운행 관리 ===
|
||||||
|
// 운행 시작
|
||||||
|
router.post("/trip/start", startTrip);
|
||||||
|
|
||||||
|
// 운행 종료
|
||||||
|
router.post("/trip/end", endTrip);
|
||||||
|
|
||||||
|
// 위치 기록 추가 (연속 추적)
|
||||||
|
router.post("/trip/location", addTripLocation);
|
||||||
|
|
||||||
|
// 활성 운행 조회 (현재 진행 중)
|
||||||
|
router.get("/trip/active", getActiveTrip);
|
||||||
|
|
||||||
|
// 운행 취소
|
||||||
|
router.post("/trip/cancel", cancelTrip);
|
||||||
|
|
||||||
|
// 운행 이력 목록 조회
|
||||||
|
router.get("/trips", getTripList);
|
||||||
|
|
||||||
|
// 운행 상세 조회 (경로 포함)
|
||||||
|
router.get("/trips/:tripId", getTripDetail);
|
||||||
|
|
||||||
|
// === 리포트 ===
|
||||||
|
// 요약 통계 (대시보드용)
|
||||||
|
router.get("/reports/summary", getSummaryReport);
|
||||||
|
|
||||||
|
// 일별 통계
|
||||||
|
router.get("/reports/daily", getDailyReport);
|
||||||
|
|
||||||
|
// 주별 통계
|
||||||
|
router.get("/reports/weekly", getWeeklyReport);
|
||||||
|
|
||||||
|
// 월별 통계
|
||||||
|
router.get("/reports/monthly", getMonthlyReport);
|
||||||
|
|
||||||
|
// 운전자별 통계
|
||||||
|
router.get("/reports/by-driver", getDriverReport);
|
||||||
|
|
||||||
|
// 구간별 통계
|
||||||
|
router.get("/reports/by-route", getRouteReport);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -342,4 +342,130 @@ export class AuthService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공차중계 회원가입 처리
|
||||||
|
* - user_info 테이블에 사용자 정보 저장
|
||||||
|
* - vehicles 테이블에 차량 정보 저장
|
||||||
|
*/
|
||||||
|
static async signupDriver(data: {
|
||||||
|
userId: string;
|
||||||
|
password: string;
|
||||||
|
userName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
licenseNumber: string;
|
||||||
|
vehicleNumber: string;
|
||||||
|
vehicleType?: string;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
licenseNumber,
|
||||||
|
vehicleNumber,
|
||||||
|
vehicleType,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// 1. 중복 사용자 확인
|
||||||
|
const existingUser = await query<any>(
|
||||||
|
`SELECT user_id FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 아이디입니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 중복 차량번호 확인
|
||||||
|
const existingVehicle = await query<any>(
|
||||||
|
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`,
|
||||||
|
[vehicleNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingVehicle.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 차량번호입니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 비밀번호 암호화 (MD5 - 기존 시스템 호환)
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const hashedPassword = crypto
|
||||||
|
.createHash("md5")
|
||||||
|
.update(password)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
// 4. 사용자 정보 저장 (user_info)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO user_info (
|
||||||
|
user_id,
|
||||||
|
user_password,
|
||||||
|
user_name,
|
||||||
|
cell_phone,
|
||||||
|
license_number,
|
||||||
|
vehicle_number,
|
||||||
|
company_code,
|
||||||
|
user_type,
|
||||||
|
signup_type,
|
||||||
|
status,
|
||||||
|
regdate
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
hashedPassword,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
licenseNumber,
|
||||||
|
vehicleNumber,
|
||||||
|
"COMPANY_13", // 기본 회사 코드
|
||||||
|
null, // user_type: null
|
||||||
|
"DRIVER", // signup_type: 공차중계 회원가입 사용자
|
||||||
|
"active", // status: active
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 차량 정보 저장 (vehicles)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO vehicles (
|
||||||
|
vehicle_number,
|
||||||
|
vehicle_type,
|
||||||
|
driver_name,
|
||||||
|
driver_phone,
|
||||||
|
status,
|
||||||
|
company_code,
|
||||||
|
user_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
|
||||||
|
[
|
||||||
|
vehicleNumber,
|
||||||
|
vehicleType || null,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
"off", // 초기 상태: off (대기)
|
||||||
|
"COMPANY_13", // 기본 회사 코드
|
||||||
|
userId, // 사용자 ID 연결
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "회원가입이 완료되었습니다.",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("공차중계 회원가입 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "회원가입 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -403,18 +403,25 @@ export class EntityJoinService {
|
||||||
const fromClause = `FROM ${tableName} main`;
|
const fromClause = `FROM ${tableName} main`;
|
||||||
|
|
||||||
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
||||||
|
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
const alias = aliasMap.get(aliasKey);
|
const alias = aliasMap.get(aliasKey);
|
||||||
|
|
||||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||||
|
if (config.referenceTable === "user_info") {
|
||||||
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
|
||||||
|
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
|
||||||
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,39 +28,39 @@ export class ExternalDbConnectionService {
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
||||||
// 필터가 있으면 적용
|
// 필터가 있으면 적용
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 조건 적용
|
// 필터 조건 적용
|
||||||
if (filter.db_type) {
|
if (filter.db_type) {
|
||||||
whereConditions.push(`db_type = $${paramIndex++}`);
|
whereConditions.push(`e.db_type = $${paramIndex++}`);
|
||||||
params.push(filter.db_type);
|
params.push(filter.db_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
whereConditions.push(`is_active = $${paramIndex++}`);
|
whereConditions.push(`e.is_active = $${paramIndex++}`);
|
||||||
params.push(filter.is_active);
|
params.push(filter.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
||||||
if (filter.search && filter.search.trim()) {
|
if (filter.search && filter.search.trim()) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
`(e.connection_name ILIKE $${paramIndex} OR e.description ILIKE $${paramIndex})`
|
||||||
);
|
);
|
||||||
params.push(`%${filter.search.trim()}%`);
|
params.push(`%${filter.search.trim()}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -72,9 +72,12 @@ export class ExternalDbConnectionService {
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const connections = await query<any>(
|
const connections = await query<any>(
|
||||||
`SELECT * FROM external_db_connections
|
`SELECT e.*,
|
||||||
|
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
|
||||||
|
FROM external_db_connections e
|
||||||
|
LEFT JOIN company_mng c ON e.company_code = c.company_code
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY is_active DESC, connection_name ASC`,
|
ORDER BY e.is_active DESC, e.connection_name ASC`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,17 @@ export class ExternalRestApiConnectionService {
|
||||||
try {
|
try {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
|
||||||
default_method,
|
e.default_method,
|
||||||
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||||
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||||
default_request_body AS default_body,
|
e.default_request_body AS default_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay,
|
||||||
company_code, is_active, created_date, created_by,
|
e.company_code, e.is_active, e.created_date, e.created_by,
|
||||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.last_test_message,
|
||||||
FROM external_rest_api_connections
|
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
|
||||||
|
FROM external_rest_api_connections e
|
||||||
|
LEFT JOIN company_mng c ON e.company_code = c.company_code
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -48,7 +50,7 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
query += ` AND company_code = $${paramIndex}`;
|
query += ` AND e.company_code = $${paramIndex}`;
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
||||||
|
|
@ -56,14 +58,14 @@ export class ExternalRestApiConnectionService {
|
||||||
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
||||||
// 필터가 있으면 적용
|
// 필터가 있으면 적용
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
query += ` AND company_code = $${paramIndex}`;
|
query += ` AND e.company_code = $${paramIndex}`;
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
query += ` AND company_code = $${paramIndex}`;
|
query += ` AND e.company_code = $${paramIndex}`;
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -71,14 +73,14 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
// 활성 상태 필터
|
// 활성 상태 필터
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
query += ` AND is_active = $${paramIndex}`;
|
query += ` AND e.is_active = $${paramIndex}`;
|
||||||
params.push(filter.is_active);
|
params.push(filter.is_active);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 타입 필터
|
// 인증 타입 필터
|
||||||
if (filter.auth_type) {
|
if (filter.auth_type) {
|
||||||
query += ` AND auth_type = $${paramIndex}`;
|
query += ` AND e.auth_type = $${paramIndex}`;
|
||||||
params.push(filter.auth_type);
|
params.push(filter.auth_type);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -86,9 +88,9 @@ export class ExternalRestApiConnectionService {
|
||||||
// 검색어 필터 (연결명, 설명, URL)
|
// 검색어 필터 (연결명, 설명, URL)
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
query += ` AND (
|
query += ` AND (
|
||||||
connection_name ILIKE $${paramIndex} OR
|
e.connection_name ILIKE $${paramIndex} OR
|
||||||
description ILIKE $${paramIndex} OR
|
e.description ILIKE $${paramIndex} OR
|
||||||
base_url ILIKE $${paramIndex}
|
e.base_url ILIKE $${paramIndex}
|
||||||
)`;
|
)`;
|
||||||
params.push(`%${filter.search}%`);
|
params.push(`%${filter.search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -233,6 +235,7 @@ export class ExternalRestApiConnectionService {
|
||||||
// 디버깅: 저장하려는 데이터 로깅
|
// 디버깅: 저장하려는 데이터 로깅
|
||||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||||
connection_name: data.connection_name,
|
connection_name: data.connection_name,
|
||||||
|
company_code: data.company_code,
|
||||||
default_method: data.default_method,
|
default_method: data.default_method,
|
||||||
endpoint_path: data.endpoint_path,
|
endpoint_path: data.endpoint_path,
|
||||||
base_url: data.base_url,
|
base_url: data.base_url,
|
||||||
|
|
@ -1091,4 +1094,150 @@ export class ExternalRestApiConnectionService {
|
||||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
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 : "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,21 @@ export class FlowDefinitionService {
|
||||||
tableName: request.tableName,
|
tableName: request.tableName,
|
||||||
dbSourceType: request.dbSourceType,
|
dbSourceType: request.dbSourceType,
|
||||||
dbConnectionId: request.dbConnectionId,
|
dbConnectionId: request.dbConnectionId,
|
||||||
|
restApiConnectionId: request.restApiConnectionId,
|
||||||
|
restApiEndpoint: request.restApiEndpoint,
|
||||||
|
restApiJsonPath: request.restApiJsonPath,
|
||||||
|
restApiConnections: request.restApiConnections,
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
|
INSERT INTO flow_definition (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
name, description, table_name, db_source_type, db_connection_id,
|
||||||
|
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
|
||||||
|
rest_api_connections, company_code, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -43,6 +51,10 @@ export class FlowDefinitionService {
|
||||||
request.tableName || null,
|
request.tableName || null,
|
||||||
request.dbSourceType || "internal",
|
request.dbSourceType || "internal",
|
||||||
request.dbConnectionId || null,
|
request.dbConnectionId || null,
|
||||||
|
request.restApiConnectionId || null,
|
||||||
|
request.restApiEndpoint || null,
|
||||||
|
request.restApiJsonPath || "response",
|
||||||
|
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
];
|
];
|
||||||
|
|
@ -199,6 +211,19 @@ export class FlowDefinitionService {
|
||||||
* DB 행을 FlowDefinition 객체로 변환
|
* DB 행을 FlowDefinition 객체로 변환
|
||||||
*/
|
*/
|
||||||
private mapToFlowDefinition(row: any): 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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|
@ -206,6 +231,12 @@ export class FlowDefinitionService {
|
||||||
tableName: row.table_name,
|
tableName: row.table_name,
|
||||||
dbSourceType: row.db_source_type || "internal",
|
dbSourceType: row.db_source_type || "internal",
|
||||||
dbConnectionId: row.db_connection_id,
|
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 || "*",
|
companyCode: row.company_code || "*",
|
||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ interface ScreenDefinition {
|
||||||
layout_metadata: any;
|
layout_metadata: any;
|
||||||
db_source_type: string | null;
|
db_source_type: string | null;
|
||||||
db_connection_id: number | null;
|
db_connection_id: number | null;
|
||||||
|
source_screen_id: number | null; // 원본 화면 ID (복사 추적용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -234,6 +235,27 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId)
|
||||||
|
if (props?.componentConfig?.leftScreenId) {
|
||||||
|
const leftScreenId = props.componentConfig.leftScreenId;
|
||||||
|
const numId =
|
||||||
|
typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId);
|
||||||
|
if (!isNaN(numId) && numId > 0) {
|
||||||
|
referenced.push(numId);
|
||||||
|
logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props?.componentConfig?.rightScreenId) {
|
||||||
|
const rightScreenId = props.componentConfig.rightScreenId;
|
||||||
|
const numId =
|
||||||
|
typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId);
|
||||||
|
if (!isNaN(numId) && numId > 0) {
|
||||||
|
referenced.push(numId);
|
||||||
|
logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return referenced;
|
return referenced;
|
||||||
|
|
@ -431,14 +453,16 @@ export class MenuCopyService {
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
const currentPath = path ? `${path}.${key}` : key;
|
const currentPath = path ? `${path}.${key}` : key;
|
||||||
|
|
||||||
// screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열)
|
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
|
||||||
if (
|
if (
|
||||||
key === "screen_id" ||
|
key === "screen_id" ||
|
||||||
key === "screenId" ||
|
key === "screenId" ||
|
||||||
key === "targetScreenId"
|
key === "targetScreenId" ||
|
||||||
|
key === "leftScreenId" ||
|
||||||
|
key === "rightScreenId"
|
||||||
) {
|
) {
|
||||||
const numValue = typeof value === "number" ? value : parseInt(value);
|
const numValue = typeof value === "number" ? value : parseInt(value);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue) && numValue > 0) {
|
||||||
const newId = screenIdMap.get(numValue);
|
const newId = screenIdMap.get(numValue);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||||||
|
|
@ -856,7 +880,10 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 복사
|
* 화면 복사 (업데이트 또는 신규 생성)
|
||||||
|
* - source_screen_id로 기존 복사본 찾기
|
||||||
|
* - 변경된 내용이 있으면 업데이트
|
||||||
|
* - 없으면 새로 복사
|
||||||
*/
|
*/
|
||||||
private async copyScreens(
|
private async copyScreens(
|
||||||
screenIds: Set<number>,
|
screenIds: Set<number>,
|
||||||
|
|
@ -876,18 +903,19 @@ export class MenuCopyService {
|
||||||
return screenIdMap;
|
return screenIdMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📄 화면 복사 중: ${screenIds.size}개`);
|
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`);
|
||||||
|
|
||||||
// === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) ===
|
// === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) ===
|
||||||
const screenDefsToProcess: Array<{
|
const screenDefsToProcess: Array<{
|
||||||
originalScreenId: number;
|
originalScreenId: number;
|
||||||
newScreenId: number;
|
targetScreenId: number;
|
||||||
screenDef: ScreenDefinition;
|
screenDef: ScreenDefinition;
|
||||||
|
isUpdate: boolean; // 업데이트인지 신규 생성인지
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const originalScreenId of screenIds) {
|
for (const originalScreenId of screenIds) {
|
||||||
try {
|
try {
|
||||||
// 1) screen_definitions 조회
|
// 1) 원본 screen_definitions 조회
|
||||||
const screenDefResult = await client.query<ScreenDefinition>(
|
const screenDefResult = await client.query<ScreenDefinition>(
|
||||||
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
|
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
|
||||||
[originalScreenId]
|
[originalScreenId]
|
||||||
|
|
@ -900,122 +928,198 @@ export class MenuCopyService {
|
||||||
|
|
||||||
const screenDef = screenDefResult.rows[0];
|
const screenDef = screenDefResult.rows[0];
|
||||||
|
|
||||||
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
// 2) 기존 복사본 찾기: source_screen_id로 검색
|
||||||
const existingScreenResult = await client.query<{ screen_id: number }>(
|
const existingCopyResult = await client.query<{
|
||||||
`SELECT screen_id FROM screen_definitions
|
screen_id: number;
|
||||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
screen_name: string;
|
||||||
|
updated_date: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT screen_id, screen_name, updated_date
|
||||||
|
FROM screen_definitions
|
||||||
|
WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[screenDef.screen_code, targetCompanyCode]
|
[originalScreenId, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingScreenResult.rows.length > 0) {
|
// 3) 화면명 변환 적용
|
||||||
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
|
|
||||||
const existingScreenId = existingScreenResult.rows[0].screen_id;
|
|
||||||
screenIdMap.set(originalScreenId, existingScreenId);
|
|
||||||
logger.info(
|
|
||||||
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})`
|
|
||||||
);
|
|
||||||
continue; // 레이아웃 복사도 스킵
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 새 screen_code 생성
|
|
||||||
const newScreenCode = await this.generateUniqueScreenCode(
|
|
||||||
targetCompanyCode,
|
|
||||||
client
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4) 화면명 변환 적용
|
|
||||||
let transformedScreenName = screenDef.screen_name;
|
let transformedScreenName = screenDef.screen_name;
|
||||||
if (screenNameConfig) {
|
if (screenNameConfig) {
|
||||||
// 1. 제거할 텍스트 제거
|
|
||||||
if (screenNameConfig.removeText?.trim()) {
|
if (screenNameConfig.removeText?.trim()) {
|
||||||
transformedScreenName = transformedScreenName.replace(
|
transformedScreenName = transformedScreenName.replace(
|
||||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거
|
transformedScreenName = transformedScreenName.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 접두사 추가
|
|
||||||
if (screenNameConfig.addPrefix?.trim()) {
|
if (screenNameConfig.addPrefix?.trim()) {
|
||||||
transformedScreenName =
|
transformedScreenName =
|
||||||
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
|
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
if (existingCopyResult.rows.length > 0) {
|
||||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||||
`INSERT INTO screen_definitions (
|
const existingScreen = existingCopyResult.rows[0];
|
||||||
screen_name, screen_code, table_name, company_code,
|
const existingScreenId = existingScreen.screen_id;
|
||||||
description, is_active, layout_metadata,
|
|
||||||
db_source_type, db_connection_id, created_by,
|
|
||||||
deleted_date, deleted_by, delete_reason
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
||||||
RETURNING screen_id`,
|
|
||||||
[
|
|
||||||
transformedScreenName, // 변환된 화면명
|
|
||||||
newScreenCode, // 새 화면 코드
|
|
||||||
screenDef.table_name,
|
|
||||||
targetCompanyCode, // 새 회사 코드
|
|
||||||
screenDef.description,
|
|
||||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화
|
|
||||||
screenDef.layout_metadata,
|
|
||||||
screenDef.db_source_type,
|
|
||||||
screenDef.db_connection_id,
|
|
||||||
userId,
|
|
||||||
null, // deleted_date: NULL (새 화면은 삭제되지 않음)
|
|
||||||
null, // deleted_by: NULL
|
|
||||||
null, // delete_reason: NULL
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newScreenId = newScreenResult.rows[0].screen_id;
|
// 원본 레이아웃 조회
|
||||||
screenIdMap.set(originalScreenId, newScreenId);
|
const sourceLayoutsResult = await client.query<ScreenLayout>(
|
||||||
|
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||||
|
[originalScreenId]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
// 대상 레이아웃 조회
|
||||||
` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
const targetLayoutsResult = await client.query<ScreenLayout>(
|
||||||
);
|
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||||
|
[existingScreenId]
|
||||||
|
);
|
||||||
|
|
||||||
// 저장해서 2단계에서 처리
|
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
|
||||||
screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef });
|
const hasChanges = this.hasLayoutChanges(
|
||||||
|
sourceLayoutsResult.rows,
|
||||||
|
targetLayoutsResult.rows
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
// 변경 사항이 있으면 업데이트
|
||||||
|
logger.info(
|
||||||
|
` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// screen_definitions 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_definitions SET
|
||||||
|
screen_name = $1,
|
||||||
|
table_name = $2,
|
||||||
|
description = $3,
|
||||||
|
is_active = $4,
|
||||||
|
layout_metadata = $5,
|
||||||
|
db_source_type = $6,
|
||||||
|
db_connection_id = $7,
|
||||||
|
updated_by = $8,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE screen_id = $9`,
|
||||||
|
[
|
||||||
|
transformedScreenName,
|
||||||
|
screenDef.table_name,
|
||||||
|
screenDef.description,
|
||||||
|
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
||||||
|
screenDef.layout_metadata,
|
||||||
|
screenDef.db_source_type,
|
||||||
|
screenDef.db_connection_id,
|
||||||
|
userId,
|
||||||
|
existingScreenId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
screenIdMap.set(originalScreenId, existingScreenId);
|
||||||
|
screenDefsToProcess.push({
|
||||||
|
originalScreenId,
|
||||||
|
targetScreenId: existingScreenId,
|
||||||
|
screenDef,
|
||||||
|
isUpdate: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 변경 사항이 없으면 스킵
|
||||||
|
screenIdMap.set(originalScreenId, existingScreenId);
|
||||||
|
logger.info(
|
||||||
|
` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// === 기존 복사본이 없는 경우: 신규 생성 ===
|
||||||
|
const newScreenCode = await this.generateUniqueScreenCode(
|
||||||
|
targetCompanyCode,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||||
|
`INSERT INTO screen_definitions (
|
||||||
|
screen_name, screen_code, table_name, company_code,
|
||||||
|
description, is_active, layout_metadata,
|
||||||
|
db_source_type, db_connection_id, created_by,
|
||||||
|
deleted_date, deleted_by, delete_reason, source_screen_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING screen_id`,
|
||||||
|
[
|
||||||
|
transformedScreenName,
|
||||||
|
newScreenCode,
|
||||||
|
screenDef.table_name,
|
||||||
|
targetCompanyCode,
|
||||||
|
screenDef.description,
|
||||||
|
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
||||||
|
screenDef.layout_metadata,
|
||||||
|
screenDef.db_source_type,
|
||||||
|
screenDef.db_connection_id,
|
||||||
|
userId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
originalScreenId, // source_screen_id 저장
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newScreenId = newScreenResult.rows[0].screen_id;
|
||||||
|
screenIdMap.set(originalScreenId, newScreenId);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
||||||
|
);
|
||||||
|
|
||||||
|
screenDefsToProcess.push({
|
||||||
|
originalScreenId,
|
||||||
|
targetScreenId: newScreenId,
|
||||||
|
screenDef,
|
||||||
|
isUpdate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`,
|
`❌ 화면 처리 실패: screen_id=${originalScreenId}`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) ===
|
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
|
||||||
logger.info(
|
logger.info(
|
||||||
`\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
originalScreenId,
|
originalScreenId,
|
||||||
newScreenId,
|
targetScreenId,
|
||||||
screenDef,
|
screenDef,
|
||||||
|
isUpdate,
|
||||||
} of screenDefsToProcess) {
|
} of screenDefsToProcess) {
|
||||||
try {
|
try {
|
||||||
// screen_layouts 복사
|
// 원본 레이아웃 조회
|
||||||
const layoutsResult = await client.query<ScreenLayout>(
|
const layoutsResult = await client.query<ScreenLayout>(
|
||||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||||
[originalScreenId]
|
[originalScreenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1단계: component_id 매핑 생성 (원본 → 새 ID)
|
if (isUpdate) {
|
||||||
|
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM screen_layouts WHERE screen_id = $1`,
|
||||||
|
[targetScreenId]
|
||||||
|
);
|
||||||
|
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// component_id 매핑 생성 (원본 → 새 ID)
|
||||||
const componentIdMap = new Map<string, string>();
|
const componentIdMap = new Map<string, string>();
|
||||||
for (const layout of layoutsResult.rows) {
|
for (const layout of layoutsResult.rows) {
|
||||||
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
componentIdMap.set(layout.component_id, newComponentId);
|
componentIdMap.set(layout.component_id, newComponentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑)
|
// 레이아웃 삽입
|
||||||
for (const layout of layoutsResult.rows) {
|
for (const layout of layoutsResult.rows) {
|
||||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||||
|
|
||||||
// parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우)
|
|
||||||
const newParentId = layout.parent_id
|
const newParentId = layout.parent_id
|
||||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -1023,7 +1127,6 @@ export class MenuCopyService {
|
||||||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// properties 내부 참조 업데이트
|
|
||||||
const updatedProperties = this.updateReferencesInProperties(
|
const updatedProperties = this.updateReferencesInProperties(
|
||||||
layout.properties,
|
layout.properties,
|
||||||
screenIdMap,
|
screenIdMap,
|
||||||
|
|
@ -1037,38 +1140,94 @@ export class MenuCopyService {
|
||||||
display_order, layout_type, layout_config, zones_config, zone_id
|
display_order, layout_type, layout_config, zones_config, zone_id
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||||
[
|
[
|
||||||
newScreenId, // 새 화면 ID
|
targetScreenId,
|
||||||
layout.component_type,
|
layout.component_type,
|
||||||
newComponentId, // 새 컴포넌트 ID
|
newComponentId,
|
||||||
newParentId, // 매핑된 parent_id
|
newParentId,
|
||||||
layout.position_x,
|
layout.position_x,
|
||||||
layout.position_y,
|
layout.position_y,
|
||||||
layout.width,
|
layout.width,
|
||||||
layout.height,
|
layout.height,
|
||||||
updatedProperties, // 업데이트된 속성
|
updatedProperties,
|
||||||
layout.display_order,
|
layout.display_order,
|
||||||
layout.layout_type,
|
layout.layout_type,
|
||||||
layout.layout_config,
|
layout.layout_config,
|
||||||
layout.zones_config,
|
layout.zones_config,
|
||||||
newZoneId, // 매핑된 zone_id
|
newZoneId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`);
|
const action = isUpdate ? "업데이트" : "복사";
|
||||||
|
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`,
|
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`);
|
// 통계 출력
|
||||||
|
const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length;
|
||||||
|
const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length;
|
||||||
|
const skipCount = screenIds.size - screenDefsToProcess.length;
|
||||||
|
|
||||||
|
logger.info(`
|
||||||
|
✅ 화면 처리 완료:
|
||||||
|
- 신규 복사: ${newCount}개
|
||||||
|
- 업데이트: ${updateCount}개
|
||||||
|
- 스킵 (변경 없음): ${skipCount}개
|
||||||
|
- 총 매핑: ${screenIdMap.size}개
|
||||||
|
`);
|
||||||
|
|
||||||
return screenIdMap;
|
return screenIdMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 변경 여부 확인
|
||||||
|
*/
|
||||||
|
private hasLayoutChanges(
|
||||||
|
sourceLayouts: ScreenLayout[],
|
||||||
|
targetLayouts: ScreenLayout[]
|
||||||
|
): boolean {
|
||||||
|
// 1. 레이아웃 개수가 다르면 변경됨
|
||||||
|
if (sourceLayouts.length !== targetLayouts.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 각 레이아웃의 주요 속성 비교
|
||||||
|
for (let i = 0; i < sourceLayouts.length; i++) {
|
||||||
|
const source = sourceLayouts[i];
|
||||||
|
const target = targetLayouts[i];
|
||||||
|
|
||||||
|
// component_type이 다르면 변경됨
|
||||||
|
if (source.component_type !== target.component_type) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위치/크기가 다르면 변경됨
|
||||||
|
if (
|
||||||
|
source.position_x !== target.position_x ||
|
||||||
|
source.position_y !== target.position_y ||
|
||||||
|
source.width !== target.width ||
|
||||||
|
source.height !== target.height
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// properties의 JSON 문자열 비교 (깊은 비교)
|
||||||
|
const sourceProps = JSON.stringify(source.properties || {});
|
||||||
|
const targetProps = JSON.stringify(target.properties || {});
|
||||||
|
if (sourceProps !== targetProps) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 위상 정렬 (부모 먼저)
|
* 메뉴 위상 정렬 (부모 먼저)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,24 @@ export class RiskAlertService {
|
||||||
|
|
||||||
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
||||||
|
|
||||||
// 텍스트 응답 파싱 (EUC-KR 인코딩)
|
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
||||||
const iconv = require('iconv-lite');
|
const iconv = require('iconv-lite');
|
||||||
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
|
const buffer = Buffer.from(warningResponse.data);
|
||||||
|
|
||||||
|
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
|
||||||
|
let responseText: string;
|
||||||
|
const utf8Text = buffer.toString('utf-8');
|
||||||
|
|
||||||
|
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
|
||||||
|
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||||
|
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||||
|
responseText = utf8Text;
|
||||||
|
console.log('📝 UTF-8 인코딩으로 디코딩');
|
||||||
|
} else {
|
||||||
|
// EUC-KR로 디코딩
|
||||||
|
responseText = iconv.decode(buffer, 'EUC-KR');
|
||||||
|
console.log('📝 EUC-KR 인코딩으로 디코딩');
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
||||||
const lines = responseText.split('\n');
|
const lines = responseText.split('\n');
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,19 @@ export class ScreenManagementService {
|
||||||
*/
|
*/
|
||||||
async updateScreenInfo(
|
async updateScreenInfo(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
updateData: { screenName: string; tableName?: string; description?: string; isActive: string },
|
updateData: {
|
||||||
|
screenName: string;
|
||||||
|
tableName?: string;
|
||||||
|
description?: string;
|
||||||
|
isActive: string;
|
||||||
|
// REST API 관련 필드 추가
|
||||||
|
dataSourceType?: string;
|
||||||
|
dbSourceType?: string;
|
||||||
|
dbConnectionId?: number;
|
||||||
|
restApiConnectionId?: number;
|
||||||
|
restApiEndpoint?: string;
|
||||||
|
restApiJsonPath?: string;
|
||||||
|
},
|
||||||
userCompanyCode: string
|
userCompanyCode: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
|
|
@ -348,24 +360,43 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 정보 업데이트 (tableName 포함)
|
// 화면 정보 업데이트 (REST API 필드 포함)
|
||||||
await query(
|
await query(
|
||||||
`UPDATE screen_definitions
|
`UPDATE screen_definitions
|
||||||
SET screen_name = $1,
|
SET screen_name = $1,
|
||||||
table_name = $2,
|
table_name = $2,
|
||||||
description = $3,
|
description = $3,
|
||||||
is_active = $4,
|
is_active = $4,
|
||||||
updated_date = $5
|
updated_date = $5,
|
||||||
WHERE screen_id = $6`,
|
data_source_type = $6,
|
||||||
|
db_source_type = $7,
|
||||||
|
db_connection_id = $8,
|
||||||
|
rest_api_connection_id = $9,
|
||||||
|
rest_api_endpoint = $10,
|
||||||
|
rest_api_json_path = $11
|
||||||
|
WHERE screen_id = $12`,
|
||||||
[
|
[
|
||||||
updateData.screenName,
|
updateData.screenName,
|
||||||
updateData.tableName || null,
|
updateData.tableName || null,
|
||||||
updateData.description || null,
|
updateData.description || null,
|
||||||
updateData.isActive,
|
updateData.isActive,
|
||||||
new Date(),
|
new Date(),
|
||||||
|
updateData.dataSourceType || "database",
|
||||||
|
updateData.dbSourceType || "internal",
|
||||||
|
updateData.dbConnectionId || null,
|
||||||
|
updateData.restApiConnectionId || null,
|
||||||
|
updateData.restApiEndpoint || null,
|
||||||
|
updateData.restApiJsonPath || null,
|
||||||
screenId,
|
screenId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
|
||||||
|
dataSourceType: updateData.dataSourceType,
|
||||||
|
restApiConnectionId: updateData.restApiConnectionId,
|
||||||
|
restApiEndpoint: updateData.restApiEndpoint,
|
||||||
|
restApiJsonPath: updateData.restApiJsonPath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -861,6 +892,134 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
|
*/
|
||||||
|
async bulkDeleteScreens(
|
||||||
|
screenIds: number[],
|
||||||
|
userCompanyCode: string,
|
||||||
|
deletedBy: string,
|
||||||
|
deleteReason?: string,
|
||||||
|
force: boolean = false
|
||||||
|
): Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errors: Array<{ screenId: number; error: string }>;
|
||||||
|
}> {
|
||||||
|
if (screenIds.length === 0) {
|
||||||
|
throw new Error("삭제할 화면을 선택해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
const errors: Array<{ screenId: number; error: string }> = [];
|
||||||
|
|
||||||
|
// 각 화면을 개별적으로 삭제 처리
|
||||||
|
for (const screenId of screenIds) {
|
||||||
|
try {
|
||||||
|
// 권한 확인 (Raw Query)
|
||||||
|
const existingResult = await query<{
|
||||||
|
company_code: string | null;
|
||||||
|
is_active: string;
|
||||||
|
screen_name: string;
|
||||||
|
}>(
|
||||||
|
`SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.length === 0) {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: "화면을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingScreen = existingResult[0];
|
||||||
|
|
||||||
|
// 권한 확인
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
existingScreen.company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: "이 화면을 삭제할 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 삭제된 화면인지 확인
|
||||||
|
if (existingScreen.is_active === "D") {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: "이미 삭제된 화면입니다.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강제 삭제가 아닌 경우 의존성 체크
|
||||||
|
if (!force) {
|
||||||
|
const dependencyCheck = await this.checkScreenDependencies(
|
||||||
|
screenId,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
if (dependencyCheck.hasDependencies) {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
|
||||||
|
await transaction(async (client) => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 소프트 삭제 (휴지통으로 이동)
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_definitions
|
||||||
|
SET is_active = 'D',
|
||||||
|
deleted_date = $1,
|
||||||
|
deleted_by = $2,
|
||||||
|
delete_reason = $3,
|
||||||
|
updated_date = $4,
|
||||||
|
updated_by = $5
|
||||||
|
WHERE screen_id = $6`,
|
||||||
|
[now, deletedBy, deleteReason || null, now, deletedBy, screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거)
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
deletedCount++;
|
||||||
|
logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`);
|
||||||
|
} catch (error) {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
logger.error(`화면 삭제 실패: ${screenId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { deletedCount, skippedCount, errors };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 휴지통 화면 일괄 영구 삭제
|
* 휴지통 화면 일괄 영구 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -1486,11 +1645,23 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
|
||||||
|
const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode);
|
||||||
|
|
||||||
const components: ComponentData[] = componentLayouts.map((layout) => {
|
const components: ComponentData[] = componentLayouts.map((layout) => {
|
||||||
const properties = layout.properties as any;
|
const properties = layout.properties as any;
|
||||||
|
|
||||||
|
// 🔥 최신 inputType으로 widgetType 및 componentType 업데이트
|
||||||
|
const tableName = properties?.tableName;
|
||||||
|
const columnName = properties?.columnName;
|
||||||
|
const latestTypeInfo = tableName && columnName
|
||||||
|
? inputTypeMap.get(`${tableName}.${columnName}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
const component = {
|
const component = {
|
||||||
id: layout.component_id,
|
id: layout.component_id,
|
||||||
type: layout.component_type as any,
|
// 🔥 최신 componentType이 있으면 type 덮어쓰기
|
||||||
|
type: latestTypeInfo?.componentType || layout.component_type as any,
|
||||||
position: {
|
position: {
|
||||||
x: layout.position_x,
|
x: layout.position_x,
|
||||||
y: layout.position_y,
|
y: layout.position_y,
|
||||||
|
|
@ -1499,6 +1670,17 @@ export class ScreenManagementService {
|
||||||
size: { width: layout.width, height: layout.height },
|
size: { width: layout.width, height: layout.height },
|
||||||
parentId: layout.parent_id,
|
parentId: layout.parent_id,
|
||||||
...properties,
|
...properties,
|
||||||
|
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
|
||||||
|
...(latestTypeInfo && {
|
||||||
|
widgetType: latestTypeInfo.inputType,
|
||||||
|
inputType: latestTypeInfo.inputType,
|
||||||
|
componentType: latestTypeInfo.componentType,
|
||||||
|
componentConfig: {
|
||||||
|
...properties?.componentConfig,
|
||||||
|
type: latestTypeInfo.componentType,
|
||||||
|
inputType: latestTypeInfo.inputType,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`로드된 컴포넌트:`, {
|
console.log(`로드된 컴포넌트:`, {
|
||||||
|
|
@ -1508,6 +1690,9 @@ export class ScreenManagementService {
|
||||||
size: component.size,
|
size: component.size,
|
||||||
parentId: component.parentId,
|
parentId: component.parentId,
|
||||||
title: (component as any).title,
|
title: (component as any).title,
|
||||||
|
widgetType: (component as any).widgetType,
|
||||||
|
componentType: (component as any).componentType,
|
||||||
|
latestTypeInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
|
|
@ -1527,6 +1712,112 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
||||||
|
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
||||||
|
*/
|
||||||
|
private getComponentIdFromInputType(inputType: string): string {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
// 텍스트 입력
|
||||||
|
text: "text-input",
|
||||||
|
email: "text-input",
|
||||||
|
password: "text-input",
|
||||||
|
tel: "text-input",
|
||||||
|
// 숫자 입력
|
||||||
|
number: "number-input",
|
||||||
|
decimal: "number-input",
|
||||||
|
// 날짜/시간
|
||||||
|
date: "date-input",
|
||||||
|
datetime: "date-input",
|
||||||
|
time: "date-input",
|
||||||
|
// 텍스트 영역
|
||||||
|
textarea: "textarea-basic",
|
||||||
|
// 선택
|
||||||
|
select: "select-basic",
|
||||||
|
dropdown: "select-basic",
|
||||||
|
// 체크박스/라디오
|
||||||
|
checkbox: "checkbox-basic",
|
||||||
|
radio: "radio-basic",
|
||||||
|
boolean: "toggle-switch",
|
||||||
|
// 파일
|
||||||
|
file: "file-upload",
|
||||||
|
// 이미지
|
||||||
|
image: "image-widget",
|
||||||
|
img: "image-widget",
|
||||||
|
picture: "image-widget",
|
||||||
|
photo: "image-widget",
|
||||||
|
// 버튼
|
||||||
|
button: "button-primary",
|
||||||
|
// 기타
|
||||||
|
label: "text-display",
|
||||||
|
code: "select-basic",
|
||||||
|
entity: "select-basic",
|
||||||
|
category: "select-basic",
|
||||||
|
};
|
||||||
|
|
||||||
|
return mapping[inputType] || "text-input";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트들의 최신 inputType 정보 조회
|
||||||
|
* @param layouts - 레이아웃 목록
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @returns Map<"tableName.columnName", { inputType, componentType }>
|
||||||
|
*/
|
||||||
|
private async getLatestInputTypes(
|
||||||
|
layouts: any[],
|
||||||
|
companyCode: string
|
||||||
|
): Promise<Map<string, { inputType: string; componentType: string }>> {
|
||||||
|
const inputTypeMap = new Map<string, { inputType: string; componentType: string }>();
|
||||||
|
|
||||||
|
// tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출
|
||||||
|
const tableColumnPairs = new Set<string>();
|
||||||
|
for (const layout of layouts) {
|
||||||
|
const properties = layout.properties as any;
|
||||||
|
if (properties?.tableName && properties?.columnName) {
|
||||||
|
tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableColumnPairs.size === 0) {
|
||||||
|
return inputTypeMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 테이블-컬럼 조합에 대해 최신 inputType 조회
|
||||||
|
const pairs = Array.from(tableColumnPairs).map(pair => {
|
||||||
|
const [tableName, columnName] = pair.split('|');
|
||||||
|
return { tableName, columnName };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배치 쿼리로 한 번에 조회
|
||||||
|
const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ');
|
||||||
|
const params = pairs.flatMap(p => [p.tableName, p.columnName]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await query<{ table_name: string; column_name: string; input_type: string }>(
|
||||||
|
`SELECT table_name, column_name, input_type
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE (table_name, column_name) IN (${placeholders})
|
||||||
|
AND company_code = $${params.length + 1}`,
|
||||||
|
[...params, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of results) {
|
||||||
|
const componentType = this.getComponentIdFromInputType(row.input_type);
|
||||||
|
inputTypeMap.set(`${row.table_name}.${row.column_name}`, {
|
||||||
|
inputType: row.input_type,
|
||||||
|
componentType: componentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`최신 inputType 조회 완료: ${results.length}개`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`최신 inputType 조회 실패 (무시됨):`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputTypeMap;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 템플릿 관리
|
// 템플릿 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -2016,37 +2307,40 @@ export class ScreenManagementService {
|
||||||
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
||||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
|
|
||||||
// 해당 회사의 기존 화면 코드들 조회
|
// 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
|
||||||
|
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
|
||||||
const existingScreens = await client.query<{ screen_code: string }>(
|
const existingScreens = await client.query<{ screen_code: string }>(
|
||||||
`SELECT screen_code FROM screen_definitions
|
`SELECT screen_code FROM screen_definitions
|
||||||
WHERE company_code = $1 AND screen_code LIKE $2
|
WHERE screen_code LIKE $1
|
||||||
ORDER BY screen_code DESC
|
ORDER BY screen_code DESC`,
|
||||||
LIMIT 10`,
|
[`${companyCode}_%`]
|
||||||
[companyCode, `${companyCode}%`]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||||
let maxNumber = 0;
|
let maxNumber = 0;
|
||||||
const pattern = new RegExp(
|
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) {
|
for (const screen of existingScreens.rows) {
|
||||||
const match = screen.screen_code.match(pattern);
|
const match = screen.screen_code.match(pattern);
|
||||||
if (match) {
|
if (match) {
|
||||||
const number = parseInt(match[1], 10);
|
const number = parseInt(match[1], 10);
|
||||||
|
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
|
||||||
if (number > maxNumber) {
|
if (number > maxNumber) {
|
||||||
maxNumber = number;
|
maxNumber = number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
// 다음 순번으로 화면 코드 생성
|
||||||
const nextNumber = maxNumber + 1;
|
const nextNumber = maxNumber + 1;
|
||||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
|
||||||
|
const newCode = `${companyCode}_${nextNumber}`;
|
||||||
const newCode = `${companyCode}_${paddedNumber}`;
|
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
|
||||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`);
|
|
||||||
|
|
||||||
return newCode;
|
return newCode;
|
||||||
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,9 @@ export class TableManagementService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
|
||||||
|
await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode);
|
||||||
|
|
||||||
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
||||||
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
||||||
cache.delete(cacheKeyPattern);
|
cache.delete(cacheKeyPattern);
|
||||||
|
|
@ -816,6 +819,135 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
||||||
|
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
||||||
|
*/
|
||||||
|
private getComponentIdFromInputType(inputType: string): string {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
// 텍스트 입력
|
||||||
|
text: "text-input",
|
||||||
|
email: "text-input",
|
||||||
|
password: "text-input",
|
||||||
|
tel: "text-input",
|
||||||
|
// 숫자 입력
|
||||||
|
number: "number-input",
|
||||||
|
decimal: "number-input",
|
||||||
|
// 날짜/시간
|
||||||
|
date: "date-input",
|
||||||
|
datetime: "date-input",
|
||||||
|
time: "date-input",
|
||||||
|
// 텍스트 영역
|
||||||
|
textarea: "textarea-basic",
|
||||||
|
// 선택
|
||||||
|
select: "select-basic",
|
||||||
|
dropdown: "select-basic",
|
||||||
|
// 체크박스/라디오
|
||||||
|
checkbox: "checkbox-basic",
|
||||||
|
radio: "radio-basic",
|
||||||
|
boolean: "toggle-switch",
|
||||||
|
// 파일
|
||||||
|
file: "file-upload",
|
||||||
|
// 이미지
|
||||||
|
image: "image-widget",
|
||||||
|
img: "image-widget",
|
||||||
|
picture: "image-widget",
|
||||||
|
photo: "image-widget",
|
||||||
|
// 버튼
|
||||||
|
button: "button-primary",
|
||||||
|
// 기타
|
||||||
|
label: "text-display",
|
||||||
|
code: "select-basic",
|
||||||
|
entity: "select-basic",
|
||||||
|
category: "select-basic",
|
||||||
|
};
|
||||||
|
|
||||||
|
return mapping[inputType] || "text-input";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param columnName - 컬럼명
|
||||||
|
* @param inputType - 새로운 입력 타입
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
*/
|
||||||
|
private async syncScreenLayoutsInputType(
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
inputType: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 해당 컬럼을 사용하는 화면 레이아웃 조회
|
||||||
|
const affectedLayouts = await query<{
|
||||||
|
layout_id: number;
|
||||||
|
screen_id: number;
|
||||||
|
component_id: string;
|
||||||
|
component_type: string;
|
||||||
|
properties: any;
|
||||||
|
}>(
|
||||||
|
`SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties
|
||||||
|
FROM screen_layouts sl
|
||||||
|
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
||||||
|
WHERE sl.properties->>'tableName' = $1
|
||||||
|
AND sl.properties->>'columnName' = $2
|
||||||
|
AND (sd.company_code = $3 OR $3 = '*')`,
|
||||||
|
[tableName, columnName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (affectedLayouts.length === 0) {
|
||||||
|
logger.info(
|
||||||
|
`화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새로운 componentType 계산
|
||||||
|
const newComponentType = this.getComponentIdFromInputType(inputType);
|
||||||
|
|
||||||
|
// 각 레이아웃의 widgetType, componentType 업데이트
|
||||||
|
for (const layout of affectedLayouts) {
|
||||||
|
const updatedProperties = {
|
||||||
|
...layout.properties,
|
||||||
|
widgetType: inputType,
|
||||||
|
inputType: inputType,
|
||||||
|
// componentConfig 내부의 type도 업데이트
|
||||||
|
componentConfig: {
|
||||||
|
...layout.properties?.componentConfig,
|
||||||
|
type: newComponentType,
|
||||||
|
inputType: inputType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_layouts
|
||||||
|
SET properties = $1, component_type = $2
|
||||||
|
WHERE layout_id = $3`,
|
||||||
|
[JSON.stringify(updatedProperties), newComponentType, layout.layout_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행
|
||||||
|
logger.warn(
|
||||||
|
`화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력 타입별 기본 상세 설정 생성
|
* 입력 타입별 기본 상세 설정 생성
|
||||||
*/
|
*/
|
||||||
|
|
@ -1516,6 +1648,26 @@ export class TableManagementService {
|
||||||
columnName
|
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) {
|
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
|
||||||
// 엔티티 타입이 아니면 기본 검색
|
// 엔티티 타입이 아니면 기본 검색
|
||||||
return {
|
return {
|
||||||
|
|
@ -4070,4 +4222,22 @@ export class TableManagementService {
|
||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,403 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 리포트 서비스
|
||||||
|
*/
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
|
||||||
|
interface DailyReportFilters {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
userId?: string;
|
||||||
|
vehicleId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyReportFilters {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
userId?: string;
|
||||||
|
vehicleId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyReportFilters {
|
||||||
|
year: number;
|
||||||
|
userId?: string;
|
||||||
|
vehicleId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverReportFilters {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteReportFilters {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VehicleReportService {
|
||||||
|
private get pool() {
|
||||||
|
return getPool();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 통계 조회
|
||||||
|
*/
|
||||||
|
async getDailyReport(companyCode: string, filters: DailyReportFilters) {
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 기본값: 최근 30일
|
||||||
|
const endDate = filters.endDate || new Date().toISOString().split("T")[0];
|
||||||
|
const startDate =
|
||||||
|
filters.startDate ||
|
||||||
|
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||||
|
params.push(startDate);
|
||||||
|
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||||
|
params.push(endDate);
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.vehicleId) {
|
||||||
|
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
DATE(start_time) as date,
|
||||||
|
COUNT(*) as trip_count,
|
||||||
|
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||||
|
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||||
|
FROM vehicle_trip_summary
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY DATE(start_time)
|
||||||
|
ORDER BY DATE(start_time) DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
data: result.rows.map((row) => ({
|
||||||
|
date: row.date,
|
||||||
|
tripCount: parseInt(row.trip_count),
|
||||||
|
completedCount: parseInt(row.completed_count),
|
||||||
|
cancelledCount: parseInt(row.cancelled_count),
|
||||||
|
totalDistance: parseFloat(row.total_distance),
|
||||||
|
totalDuration: parseInt(row.total_duration),
|
||||||
|
avgDistance: parseFloat(row.avg_distance),
|
||||||
|
avgDuration: parseFloat(row.avg_duration),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주별 통계 조회
|
||||||
|
*/
|
||||||
|
async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) {
|
||||||
|
const { year, month, userId, vehicleId } = filters;
|
||||||
|
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||||
|
params.push(year);
|
||||||
|
conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`);
|
||||||
|
params.push(month);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vehicleId) {
|
||||||
|
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||||
|
params.push(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
EXTRACT(WEEK FROM start_time) as week_number,
|
||||||
|
MIN(DATE(start_time)) as week_start,
|
||||||
|
MAX(DATE(start_time)) as week_end,
|
||||||
|
COUNT(*) as trip_count,
|
||||||
|
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance
|
||||||
|
FROM vehicle_trip_summary
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY EXTRACT(WEEK FROM start_time)
|
||||||
|
ORDER BY week_number
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
data: result.rows.map((row) => ({
|
||||||
|
weekNumber: parseInt(row.week_number),
|
||||||
|
weekStart: row.week_start,
|
||||||
|
weekEnd: row.week_end,
|
||||||
|
tripCount: parseInt(row.trip_count),
|
||||||
|
completedCount: parseInt(row.completed_count),
|
||||||
|
totalDistance: parseFloat(row.total_distance),
|
||||||
|
totalDuration: parseInt(row.total_duration),
|
||||||
|
avgDistance: parseFloat(row.avg_distance),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
*/
|
||||||
|
async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) {
|
||||||
|
const { year, userId, vehicleId } = filters;
|
||||||
|
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`);
|
||||||
|
params.push(year);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vehicleId) {
|
||||||
|
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||||
|
params.push(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
EXTRACT(MONTH FROM start_time) as month,
|
||||||
|
COUNT(*) as trip_count,
|
||||||
|
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||||
|
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||||
|
COUNT(DISTINCT user_id) as driver_count
|
||||||
|
FROM vehicle_trip_summary
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY EXTRACT(MONTH FROM start_time)
|
||||||
|
ORDER BY month
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
data: result.rows.map((row) => ({
|
||||||
|
month: parseInt(row.month),
|
||||||
|
tripCount: parseInt(row.trip_count),
|
||||||
|
completedCount: parseInt(row.completed_count),
|
||||||
|
cancelledCount: parseInt(row.cancelled_count),
|
||||||
|
totalDistance: parseFloat(row.total_distance),
|
||||||
|
totalDuration: parseInt(row.total_duration),
|
||||||
|
avgDistance: parseFloat(row.avg_distance),
|
||||||
|
avgDuration: parseFloat(row.avg_duration),
|
||||||
|
driverCount: parseInt(row.driver_count),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약 통계 조회 (대시보드용)
|
||||||
|
*/
|
||||||
|
async getSummaryReport(companyCode: string, period: string) {
|
||||||
|
let dateCondition = "";
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case "today":
|
||||||
|
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||||
|
break;
|
||||||
|
case "week":
|
||||||
|
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'";
|
||||||
|
break;
|
||||||
|
case "month":
|
||||||
|
dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'";
|
||||||
|
break;
|
||||||
|
case "year":
|
||||||
|
dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dateCondition = "DATE(start_time) = CURRENT_DATE";
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_trips,
|
||||||
|
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips,
|
||||||
|
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips,
|
||||||
|
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration,
|
||||||
|
COUNT(DISTINCT user_id) as active_drivers
|
||||||
|
FROM vehicle_trip_summary
|
||||||
|
WHERE company_code = $1 AND ${dateCondition}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(query, [companyCode]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
|
||||||
|
// 완료율 계산
|
||||||
|
const totalTrips = parseInt(row.total_trips) || 0;
|
||||||
|
const completedTrips = parseInt(row.completed_trips) || 0;
|
||||||
|
const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
totalTrips,
|
||||||
|
completedTrips,
|
||||||
|
activeTrips: parseInt(row.active_trips) || 0,
|
||||||
|
cancelledTrips: parseInt(row.cancelled_trips) || 0,
|
||||||
|
completionRate: parseFloat(completionRate.toFixed(1)),
|
||||||
|
totalDistance: parseFloat(row.total_distance) || 0,
|
||||||
|
totalDuration: parseInt(row.total_duration) || 0,
|
||||||
|
avgDistance: parseFloat(row.avg_distance) || 0,
|
||||||
|
avgDuration: parseFloat(row.avg_duration) || 0,
|
||||||
|
activeDrivers: parseInt(row.active_drivers) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운전자별 통계 조회
|
||||||
|
*/
|
||||||
|
async getDriverReport(companyCode: string, filters: DriverReportFilters) {
|
||||||
|
const conditions: string[] = ["vts.company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
const limit = filters.limit || 10;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
vts.user_id,
|
||||||
|
ui.user_name,
|
||||||
|
COUNT(*) as trip_count,
|
||||||
|
COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance,
|
||||||
|
COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration,
|
||||||
|
COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance
|
||||||
|
FROM vehicle_trip_summary vts
|
||||||
|
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY vts.user_id, ui.user_name
|
||||||
|
ORDER BY total_distance DESC
|
||||||
|
LIMIT $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit);
|
||||||
|
const result = await this.pool.query(query, params);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
userId: row.user_id,
|
||||||
|
userName: row.user_name || row.user_id,
|
||||||
|
tripCount: parseInt(row.trip_count),
|
||||||
|
completedCount: parseInt(row.completed_count),
|
||||||
|
totalDistance: parseFloat(row.total_distance),
|
||||||
|
totalDuration: parseInt(row.total_duration),
|
||||||
|
avgDistance: parseFloat(row.avg_distance),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구간별 통계 조회
|
||||||
|
*/
|
||||||
|
async getRouteReport(companyCode: string, filters: RouteReportFilters) {
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`DATE(start_time) >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`DATE(start_time) <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 출발지/도착지가 있는 것만
|
||||||
|
conditions.push("departure IS NOT NULL");
|
||||||
|
conditions.push("arrival IS NOT NULL");
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
const limit = filters.limit || 10;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departure_name,
|
||||||
|
destination_name,
|
||||||
|
COUNT(*) as trip_count,
|
||||||
|
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance,
|
||||||
|
COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration
|
||||||
|
FROM vehicle_trip_summary
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY departure, arrival, departure_name, destination_name
|
||||||
|
ORDER BY trip_count DESC
|
||||||
|
LIMIT $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit);
|
||||||
|
const result = await this.pool.query(query, params);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
departure: row.departure,
|
||||||
|
arrival: row.arrival,
|
||||||
|
departureName: row.departure_name || row.departure,
|
||||||
|
destinationName: row.destination_name || row.arrival,
|
||||||
|
tripCount: parseInt(row.trip_count),
|
||||||
|
completedCount: parseInt(row.completed_count),
|
||||||
|
totalDistance: parseFloat(row.total_distance),
|
||||||
|
avgDistance: parseFloat(row.avg_distance),
|
||||||
|
avgDuration: parseFloat(row.avg_duration),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vehicleReportService = new VehicleReportService();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,456 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 이력 서비스
|
||||||
|
*/
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { calculateDistance } from "../utils/geoUtils";
|
||||||
|
|
||||||
|
interface StartTripParams {
|
||||||
|
userId: string;
|
||||||
|
companyCode: string;
|
||||||
|
vehicleId?: number;
|
||||||
|
departure?: string;
|
||||||
|
arrival?: string;
|
||||||
|
departureName?: string;
|
||||||
|
destinationName?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndTripParams {
|
||||||
|
tripId: string;
|
||||||
|
userId: string;
|
||||||
|
companyCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddLocationParams {
|
||||||
|
tripId: string;
|
||||||
|
userId: string;
|
||||||
|
companyCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracy?: number;
|
||||||
|
speed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripListFilters {
|
||||||
|
userId?: string;
|
||||||
|
vehicleId?: number;
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
departure?: string;
|
||||||
|
arrival?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VehicleTripService {
|
||||||
|
private get pool() {
|
||||||
|
return getPool();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 시작
|
||||||
|
*/
|
||||||
|
async startTrip(params: StartTripParams) {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
vehicleId,
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`;
|
||||||
|
|
||||||
|
// 1. vehicle_trip_summary에 운행 기록 생성
|
||||||
|
const summaryQuery = `
|
||||||
|
INSERT INTO vehicle_trip_summary (
|
||||||
|
trip_id, user_id, vehicle_id, departure, arrival,
|
||||||
|
departure_name, destination_name, start_time, status, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const summaryResult = await this.pool.query(summaryQuery, [
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
vehicleId || null,
|
||||||
|
departure || null,
|
||||||
|
arrival || null,
|
||||||
|
departureName || null,
|
||||||
|
destinationName || null,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 시작 위치 기록
|
||||||
|
const locationQuery = `
|
||||||
|
INSERT INTO vehicle_location_history (
|
||||||
|
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||||
|
trip_status, departure, arrival, departure_name, destination_name,
|
||||||
|
recorded_at, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.pool.query(locationQuery, [
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
vehicleId || null,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
departure || null,
|
||||||
|
arrival || null,
|
||||||
|
departureName || null,
|
||||||
|
destinationName || null,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tripId,
|
||||||
|
summary: summaryResult.rows[0],
|
||||||
|
startLocation: { latitude, longitude },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 종료
|
||||||
|
*/
|
||||||
|
async endTrip(params: EndTripParams) {
|
||||||
|
const { tripId, userId, companyCode, latitude, longitude } = params;
|
||||||
|
|
||||||
|
// 1. 운행 정보 조회
|
||||||
|
const tripQuery = `
|
||||||
|
SELECT * FROM vehicle_trip_summary
|
||||||
|
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||||
|
`;
|
||||||
|
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
||||||
|
|
||||||
|
if (tripResult.rows.length === 0) {
|
||||||
|
throw new Error("활성 운행을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const trip = tripResult.rows[0];
|
||||||
|
|
||||||
|
// 2. 마지막 위치 기록
|
||||||
|
const locationQuery = `
|
||||||
|
INSERT INTO vehicle_location_history (
|
||||||
|
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||||
|
trip_status, departure, arrival, departure_name, destination_name,
|
||||||
|
recorded_at, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.pool.query(locationQuery, [
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
trip.vehicle_id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
trip.departure,
|
||||||
|
trip.arrival,
|
||||||
|
trip.departure_name,
|
||||||
|
trip.destination_name,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. 총 거리 및 위치 수 계산
|
||||||
|
const statsQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as location_count,
|
||||||
|
MIN(recorded_at) as start_time,
|
||||||
|
MAX(recorded_at) as end_time
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE trip_id = $1 AND company_code = $2
|
||||||
|
`;
|
||||||
|
const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]);
|
||||||
|
const stats = statsResult.rows[0];
|
||||||
|
|
||||||
|
// 4. 모든 위치 데이터로 총 거리 계산
|
||||||
|
const locationsQuery = `
|
||||||
|
SELECT latitude, longitude
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE trip_id = $1 AND company_code = $2
|
||||||
|
ORDER BY recorded_at ASC
|
||||||
|
`;
|
||||||
|
const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]);
|
||||||
|
|
||||||
|
let totalDistance = 0;
|
||||||
|
const locations = locationsResult.rows;
|
||||||
|
for (let i = 1; i < locations.length; i++) {
|
||||||
|
const prev = locations[i - 1];
|
||||||
|
const curr = locations[i];
|
||||||
|
totalDistance += calculateDistance(
|
||||||
|
prev.latitude,
|
||||||
|
prev.longitude,
|
||||||
|
curr.latitude,
|
||||||
|
curr.longitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 운행 시간 계산 (분)
|
||||||
|
const startTime = new Date(stats.start_time);
|
||||||
|
const endTime = new Date(stats.end_time);
|
||||||
|
const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
||||||
|
|
||||||
|
// 6. 운행 요약 업데이트
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE vehicle_trip_summary
|
||||||
|
SET
|
||||||
|
end_time = NOW(),
|
||||||
|
total_distance = $1,
|
||||||
|
duration_minutes = $2,
|
||||||
|
location_count = $3,
|
||||||
|
status = 'completed'
|
||||||
|
WHERE trip_id = $4 AND company_code = $5
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateResult = await this.pool.query(updateQuery, [
|
||||||
|
totalDistance.toFixed(3),
|
||||||
|
durationMinutes,
|
||||||
|
stats.location_count,
|
||||||
|
tripId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tripId,
|
||||||
|
summary: updateResult.rows[0],
|
||||||
|
totalDistance: parseFloat(totalDistance.toFixed(3)),
|
||||||
|
durationMinutes,
|
||||||
|
locationCount: parseInt(stats.location_count),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 기록 추가 (연속 추적)
|
||||||
|
*/
|
||||||
|
async addLocation(params: AddLocationParams) {
|
||||||
|
const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params;
|
||||||
|
|
||||||
|
// 1. 운행 정보 조회
|
||||||
|
const tripQuery = `
|
||||||
|
SELECT * FROM vehicle_trip_summary
|
||||||
|
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||||
|
`;
|
||||||
|
const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]);
|
||||||
|
|
||||||
|
if (tripResult.rows.length === 0) {
|
||||||
|
throw new Error("활성 운행을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const trip = tripResult.rows[0];
|
||||||
|
|
||||||
|
// 2. 이전 위치 조회 (거리 계산용)
|
||||||
|
const prevLocationQuery = `
|
||||||
|
SELECT latitude, longitude
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE trip_id = $1 AND company_code = $2
|
||||||
|
ORDER BY recorded_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]);
|
||||||
|
|
||||||
|
let distanceFromPrev = 0;
|
||||||
|
if (prevResult.rows.length > 0) {
|
||||||
|
const prev = prevResult.rows[0];
|
||||||
|
distanceFromPrev = calculateDistance(
|
||||||
|
prev.latitude,
|
||||||
|
prev.longitude,
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 위치 기록 추가
|
||||||
|
const locationQuery = `
|
||||||
|
INSERT INTO vehicle_location_history (
|
||||||
|
trip_id, user_id, vehicle_id, latitude, longitude,
|
||||||
|
accuracy, speed, distance_from_prev,
|
||||||
|
trip_status, departure, arrival, departure_name, destination_name,
|
||||||
|
recorded_at, company_code
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.pool.query(locationQuery, [
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
trip.vehicle_id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy || null,
|
||||||
|
speed || null,
|
||||||
|
distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null,
|
||||||
|
trip.departure,
|
||||||
|
trip.arrival,
|
||||||
|
trip.departure_name,
|
||||||
|
trip.destination_name,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. 운행 요약의 위치 수 업데이트
|
||||||
|
await this.pool.query(
|
||||||
|
`UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`,
|
||||||
|
[tripId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationId: result.rows[0].id,
|
||||||
|
distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 이력 목록 조회
|
||||||
|
*/
|
||||||
|
async getTripList(companyCode: string, filters: TripListFilters) {
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.vehicleId) {
|
||||||
|
conditions.push(`vehicle_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`status = $${paramIndex++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`start_time >= $${paramIndex++}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`start_time <= $${paramIndex++}`);
|
||||||
|
params.push(filters.endDate + " 23:59:59");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.departure) {
|
||||||
|
conditions.push(`departure = $${paramIndex++}`);
|
||||||
|
params.push(filters.departure);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.arrival) {
|
||||||
|
conditions.push(`arrival = $${paramIndex++}`);
|
||||||
|
params.push(filters.arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
// 총 개수 조회
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`;
|
||||||
|
const countResult = await this.pool.query(countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0].total);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
const limit = filters.limit || 50;
|
||||||
|
const offset = filters.offset || 0;
|
||||||
|
|
||||||
|
const listQuery = `
|
||||||
|
SELECT
|
||||||
|
vts.*,
|
||||||
|
ui.user_name,
|
||||||
|
v.vehicle_number
|
||||||
|
FROM vehicle_trip_summary vts
|
||||||
|
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||||
|
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY vts.start_time DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const listResult = await this.pool.query(listQuery, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: listResult.rows,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 상세 조회 (경로 포함)
|
||||||
|
*/
|
||||||
|
async getTripDetail(tripId: string, companyCode: string) {
|
||||||
|
// 1. 운행 요약 조회
|
||||||
|
const summaryQuery = `
|
||||||
|
SELECT
|
||||||
|
vts.*,
|
||||||
|
ui.user_name,
|
||||||
|
v.vehicle_number
|
||||||
|
FROM vehicle_trip_summary vts
|
||||||
|
LEFT JOIN user_info ui ON vts.user_id = ui.user_id
|
||||||
|
LEFT JOIN vehicles v ON vts.vehicle_id = v.id
|
||||||
|
WHERE vts.trip_id = $1 AND vts.company_code = $2
|
||||||
|
`;
|
||||||
|
const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]);
|
||||||
|
|
||||||
|
if (summaryResult.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 경로 데이터 조회
|
||||||
|
const routeQuery = `
|
||||||
|
SELECT
|
||||||
|
id, latitude, longitude, accuracy, speed,
|
||||||
|
distance_from_prev, trip_status, recorded_at
|
||||||
|
FROM vehicle_location_history
|
||||||
|
WHERE trip_id = $1 AND company_code = $2
|
||||||
|
ORDER BY recorded_at ASC
|
||||||
|
`;
|
||||||
|
const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: summaryResult.rows[0],
|
||||||
|
route: routeResult.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 운행 조회
|
||||||
|
*/
|
||||||
|
async getActiveTrip(userId: string, companyCode: string) {
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM vehicle_trip_summary
|
||||||
|
WHERE user_id = $1 AND company_code = $2 AND status = 'active'
|
||||||
|
ORDER BY start_time DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const result = await this.pool.query(query, [userId, companyCode]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 취소
|
||||||
|
*/
|
||||||
|
async cancelTrip(tripId: string, companyCode: string) {
|
||||||
|
const query = `
|
||||||
|
UPDATE vehicle_trip_summary
|
||||||
|
SET status = 'cancelled', end_time = NOW()
|
||||||
|
WHERE trip_id = $1 AND company_code = $2 AND status = 'active'
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const result = await this.pool.query(query, [tripId, companyCode]);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vehicleTripService = new VehicleTripService();
|
||||||
|
|
@ -2,14 +2,38 @@
|
||||||
* 플로우 관리 시스템 타입 정의
|
* 플로우 관리 시스템 타입 정의
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 다중 REST API 연결 설정
|
||||||
|
export interface RestApiConnectionConfig {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
endpoint: string;
|
||||||
|
jsonPath: string;
|
||||||
|
alias: string; // 컬럼 접두어 (예: "api1_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다중 외부 DB 연결 설정
|
||||||
|
export interface ExternalDbConnectionConfig {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
dbType: string;
|
||||||
|
tableName: string;
|
||||||
|
alias: string; // 컬럼 접두어 (예: "db1_")
|
||||||
|
}
|
||||||
|
|
||||||
// 플로우 정의
|
// 플로우 정의
|
||||||
export interface FlowDefinition {
|
export interface FlowDefinition {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
|
||||||
dbConnectionId?: number; // 외부 DB 연결 ID (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; // 회사 코드 (* = 공통)
|
companyCode: string; // 회사 코드 (* = 공통)
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
|
@ -22,8 +46,14 @@ export interface CreateFlowDefinitionRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
|
||||||
dbConnectionId?: number; // 외부 DB 연결 ID
|
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 사용)
|
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* 지리 좌표 관련 유틸리티 함수
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km)
|
||||||
|
*
|
||||||
|
* @param lat1 - 첫 번째 지점의 위도
|
||||||
|
* @param lon1 - 첫 번째 지점의 경도
|
||||||
|
* @param lat2 - 두 번째 지점의 위도
|
||||||
|
* @param lon2 - 두 번째 지점의 경도
|
||||||
|
* @returns 두 지점 간의 거리 (km)
|
||||||
|
*/
|
||||||
|
export function calculateDistance(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number
|
||||||
|
): number {
|
||||||
|
const R = 6371; // 지구 반경 (km)
|
||||||
|
|
||||||
|
const dLat = toRadians(lat2 - lat1);
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRadians(lat1)) *
|
||||||
|
Math.cos(toRadians(lat2)) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 각도를 라디안으로 변환
|
||||||
|
*/
|
||||||
|
function toRadians(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라디안을 각도로 변환
|
||||||
|
*/
|
||||||
|
export function toDegrees(radians: number): number {
|
||||||
|
return radians * (180 / Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표 배열에서 총 거리 계산
|
||||||
|
*
|
||||||
|
* @param coordinates - { latitude, longitude }[] 형태의 좌표 배열
|
||||||
|
* @returns 총 거리 (km)
|
||||||
|
*/
|
||||||
|
export function calculateTotalDistance(
|
||||||
|
coordinates: Array<{ latitude: number; longitude: number }>
|
||||||
|
): number {
|
||||||
|
let totalDistance = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < coordinates.length; i++) {
|
||||||
|
const prev = coordinates[i - 1];
|
||||||
|
const curr = coordinates[i];
|
||||||
|
totalDistance += calculateDistance(
|
||||||
|
prev.latitude,
|
||||||
|
prev.longitude,
|
||||||
|
curr.latitude,
|
||||||
|
curr.longitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표가 특정 반경 내에 있는지 확인
|
||||||
|
*
|
||||||
|
* @param centerLat - 중심점 위도
|
||||||
|
* @param centerLon - 중심점 경도
|
||||||
|
* @param pointLat - 확인할 지점의 위도
|
||||||
|
* @param pointLon - 확인할 지점의 경도
|
||||||
|
* @param radiusKm - 반경 (km)
|
||||||
|
* @returns 반경 내에 있으면 true
|
||||||
|
*/
|
||||||
|
export function isWithinRadius(
|
||||||
|
centerLat: number,
|
||||||
|
centerLon: number,
|
||||||
|
pointLat: number,
|
||||||
|
pointLon: number,
|
||||||
|
radiusKm: number
|
||||||
|
): boolean {
|
||||||
|
const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon);
|
||||||
|
return distance <= radiusKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 좌표 사이의 방위각(bearing) 계산
|
||||||
|
*
|
||||||
|
* @param lat1 - 시작점 위도
|
||||||
|
* @param lon1 - 시작점 경도
|
||||||
|
* @param lat2 - 도착점 위도
|
||||||
|
* @param lon2 - 도착점 경도
|
||||||
|
* @returns 방위각 (0-360도)
|
||||||
|
*/
|
||||||
|
export function calculateBearing(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number
|
||||||
|
): number {
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
const lat1Rad = toRadians(lat1);
|
||||||
|
const lat2Rad = toRadians(lat2);
|
||||||
|
|
||||||
|
const x = Math.sin(dLon) * Math.cos(lat2Rad);
|
||||||
|
const y =
|
||||||
|
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||||
|
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
|
||||||
|
|
||||||
|
let bearing = toDegrees(Math.atan2(x, y));
|
||||||
|
bearing = (bearing + 360) % 360; // 0-360 범위로 정규화
|
||||||
|
|
||||||
|
return bearing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표 배열의 경계 상자(bounding box) 계산
|
||||||
|
*
|
||||||
|
* @param coordinates - 좌표 배열
|
||||||
|
* @returns { minLat, maxLat, minLon, maxLon }
|
||||||
|
*/
|
||||||
|
export function getBoundingBox(
|
||||||
|
coordinates: Array<{ latitude: number; longitude: number }>
|
||||||
|
): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null {
|
||||||
|
if (coordinates.length === 0) return null;
|
||||||
|
|
||||||
|
let minLat = coordinates[0].latitude;
|
||||||
|
let maxLat = coordinates[0].latitude;
|
||||||
|
let minLon = coordinates[0].longitude;
|
||||||
|
let maxLon = coordinates[0].longitude;
|
||||||
|
|
||||||
|
for (const coord of coordinates) {
|
||||||
|
minLat = Math.min(minLat, coord.latitude);
|
||||||
|
maxLat = Math.max(maxLat, coord.latitude);
|
||||||
|
minLon = Math.min(minLon, coord.longitude);
|
||||||
|
maxLon = Math.max(maxLon, coord.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minLat, maxLat, minLon, maxLon };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표 배열의 중심점 계산
|
||||||
|
*
|
||||||
|
* @param coordinates - 좌표 배열
|
||||||
|
* @returns { latitude, longitude } 중심점
|
||||||
|
*/
|
||||||
|
export function getCenterPoint(
|
||||||
|
coordinates: Array<{ latitude: number; longitude: number }>
|
||||||
|
): { latitude: number; longitude: number } | null {
|
||||||
|
if (coordinates.length === 0) return null;
|
||||||
|
|
||||||
|
let sumLat = 0;
|
||||||
|
let sumLon = 0;
|
||||||
|
|
||||||
|
for (const coord of coordinates) {
|
||||||
|
sumLat += coord.latitude;
|
||||||
|
sumLon += coord.longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: sumLat / coordinates.length,
|
||||||
|
longitude: sumLon / coordinates.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const VehicleReport = dynamic(
|
||||||
|
() => import("@/components/vehicle/VehicleReport"),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function VehicleReportsPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">운행 리포트</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
차량 운행 통계 및 분석 리포트를 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<VehicleReport />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const VehicleTripHistory = dynamic(
|
||||||
|
() => import("@/components/vehicle/VehicleTripHistory"),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function VehicleTripsPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">운행 이력 관리</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
차량 운행 이력을 조회하고 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<VehicleTripHistory />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -317,6 +317,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-background">
|
<TableRow className="bg-background">
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
||||||
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">DB 타입</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">DB 타입</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">호스트:포트</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">호스트:포트</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">데이터베이스</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">데이터베이스</TableHead>
|
||||||
|
|
@ -333,6 +334,9 @@ export default function ExternalConnectionsPage() {
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<div className="font-medium">{connection.connection_name}</div>
|
<div className="font-medium">{connection.connection_name}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
|
{(connection as any).company_name || connection.company_code}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,10 @@ export default function FlowEditorPage() {
|
||||||
flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달
|
flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달
|
||||||
flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달
|
flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달
|
||||||
flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달
|
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)}
|
onClose={() => setSelectedStep(null)}
|
||||||
onUpdate={loadFlowData}
|
onUpdate={loadFlowData}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { formatErrorMessage } from "@/lib/utils/errorUtils";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||||
|
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||||
|
|
||||||
export default function FlowManagementPage() {
|
export default function FlowManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -52,13 +53,42 @@ export default function FlowManagementPage() {
|
||||||
);
|
);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
||||||
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
|
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
|
||||||
|
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
|
||||||
const [externalConnections, setExternalConnections] = useState<
|
const [externalConnections, setExternalConnections] = useState<
|
||||||
Array<{ id: number; connection_name: string; db_type: string }>
|
Array<{ id: number; connection_name: string; db_type: string }>
|
||||||
>([]);
|
>([]);
|
||||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -135,75 +165,288 @@ export default function FlowManagementPage() {
|
||||||
loadConnections();
|
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 테이블 목록 로드
|
// 외부 DB 테이블 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDbSource === "internal" || !selectedDbSource) {
|
// REST API인 경우 테이블 목록 로드 불필요
|
||||||
|
if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) {
|
||||||
setExternalTableList([]);
|
setExternalTableList([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadExternalTables = async () => {
|
// 외부 DB인 경우
|
||||||
try {
|
if (selectedDbSource.startsWith("external_db_")) {
|
||||||
setLoadingExternalTables(true);
|
const connectionId = selectedDbSource.replace("external_db_", "");
|
||||||
const token = localStorage.getItem("authToken");
|
|
||||||
|
const loadExternalTables = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingExternalTables(true);
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
|
||||||
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
|
const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
const tables = Array.isArray(data.data) ? data.data : [];
|
const tables = Array.isArray(data.data) ? data.data : [];
|
||||||
const tableNames = tables
|
const tableNames = tables
|
||||||
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
.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,
|
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
||||||
)
|
)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
setExternalTableList(tableNames);
|
setExternalTableList(tableNames);
|
||||||
|
} else {
|
||||||
|
setExternalTableList([]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setExternalTableList([]);
|
setExternalTableList([]);
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
setExternalTableList([]);
|
setExternalTableList([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingExternalTables(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
};
|
||||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
|
||||||
setExternalTableList([]);
|
|
||||||
} finally {
|
|
||||||
setLoadingExternalTables(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadExternalTables();
|
loadExternalTables();
|
||||||
|
}
|
||||||
}, [selectedDbSource]);
|
}, [selectedDbSource]);
|
||||||
|
|
||||||
|
// 다중 외부 DB 추가
|
||||||
|
const addExternalDbConfig = async (connectionId: number) => {
|
||||||
|
const connection = externalConnections.find(c => c.id === connectionId);
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
// 이미 추가된 경우 스킵
|
||||||
|
if (selectedExternalDbs.some(db => db.connectionId === connectionId)) {
|
||||||
|
toast({
|
||||||
|
title: "이미 추가됨",
|
||||||
|
description: "해당 외부 DB가 이미 추가되어 있습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해당 DB의 테이블 목록 로드
|
||||||
|
try {
|
||||||
|
const data = await ExternalDbConnectionAPI.getTables(connectionId);
|
||||||
|
if (data.success && data.data) {
|
||||||
|
const tables = Array.isArray(data.data) ? data.data : [];
|
||||||
|
const tableNames = tables
|
||||||
|
.map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
|
||||||
|
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig: ExternalDbConfig = {
|
||||||
|
connectionId,
|
||||||
|
connectionName: connection.connection_name,
|
||||||
|
dbType: connection.db_type,
|
||||||
|
tableName: "",
|
||||||
|
alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedExternalDbs([...selectedExternalDbs, newConfig]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중 외부 DB 삭제
|
||||||
|
const removeExternalDbConfig = (connectionId: number) => {
|
||||||
|
setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중 외부 DB 설정 업데이트
|
||||||
|
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
|
||||||
|
setSelectedExternalDbs(selectedExternalDbs.map(db =>
|
||||||
|
db.connectionId === connectionId ? { ...db, [field]: value } : db
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중 REST API 추가
|
||||||
|
const addRestApiConfig = (connectionId: number) => {
|
||||||
|
const connection = restApiConnections.find(c => c.id === connectionId);
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
// 이미 추가된 경우 스킵
|
||||||
|
if (selectedRestApis.some(api => api.connectionId === connectionId)) {
|
||||||
|
toast({
|
||||||
|
title: "이미 추가됨",
|
||||||
|
description: "해당 REST API가 이미 추가되어 있습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 테이블의 기본값 사용
|
||||||
|
const newConfig: RestApiConfig = {
|
||||||
|
connectionId,
|
||||||
|
connectionName: connection.connection_name,
|
||||||
|
endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트
|
||||||
|
jsonPath: "response", // 기본값
|
||||||
|
alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedRestApis([...selectedRestApis, newConfig]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중 REST API 삭제
|
||||||
|
const removeRestApiConfig = (connectionId: number) => {
|
||||||
|
setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중 REST API 설정 업데이트
|
||||||
|
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
|
||||||
|
setSelectedRestApis(selectedRestApis.map(api =>
|
||||||
|
api.connectionId === connectionId ? { ...api, [field]: value } : api
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
// 플로우 생성
|
// 플로우 생성
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
console.log("🚀 handleCreate called with formData:", formData);
|
console.log("🚀 handleCreate called with formData:", formData);
|
||||||
|
|
||||||
if (!formData.name || !formData.tableName) {
|
// REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵
|
||||||
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName });
|
const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi;
|
||||||
|
const isMultiMode = isMultiRestApi || isMultiExternalDb;
|
||||||
|
|
||||||
|
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
|
||||||
|
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode });
|
||||||
toast({
|
toast({
|
||||||
title: "입력 오류",
|
title: "입력 오류",
|
||||||
description: "플로우 이름과 테이블 이름은 필수입니다.",
|
description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다중 REST API 모드인 경우 검증
|
||||||
|
if (isMultiRestApi) {
|
||||||
|
if (selectedRestApis.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "입력 오류",
|
||||||
|
description: "최소 하나의 REST API를 추가해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 API의 엔드포인트 검증
|
||||||
|
const missingEndpoint = selectedRestApis.find(api => !api.endpoint);
|
||||||
|
if (missingEndpoint) {
|
||||||
|
toast({
|
||||||
|
title: "입력 오류",
|
||||||
|
description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (isMultiExternalDb) {
|
||||||
|
// 다중 외부 DB 모드인 경우 검증
|
||||||
|
if (selectedExternalDbs.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "입력 오류",
|
||||||
|
description: "최소 하나의 외부 DB를 추가해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 DB의 테이블 선택 검증
|
||||||
|
const missingTable = selectedExternalDbs.find(db => !db.tableName);
|
||||||
|
if (missingTable) {
|
||||||
|
toast({
|
||||||
|
title: "입력 오류",
|
||||||
|
description: `${missingTable.connectionName}의 테이블을 선택해주세요.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (isRestApi && !restApiEndpoint) {
|
||||||
|
// 단일 REST API인 경우 엔드포인트 검증
|
||||||
|
toast({
|
||||||
|
title: "입력 오류",
|
||||||
|
description: "REST API 엔드포인트는 필수입니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// DB 소스 정보 추가
|
// 데이터 소스 타입 및 ID 파싱
|
||||||
const requestData = {
|
let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal";
|
||||||
|
let dbConnectionId: number | undefined = undefined;
|
||||||
|
let restApiConnectionId: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (isMultiRestApi) {
|
||||||
|
dbSourceType = "multi_restapi";
|
||||||
|
} else if (isMultiExternalDb) {
|
||||||
|
dbSourceType = "multi_external_db";
|
||||||
|
} else if (selectedDbSource === "internal") {
|
||||||
|
dbSourceType = "internal";
|
||||||
|
} else if (selectedDbSource.startsWith("external_db_")) {
|
||||||
|
dbSourceType = "external";
|
||||||
|
dbConnectionId = parseInt(selectedDbSource.replace("external_db_", ""));
|
||||||
|
} else if (selectedDbSource.startsWith("restapi_")) {
|
||||||
|
dbSourceType = "restapi";
|
||||||
|
restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요청 데이터 구성
|
||||||
|
const requestData: Record<string, unknown> = {
|
||||||
...formData,
|
...formData,
|
||||||
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
dbSourceType,
|
||||||
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
dbConnectionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 다중 REST API인 경우
|
||||||
|
if (dbSourceType === "multi_restapi") {
|
||||||
|
requestData.restApiConnections = selectedRestApis;
|
||||||
|
// 다중 REST API는 첫 번째 API의 ID를 기본으로 사용
|
||||||
|
requestData.restApiConnectionId = selectedRestApis[0]?.connectionId;
|
||||||
|
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
|
||||||
|
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
|
||||||
|
// 가상 테이블명: 모든 연결 ID를 조합
|
||||||
|
requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`;
|
||||||
|
} else if (dbSourceType === "multi_external_db") {
|
||||||
|
// 다중 외부 DB인 경우
|
||||||
|
requestData.externalDbConnections = selectedExternalDbs;
|
||||||
|
// 첫 번째 DB의 ID를 기본으로 사용
|
||||||
|
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
|
||||||
|
// 가상 테이블명: 모든 연결 ID와 테이블명 조합
|
||||||
|
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`;
|
||||||
|
} else if (dbSourceType === "restapi") {
|
||||||
|
// 단일 REST API인 경우
|
||||||
|
requestData.restApiConnectionId = restApiConnectionId;
|
||||||
|
requestData.restApiEndpoint = restApiEndpoint;
|
||||||
|
requestData.restApiJsonPath = restApiJsonPath || "response";
|
||||||
|
// REST API는 가상 테이블명 사용
|
||||||
|
requestData.tableName = `_restapi_${restApiConnectionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("✅ Calling createFlowDefinition with:", requestData);
|
console.log("✅ Calling createFlowDefinition with:", requestData);
|
||||||
const response = await createFlowDefinition(requestData);
|
const response = await createFlowDefinition(requestData as Parameters<typeof createFlowDefinition>[0]);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
toast({
|
toast({
|
||||||
title: "생성 완료",
|
title: "생성 완료",
|
||||||
|
|
@ -212,6 +455,12 @@ export default function FlowManagementPage() {
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
setFormData({ name: "", description: "", tableName: "" });
|
setFormData({ name: "", description: "", tableName: "" });
|
||||||
setSelectedDbSource("internal");
|
setSelectedDbSource("internal");
|
||||||
|
setRestApiEndpoint("");
|
||||||
|
setRestApiJsonPath("response");
|
||||||
|
setSelectedRestApis([]);
|
||||||
|
setSelectedExternalDbs([]);
|
||||||
|
setIsMultiRestApi(false);
|
||||||
|
setIsMultiExternalDb(false);
|
||||||
loadFlows();
|
loadFlows();
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -415,125 +664,373 @@ export default function FlowManagementPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DB 소스 선택 */}
|
{/* 데이터 소스 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">데이터베이스 소스</Label>
|
<Label className="text-xs sm:text-sm">데이터 소스</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedDbSource.toString()}
|
value={isMultiRestApi ? "multi_restapi" : isMultiExternalDb ? "multi_external_db" : selectedDbSource}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const dbSource = value === "internal" ? "internal" : parseInt(value);
|
if (value === "multi_restapi") {
|
||||||
setSelectedDbSource(dbSource);
|
setIsMultiRestApi(true);
|
||||||
// DB 소스 변경 시 테이블 선택 초기화
|
setIsMultiExternalDb(false);
|
||||||
|
setSelectedDbSource("internal");
|
||||||
|
} else if (value === "multi_external_db") {
|
||||||
|
setIsMultiExternalDb(true);
|
||||||
|
setIsMultiRestApi(false);
|
||||||
|
setSelectedDbSource("internal");
|
||||||
|
} else {
|
||||||
|
setIsMultiRestApi(false);
|
||||||
|
setIsMultiExternalDb(false);
|
||||||
|
setSelectedDbSource(value);
|
||||||
|
}
|
||||||
|
// 소스 변경 시 초기화
|
||||||
setFormData({ ...formData, tableName: "" });
|
setFormData({ ...formData, tableName: "" });
|
||||||
|
setRestApiEndpoint("");
|
||||||
|
setRestApiJsonPath("response");
|
||||||
|
setSelectedRestApis([]);
|
||||||
|
setSelectedExternalDbs([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue placeholder="데이터베이스 선택" />
|
<SelectValue placeholder="데이터 소스 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{/* 내부 DB */}
|
||||||
<SelectItem value="internal">내부 데이터베이스</SelectItem>
|
<SelectItem value="internal">내부 데이터베이스</SelectItem>
|
||||||
{externalConnections.map((conn) => (
|
|
||||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
{/* 외부 DB 연결 */}
|
||||||
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
{externalConnections.length > 0 && (
|
||||||
</SelectItem>
|
<>
|
||||||
))}
|
<SelectItem value="__divider_db__" disabled className="text-muted-foreground text-xs">
|
||||||
|
-- 외부 데이터베이스 --
|
||||||
|
</SelectItem>
|
||||||
|
{externalConnections.map((conn) => (
|
||||||
|
<SelectItem key={`db_${conn.id}`} value={`external_db_${conn.id}`}>
|
||||||
|
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* REST API 연결 */}
|
||||||
|
{restApiConnections.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SelectItem value="__divider_api__" disabled className="text-muted-foreground text-xs">
|
||||||
|
-- REST API --
|
||||||
|
</SelectItem>
|
||||||
|
{restApiConnections.map((conn) => (
|
||||||
|
<SelectItem key={`api_${conn.id}`} value={`restapi_${conn.id}`}>
|
||||||
|
{conn.connection_name} (REST API)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 다중 연결 옵션 */}
|
||||||
|
{(externalConnections.length > 0 || restApiConnections.length > 0) && (
|
||||||
|
<>
|
||||||
|
<SelectItem value="__divider_multi__" disabled className="text-muted-foreground text-xs">
|
||||||
|
-- 다중 연결 (데이터 병합) --
|
||||||
|
</SelectItem>
|
||||||
|
{externalConnections.length > 0 && (
|
||||||
|
<SelectItem value="multi_external_db">
|
||||||
|
다중 외부 DB (데이터 병합)
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{restApiConnections.length > 0 && (
|
||||||
|
<SelectItem value="multi_restapi">
|
||||||
|
다중 REST API (데이터 병합)
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
플로우에서 사용할 데이터베이스를 선택합니다
|
플로우에서 사용할 데이터 소스를 선택합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
{/* 다중 REST API 선택 UI */}
|
||||||
<div>
|
{isMultiRestApi && (
|
||||||
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
<div className="space-y-3">
|
||||||
연결 테이블 *
|
<div className="flex items-center justify-between">
|
||||||
</Label>
|
<Label className="text-xs sm:text-sm">REST API 연결 목록</Label>
|
||||||
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
<Select
|
||||||
<PopoverTrigger asChild>
|
value=""
|
||||||
<Button
|
onValueChange={(value) => {
|
||||||
variant="outline"
|
if (value) {
|
||||||
role="combobox"
|
addRestApiConfig(parseInt(value));
|
||||||
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
|
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
|
||||||
? selectedDbSource === "internal"
|
<SelectValue placeholder="API 추가..." />
|
||||||
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
</SelectTrigger>
|
||||||
formData.tableName
|
<SelectContent>
|
||||||
: formData.tableName
|
{restApiConnections
|
||||||
: loadingTables || loadingExternalTables
|
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id))
|
||||||
? "로딩 중..."
|
.map((conn) => (
|
||||||
: "테이블 선택"}
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
{conn.connection_name}
|
||||||
</Button>
|
</SelectItem>
|
||||||
</PopoverTrigger>
|
))}
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
</SelectContent>
|
||||||
<Command>
|
</Select>
|
||||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
</div>
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
{selectedRestApis.length === 0 ? (
|
||||||
<CommandGroup>
|
<div className="rounded-md border border-dashed p-4 text-center">
|
||||||
{selectedDbSource === "internal"
|
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||||
? // 내부 DB 테이블 목록
|
위에서 REST API를 추가해주세요
|
||||||
tableList.map((table) => (
|
</p>
|
||||||
<CommandItem
|
</div>
|
||||||
key={table.tableName}
|
) : (
|
||||||
value={table.tableName}
|
<div className="space-y-2">
|
||||||
onSelect={(currentValue) => {
|
{selectedRestApis.map((api) => (
|
||||||
console.log("📝 Internal table selected:", {
|
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
|
||||||
tableName: table.tableName,
|
<div className="flex items-center gap-2">
|
||||||
currentValue,
|
<span className="text-sm font-medium">{api.connectionName}</span>
|
||||||
});
|
<span className="text-muted-foreground text-xs">
|
||||||
setFormData({ ...formData, tableName: currentValue });
|
({api.endpoint || "기본 엔드포인트"})
|
||||||
setOpenTableCombobox(false);
|
</span>
|
||||||
}}
|
</div>
|
||||||
className="text-xs sm:text-sm"
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Check
|
size="sm"
|
||||||
className={cn(
|
className="h-6 w-6 p-0"
|
||||||
"mr-2 h-4 w-4",
|
onClick={() => removeRestApiConfig(api.connectionId)}
|
||||||
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
>
|
||||||
)}
|
<Trash2 className="h-3 w-3" />
|
||||||
/>
|
</Button>
|
||||||
<div className="flex flex-col">
|
</div>
|
||||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
))}
|
||||||
{table.description && (
|
</div>
|
||||||
<span className="text-[10px] text-gray-500">{table.description}</span>
|
)}
|
||||||
)}
|
<p className="text-muted-foreground text-[10px] sm:text-xs">
|
||||||
</div>
|
선택한 REST API들의 데이터가 자동으로 병합됩니다.
|
||||||
</CommandItem>
|
</p>
|
||||||
))
|
</div>
|
||||||
: // 외부 DB 테이블 목록
|
)}
|
||||||
externalTableList.map((tableName, index) => (
|
|
||||||
<CommandItem
|
{/* 다중 외부 DB 선택 UI */}
|
||||||
key={`external-${selectedDbSource}-${tableName}-${index}`}
|
{isMultiExternalDb && (
|
||||||
value={tableName}
|
<div className="space-y-3">
|
||||||
onSelect={(currentValue) => {
|
<div className="flex items-center justify-between">
|
||||||
setFormData({ ...formData, tableName: currentValue });
|
<Label className="text-xs sm:text-sm">외부 DB 연결 목록</Label>
|
||||||
setOpenTableCombobox(false);
|
<Select
|
||||||
}}
|
value=""
|
||||||
className="text-xs sm:text-sm"
|
onValueChange={(value) => {
|
||||||
>
|
if (value) {
|
||||||
<Check
|
addExternalDbConfig(parseInt(value));
|
||||||
className={cn(
|
}
|
||||||
"mr-2 h-4 w-4",
|
}}
|
||||||
formData.tableName === tableName ? "opacity-100" : "opacity-0",
|
>
|
||||||
)}
|
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
|
||||||
/>
|
<SelectValue placeholder="DB 추가..." />
|
||||||
<div>{tableName}</div>
|
</SelectTrigger>
|
||||||
</CommandItem>
|
<SelectContent>
|
||||||
))}
|
{externalConnections
|
||||||
</CommandGroup>
|
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id))
|
||||||
</CommandList>
|
.map((conn) => (
|
||||||
</Command>
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
</PopoverContent>
|
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||||
</Popover>
|
</SelectItem>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
))}
|
||||||
플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)
|
</SelectContent>
|
||||||
</p>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedExternalDbs.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed p-4 text-center">
|
||||||
|
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||||
|
위에서 외부 DB를 추가해주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedExternalDbs.map((db) => (
|
||||||
|
<div key={db.connectionId} className="rounded-md border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{db.connectionName} ({db.dbType?.toUpperCase()})
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => removeExternalDbConfig(db.connectionId)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={db.tableName}
|
||||||
|
onValueChange={(value) => updateExternalDbConfig(db.connectionId, "tableName", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(multiDbTableLists[db.connectionId] || []).map((table) => (
|
||||||
|
<SelectItem key={table} value={table}>
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">별칭 (접두어)</Label>
|
||||||
|
<Input
|
||||||
|
value={db.alias}
|
||||||
|
onChange={(e) => updateExternalDbConfig(db.connectionId, "alias", e.target.value)}
|
||||||
|
placeholder="db1_"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-[10px] sm:text-xs">
|
||||||
|
선택한 외부 DB들의 데이터가 자동으로 병합됩니다. 각 DB별 테이블을 선택해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 단일 REST API인 경우 엔드포인트 설정 */}
|
||||||
|
{!isMultiRestApi && selectedDbSource.startsWith("restapi_") && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="restApiEndpoint" className="text-xs sm:text-sm">
|
||||||
|
API 엔드포인트 *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="restApiEndpoint"
|
||||||
|
value={restApiEndpoint}
|
||||||
|
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
||||||
|
placeholder="예: /api/data/list"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
데이터를 조회할 API 엔드포인트 경로입니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="restApiJsonPath" className="text-xs sm:text-sm">
|
||||||
|
JSON 경로
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="restApiJsonPath"
|
||||||
|
value={restApiJsonPath}
|
||||||
|
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
||||||
|
placeholder="예: data 또는 result.items"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
응답 JSON에서 데이터 배열의 경로입니다 (기본: data)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */}
|
||||||
|
{!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
||||||
|
연결 테이블 *
|
||||||
|
</Label>
|
||||||
|
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={openTableCombobox}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
|
||||||
|
>
|
||||||
|
{formData.tableName
|
||||||
|
? selectedDbSource === "internal"
|
||||||
|
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
||||||
|
formData.tableName
|
||||||
|
: formData.tableName
|
||||||
|
: loadingTables || loadingExternalTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{selectedDbSource === "internal"
|
||||||
|
? // 내부 DB 테이블 목록
|
||||||
|
tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={table.tableName}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
console.log("📝 Internal table selected:", {
|
||||||
|
tableName: table.tableName,
|
||||||
|
currentValue,
|
||||||
|
});
|
||||||
|
setFormData({ ...formData, tableName: currentValue });
|
||||||
|
setOpenTableCombobox(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
|
{table.description && (
|
||||||
|
<span className="text-[10px] text-gray-500">{table.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))
|
||||||
|
: // 외부 DB 테이블 목록
|
||||||
|
externalTableList.map((tableName, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`external-${selectedDbSource}-${tableName}-${index}`}
|
||||||
|
value={tableName}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
setFormData({ ...formData, tableName: currentValue });
|
||||||
|
setOpenTableCombobox(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>{tableName}</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -1093,229 +1093,283 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
||||||
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
||||||
<div className="flex h-full flex-col space-y-4 overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{!selectedTable ? (
|
||||||
{!selectedTable ? (
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<p className="text-muted-foreground text-sm">
|
||||||
<p className="text-muted-foreground text-sm">
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
{/* 테이블 라벨 설정 */}
|
<>
|
||||||
<div className="mb-4 flex items-center gap-4">
|
{/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */}
|
||||||
<div className="flex-1">
|
<div className="mb-4 flex items-center gap-4">
|
||||||
<Input
|
<div className="flex-1">
|
||||||
value={tableLabel}
|
<Input
|
||||||
onChange={(e) => setTableLabel(e.target.value)}
|
value={tableLabel}
|
||||||
placeholder="테이블 표시명"
|
onChange={(e) => setTableLabel(e.target.value)}
|
||||||
className="h-10 text-sm"
|
placeholder="테이블 표시명"
|
||||||
/>
|
className="h-10 text-sm"
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={tableDescription}
|
|
||||||
onChange={(e) => setTableDescription(e.target.value)}
|
|
||||||
placeholder="테이블 설명"
|
|
||||||
className="h-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={tableDescription}
|
||||||
|
onChange={(e) => setTableDescription(e.target.value)}
|
||||||
|
placeholder="테이블 설명"
|
||||||
|
className="h-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
|
||||||
|
<Button
|
||||||
|
onClick={saveAllSettings}
|
||||||
|
disabled={!selectedTable || columns.length === 0}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
전체 설정 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{columnsLoading ? (
|
{columnsLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="text-muted-foreground ml-2 text-sm">
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
) : columns.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* 컬럼 헤더 (고정) */}
|
||||||
|
<div className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
||||||
|
<div className="pr-4">컬럼명</div>
|
||||||
|
<div className="px-4">라벨</div>
|
||||||
|
<div className="pr-6">입력 타입</div>
|
||||||
|
<div className="pl-4">설명</div>
|
||||||
</div>
|
</div>
|
||||||
) : columns.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 컬럼 헤더 */}
|
|
||||||
<div className="text-foreground grid h-12 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
|
||||||
<div className="pr-4">컬럼명</div>
|
|
||||||
<div className="px-4">라벨</div>
|
|
||||||
<div className="pr-6">입력 타입</div>
|
|
||||||
<div className="pl-4">설명</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컬럼 리스트 */}
|
{/* 컬럼 리스트 (스크롤 영역) */}
|
||||||
<div
|
<div
|
||||||
className="max-h-96 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
||||||
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
||||||
loadMoreColumns();
|
loadMoreColumns();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map((column, index) => (
|
{columns.map((column, index) => (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||||
>
|
>
|
||||||
<div className="pr-4 pt-1">
|
<div className="pr-4 pt-1">
|
||||||
<div className="font-mono text-sm">{column.columnName}</div>
|
<div className="font-mono text-sm">{column.columnName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<Input
|
<Input
|
||||||
value={column.displayName || ""}
|
value={column.displayName || ""}
|
||||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||||
placeholder={column.columnName}
|
placeholder={column.columnName}
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pr-6">
|
<div className="pr-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 입력 타입 선택 */}
|
{/* 입력 타입 선택 */}
|
||||||
|
<Select
|
||||||
|
value={column.inputType || "text"}
|
||||||
|
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="입력 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{memoizedInputTypeOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
||||||
|
{column.inputType === "code" && (
|
||||||
<Select
|
<Select
|
||||||
value={column.inputType || "text"}
|
value={column.codeCategory || "none"}
|
||||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
onValueChange={(value) =>
|
||||||
|
handleDetailSettingsChange(column.columnName, "code", value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="입력 타입 선택" />
|
<SelectValue placeholder="공통코드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{memoizedInputTypeOptions.map((option) => (
|
{commonCodeOptions.map((option, index) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
)}
|
||||||
{column.inputType === "code" && (
|
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||||
<Select
|
{column.inputType === "category" && (
|
||||||
value={column.codeCategory || "none"}
|
<div className="space-y-2">
|
||||||
onValueChange={(value) =>
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
handleDetailSettingsChange(column.columnName, "code", value)
|
적용할 메뉴 (2레벨)
|
||||||
}
|
</label>
|
||||||
>
|
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||||
<SelectTrigger className="h-8 text-xs">
|
{secondLevelMenus.length === 0 ? (
|
||||||
<SelectValue placeholder="공통코드 선택" />
|
<p className="text-xs text-muted-foreground">
|
||||||
</SelectTrigger>
|
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||||
<SelectContent>
|
|
||||||
{commonCodeOptions.map((option, index) => (
|
|
||||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
|
||||||
{column.inputType === "category" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
|
||||||
적용할 메뉴 (2레벨)
|
|
||||||
</label>
|
|
||||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
|
||||||
{secondLevelMenus.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
secondLevelMenus.map((menu) => {
|
|
||||||
// menuObjid를 숫자로 변환하여 비교
|
|
||||||
const menuObjidNum = Number(menu.menuObjid);
|
|
||||||
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={menu.menuObjid} className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={(e) => {
|
|
||||||
const currentMenus = column.categoryMenus || [];
|
|
||||||
const newMenus = e.target.checked
|
|
||||||
? [...currentMenus, menuObjidNum]
|
|
||||||
: currentMenus.filter((id) => id !== menuObjidNum);
|
|
||||||
|
|
||||||
setColumns((prev) =>
|
|
||||||
prev.map((col) =>
|
|
||||||
col.columnName === column.columnName
|
|
||||||
? { ...col, categoryMenus: newMenus }
|
|
||||||
: col
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
|
||||||
className="text-xs cursor-pointer flex-1"
|
|
||||||
>
|
|
||||||
{menu.parentMenuName} → {menu.menuName}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
|
||||||
<p className="text-primary text-xs">
|
|
||||||
{column.categoryMenus.length}개 메뉴 선택됨
|
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
secondLevelMenus.map((menu) => {
|
||||||
|
// menuObjid를 숫자로 변환하여 비교
|
||||||
|
const menuObjidNum = Number(menu.menuObjid);
|
||||||
|
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={menu.menuObjid} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentMenus = column.categoryMenus || [];
|
||||||
|
const newMenus = e.target.checked
|
||||||
|
? [...currentMenus, menuObjidNum]
|
||||||
|
: currentMenus.filter((id) => id !== menuObjidNum);
|
||||||
|
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((col) =>
|
||||||
|
col.columnName === column.columnName
|
||||||
|
? { ...col, categoryMenus: newMenus }
|
||||||
|
: col
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||||
|
className="text-xs cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
{menu.parentMenuName} → {menu.menuName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
<p className="text-primary text-xs">
|
||||||
{column.inputType === "entity" && (
|
{column.categoryMenus.length}개 메뉴 선택됨
|
||||||
<>
|
</p>
|
||||||
{/* 참조 테이블 */}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
|
{column.inputType === "entity" && (
|
||||||
|
<>
|
||||||
|
{/* 참조 테이블 */}
|
||||||
|
<div className="w-48">
|
||||||
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
|
참조 테이블
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={column.referenceTable || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{referenceTableOptions.map((option, index) => (
|
||||||
|
<SelectItem
|
||||||
|
key={`entity-${option.value}-${index}`}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{option.label}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{option.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 컬럼 */}
|
||||||
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
참조 테이블
|
조인 컬럼
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceTable || "none"}
|
value={column.referenceColumn || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
handleDetailSettingsChange(
|
||||||
|
column.columnName,
|
||||||
|
"entity_reference_column",
|
||||||
|
value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceTableOptions.map((option, index) => (
|
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||||
|
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={`entity-${option.value}-${index}`}
|
key={`ref-col-${refCol.columnName}-${index}`}
|
||||||
value={option.value}
|
value={refCol.columnName}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<span className="font-medium">{refCol.columnName}</span>
|
||||||
<span className="font-medium">{option.label}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{option.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{(!referenceTableColumns[column.referenceTable] ||
|
||||||
|
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||||
|
로딩중
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 조인 컬럼 */}
|
{/* 표시 컬럼 */}
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
{column.referenceTable &&
|
||||||
|
column.referenceTable !== "none" &&
|
||||||
|
column.referenceColumn &&
|
||||||
|
column.referenceColumn !== "none" && (
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
조인 컬럼
|
표시 컬럼
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceColumn || "none"}
|
value={column.displayColumn || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleDetailSettingsChange(
|
handleDetailSettingsChange(
|
||||||
column.columnName,
|
column.columnName,
|
||||||
"entity_reference_column",
|
"entity_display_column",
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1347,79 +1401,32 @@ export default function TableManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 표시 컬럼 */}
|
{/* 설정 완료 표시 */}
|
||||||
{column.referenceTable &&
|
{column.referenceTable &&
|
||||||
column.referenceTable !== "none" &&
|
column.referenceTable !== "none" &&
|
||||||
column.referenceColumn &&
|
column.referenceColumn &&
|
||||||
column.referenceColumn !== "none" && (
|
column.referenceColumn !== "none" &&
|
||||||
<div className="w-48">
|
column.displayColumn &&
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
column.displayColumn !== "none" && (
|
||||||
표시 컬럼
|
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
||||||
</label>
|
<span>✓</span>
|
||||||
<Select
|
<span className="truncate">설정 완료</span>
|
||||||
value={column.displayColumn || "none"}
|
</div>
|
||||||
onValueChange={(value) =>
|
)}
|
||||||
handleDetailSettingsChange(
|
</>
|
||||||
column.columnName,
|
)}
|
||||||
"entity_display_column",
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
|
||||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
|
||||||
<SelectItem
|
|
||||||
key={`ref-col-${refCol.columnName}-${index}`}
|
|
||||||
value={refCol.columnName}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{(!referenceTableColumns[column.referenceTable] ||
|
|
||||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
|
||||||
로딩중
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 설정 완료 표시 */}
|
|
||||||
{column.referenceTable &&
|
|
||||||
column.referenceTable !== "none" &&
|
|
||||||
column.referenceColumn &&
|
|
||||||
column.referenceColumn !== "none" &&
|
|
||||||
column.displayColumn &&
|
|
||||||
column.displayColumn !== "none" && (
|
|
||||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
|
||||||
<span>✓</span>
|
|
||||||
<span className="truncate">설정 완료</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pl-4">
|
|
||||||
<Input
|
|
||||||
value={column.description || ""}
|
|
||||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
|
||||||
placeholder="설명"
|
|
||||||
className="h-8 w-full text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="pl-4">
|
||||||
</div>
|
<Input
|
||||||
|
value={column.description || ""}
|
||||||
|
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||||
|
placeholder="설명"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* 로딩 표시 */}
|
{/* 로딩 표시 */}
|
||||||
{columnsLoading && (
|
{columnsLoading && (
|
||||||
|
|
@ -1428,28 +1435,16 @@ export default function TableManagementPage() {
|
||||||
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지 정보 */}
|
|
||||||
<div className="text-muted-foreground text-center text-sm">
|
|
||||||
{columns.length} / {totalColumns} 컬럼 표시됨
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 전체 저장 버튼 */}
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<Button
|
|
||||||
onClick={saveAllSettings}
|
|
||||||
disabled={!selectedTable || columns.length === 0}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
전체 설정 저장
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
{/* 페이지 정보 (고정 하단) */}
|
||||||
)}
|
<div className="text-muted-foreground flex-shrink-0 border-t py-2 text-center text-sm">
|
||||||
</div>
|
{columns.length} / {totalColumns} 컬럼 표시됨
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ function ScreenViewPage() {
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="bg-background flex h-full w-full items-center justify-center overflow-auto pt-8"
|
className="bg-background flex h-full w-full items-center justify-center overflow-auto"
|
||||||
>
|
>
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ export function RestApiConnectionList() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-background">
|
<TableRow className="bg-background">
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
||||||
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 URL</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 URL</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">인증 타입</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">인증 타입</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">헤더 수</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">헤더 수</TableHead>
|
||||||
|
|
@ -308,6 +309,9 @@ export function RestApiConnectionList() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||||
|
{(connection as any).company_name || connection.company_code}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
||||||
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
||||||
{connection.base_url}
|
{connection.base_url}
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
timeout,
|
timeout,
|
||||||
retry_count: retryCount,
|
retry_count: retryCount,
|
||||||
retry_delay: retryDelay,
|
retry_delay: retryDelay,
|
||||||
company_code: "*",
|
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
|
||||||
is_active: isActive ? "Y" : "N",
|
is_active: isActive ? "Y" : "N",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { toast } from "sonner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||||
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -32,6 +33,7 @@ interface ScreenModalProps {
|
||||||
|
|
||||||
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const { userId, userName, user } = useAuth();
|
const { userId, userName, user } = useAuth();
|
||||||
|
const splitPanelContext = useSplitPanelContext();
|
||||||
|
|
||||||
const [modalState, setModalState] = useState<ScreenModalState>({
|
const [modalState, setModalState] = useState<ScreenModalState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
|
@ -60,6 +62,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
|
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
|
||||||
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
|
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
|
||||||
|
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
||||||
|
|
||||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||||
const [continuousMode, setContinuousMode] = useState(false);
|
const [continuousMode, setContinuousMode] = useState(false);
|
||||||
|
|
||||||
|
|
@ -129,12 +134,37 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 전역 모달 이벤트 리스너
|
// 전역 모달 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenModal = (event: CustomEvent) => {
|
const handleOpenModal = (event: CustomEvent) => {
|
||||||
const { screenId, title, description, size, urlParams, editData } = event.detail;
|
const {
|
||||||
|
screenId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
size,
|
||||||
|
urlParams,
|
||||||
|
editData,
|
||||||
|
splitPanelParentData,
|
||||||
|
selectedData: eventSelectedData,
|
||||||
|
selectedIds,
|
||||||
|
} = event.detail;
|
||||||
|
|
||||||
|
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
|
||||||
|
screenId,
|
||||||
|
title,
|
||||||
|
selectedData: eventSelectedData,
|
||||||
|
selectedIds,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 모달 열린 시간 기록
|
// 🆕 모달 열린 시간 기록
|
||||||
modalOpenedAtRef.current = Date.now();
|
modalOpenedAtRef.current = Date.now();
|
||||||
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
|
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
|
||||||
|
|
||||||
|
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
||||||
|
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||||
|
setSelectedData(eventSelectedData);
|
||||||
|
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
|
||||||
|
} else {
|
||||||
|
setSelectedData([]);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||||
if (urlParams && typeof window !== "undefined") {
|
if (urlParams && typeof window !== "undefined") {
|
||||||
const currentUrl = new URL(window.location.href);
|
const currentUrl = new URL(window.location.href);
|
||||||
|
|
@ -152,6 +182,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
setFormData(editData);
|
setFormData(editData);
|
||||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
} else {
|
} else {
|
||||||
|
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||||
|
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
|
||||||
|
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
|
||||||
|
const parentData =
|
||||||
|
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
||||||
|
? splitPanelParentData
|
||||||
|
: splitPanelContext?.getMappedParentData() || {};
|
||||||
|
|
||||||
|
if (Object.keys(parentData).length > 0) {
|
||||||
|
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
|
||||||
|
setFormData(parentData);
|
||||||
|
} else {
|
||||||
|
setFormData({});
|
||||||
|
}
|
||||||
setOriginalData(null); // 신규 등록 모드
|
setOriginalData(null); // 신규 등록 모드
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +228,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
setScreenData(null);
|
setScreenData(null);
|
||||||
setFormData({});
|
setFormData({});
|
||||||
setOriginalData(null); // 🆕 원본 데이터 초기화
|
setOriginalData(null); // 🆕 원본 데이터 초기화
|
||||||
|
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||||
setContinuousMode(false);
|
setContinuousMode(false);
|
||||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||||
console.log("🔄 연속 모드 초기화: false");
|
console.log("🔄 연속 모드 초기화: false");
|
||||||
|
|
@ -649,6 +694,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
id: modalState.screenId!,
|
id: modalState.screenId!,
|
||||||
tableName: screenData.screenInfo?.tableName,
|
tableName: screenData.screenInfo?.tableName,
|
||||||
}}
|
}}
|
||||||
|
groupedData={selectedData}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={user?.companyCode}
|
companyCode={user?.companyCode}
|
||||||
|
|
|
||||||
|
|
@ -545,8 +545,8 @@ export function DashboardViewer({
|
||||||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||||
<div className="bg-muted hidden min-h-screen py-8 lg:block" style={{ backgroundColor }}>
|
<div className="bg-muted hidden min-h-screen py-8 lg:block" style={{ backgroundColor }}>
|
||||||
<div className="mx-auto px-4" style={{ width: "100%", maxWidth: "none" }}>
|
<div className="mx-auto px-4" style={{ width: "100%", maxWidth: "none" }}>
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 - 비활성화 */}
|
||||||
<div className="mb-4 flex justify-end">
|
{/* <div className="mb-4 flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
|
@ -559,7 +559,7 @@ export function DashboardViewer({
|
||||||
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="dashboard-viewer-canvas relative rounded-lg"
|
className="dashboard-viewer-canvas relative rounded-lg"
|
||||||
|
|
@ -588,8 +588,8 @@ export function DashboardViewer({
|
||||||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||||
<div className="bg-muted block min-h-screen p-4 lg:hidden" style={{ backgroundColor }}>
|
<div className="bg-muted block min-h-screen p-4 lg:hidden" style={{ backgroundColor }}>
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
{/* 다운로드 버튼 */}
|
{/* 다운로드 버튼 - 비활성화 */}
|
||||||
<div className="flex justify-end">
|
{/* <div className="flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
|
@ -602,7 +602,7 @@ export function DashboardViewer({
|
||||||
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div className="dashboard-viewer-canvas">
|
<div className="dashboard-viewer-canvas">
|
||||||
{sortedElements.map((element) => (
|
{sortedElements.map((element) => (
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ interface Vehicle {
|
||||||
driver: string;
|
driver: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
status: "active" | "inactive" | "maintenance" | "warning";
|
status: "active" | "inactive" | "maintenance" | "warning" | "off";
|
||||||
speed: number;
|
speed: number;
|
||||||
destination: string;
|
destination: string;
|
||||||
}
|
}
|
||||||
|
|
@ -88,24 +88,45 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
const statusCol = element.chartConfig.statusColumn || "status";
|
const statusCol = element.chartConfig.statusColumn || "status";
|
||||||
|
|
||||||
// DB 데이터를 Vehicle 형식으로 변환
|
// DB 데이터를 Vehicle 형식으로 변환
|
||||||
const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({
|
console.log("🗺️ [VehicleMapOnlyWidget] 원본 데이터:", result.data.rows);
|
||||||
id: row.id || row.vehicle_number || `V${index + 1}`,
|
console.log("🗺️ [VehicleMapOnlyWidget] 컬럼 매핑:", { latCol, lngCol, labelCol, statusCol });
|
||||||
name: row[labelCol] || `차량 ${index + 1}`,
|
|
||||||
driver: row.driver_name || row.driver || "미배정",
|
const vehiclesFromDB: Vehicle[] = result.data.rows
|
||||||
lat: parseFloat(row[latCol]),
|
.map((row: any, index: number) => {
|
||||||
lng: parseFloat(row[lngCol]),
|
const lat = parseFloat(row[latCol]);
|
||||||
status:
|
const lng = parseFloat(row[lngCol]);
|
||||||
row[statusCol] === "warning"
|
|
||||||
? "warning"
|
console.log(`🗺️ [VehicleMapOnlyWidget] 차량 ${index + 1}:`, {
|
||||||
: row[statusCol] === "active"
|
id: row.id || row.vehicle_number,
|
||||||
? "active"
|
latRaw: row[latCol],
|
||||||
: row[statusCol] === "maintenance"
|
lngRaw: row[lngCol],
|
||||||
? "maintenance"
|
latParsed: lat,
|
||||||
: "inactive",
|
lngParsed: lng,
|
||||||
speed: parseFloat(row.speed) || 0,
|
status: row[statusCol],
|
||||||
destination: row.destination || "대기 중",
|
});
|
||||||
}));
|
|
||||||
|
return {
|
||||||
|
id: row.id || row.vehicle_number || `V${index + 1}`,
|
||||||
|
name: row[labelCol] || `차량 ${index + 1}`,
|
||||||
|
driver: row.driver_name || row.driver || "미배정",
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
status:
|
||||||
|
row[statusCol] === "warning"
|
||||||
|
? "warning"
|
||||||
|
: row[statusCol] === "active"
|
||||||
|
? "active"
|
||||||
|
: row[statusCol] === "maintenance"
|
||||||
|
? "maintenance"
|
||||||
|
: "inactive",
|
||||||
|
speed: parseFloat(row.speed) || 0,
|
||||||
|
destination: row.destination || "대기 중",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// 유효한 위도/경도가 있는 차량만 필터링
|
||||||
|
.filter((v: Vehicle) => !isNaN(v.lat) && !isNaN(v.lng) && v.lat !== 0 && v.lng !== 0);
|
||||||
|
|
||||||
|
console.log("🗺️ [VehicleMapOnlyWidget] 유효한 차량 수:", vehiclesFromDB.length);
|
||||||
setVehicles(vehiclesFromDB);
|
setVehicles(vehiclesFromDB);
|
||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,27 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { FlowConditionGroup, FlowCondition, ConditionOperator } from "@/types/flow";
|
import { FlowConditionGroup, FlowCondition, ConditionOperator } from "@/types/flow";
|
||||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// 다중 REST API 연결 설정
|
||||||
|
interface RestApiConnectionConfig {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
endpoint: string;
|
||||||
|
jsonPath: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowConditionBuilderProps {
|
interface FlowConditionBuilderProps {
|
||||||
flowId: number;
|
flowId: number;
|
||||||
tableName?: string; // 조회할 테이블명
|
tableName?: string; // 조회할 테이블명
|
||||||
dbSourceType?: "internal" | "external"; // DB 소스 타입
|
dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // DB 소스 타입
|
||||||
dbConnectionId?: number; // 외부 DB 연결 ID
|
dbConnectionId?: number; // 외부 DB 연결 ID
|
||||||
|
restApiConnectionId?: number; // REST API 연결 ID (단일)
|
||||||
|
restApiEndpoint?: string; // REST API 엔드포인트 (단일)
|
||||||
|
restApiJsonPath?: string; // REST API JSON 경로 (단일)
|
||||||
|
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
|
||||||
condition?: FlowConditionGroup;
|
condition?: FlowConditionGroup;
|
||||||
onChange: (condition: FlowConditionGroup | undefined) => void;
|
onChange: (condition: FlowConditionGroup | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +59,10 @@ export function FlowConditionBuilder({
|
||||||
tableName,
|
tableName,
|
||||||
dbSourceType = "internal",
|
dbSourceType = "internal",
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
restApiConnections,
|
||||||
condition,
|
condition,
|
||||||
onChange,
|
onChange,
|
||||||
}: FlowConditionBuilderProps) {
|
}: FlowConditionBuilderProps) {
|
||||||
|
|
@ -65,9 +83,10 @@ export function FlowConditionBuilder({
|
||||||
}
|
}
|
||||||
}, [condition]);
|
}, [condition]);
|
||||||
|
|
||||||
// 테이블 컬럼 로드 - 내부/외부 DB 모두 지원
|
// 테이블 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableName) {
|
// REST API인 경우 tableName이 없어도 진행 가능
|
||||||
|
if (!tableName && dbSourceType !== "restapi" && dbSourceType !== "multi_restapi") {
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -79,8 +98,106 @@ export function FlowConditionBuilder({
|
||||||
tableName,
|
tableName,
|
||||||
dbSourceType,
|
dbSourceType,
|
||||||
dbConnectionId,
|
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인 경우
|
// 외부 DB인 경우
|
||||||
if (dbSourceType === "external" && dbConnectionId) {
|
if (dbSourceType === "external" && dbConnectionId) {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
|
|
@ -148,7 +265,7 @@ export function FlowConditionBuilder({
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColumns();
|
loadColumns();
|
||||||
}, [tableName, dbSourceType, dbConnectionId]);
|
}, [tableName, dbSourceType, dbConnectionId, restApiConnectionId, restApiEndpoint, restApiJsonPath]);
|
||||||
|
|
||||||
// 조건 변경 시 부모에 전달
|
// 조건 변경 시 부모에 전달
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,25 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||||
|
|
||||||
|
// 다중 REST API 연결 설정
|
||||||
|
interface RestApiConnectionConfig {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
endpoint: string;
|
||||||
|
jsonPath: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowStepPanelProps {
|
interface FlowStepPanelProps {
|
||||||
step: FlowStep;
|
step: FlowStep;
|
||||||
flowId: number;
|
flowId: number;
|
||||||
flowTableName?: string; // 플로우 정의에서 선택한 테이블명
|
flowTableName?: string; // 플로우 정의에서 선택한 테이블명
|
||||||
flowDbSourceType?: "internal" | "external"; // 플로우의 DB 소스 타입
|
flowDbSourceType?: "internal" | "external" | "restapi" | "multi_restapi"; // 플로우의 DB 소스 타입
|
||||||
flowDbConnectionId?: number; // 플로우의 외부 DB 연결 ID
|
flowDbConnectionId?: number; // 플로우의 외부 DB 연결 ID
|
||||||
|
flowRestApiConnectionId?: number; // 플로우의 REST API 연결 ID (단일)
|
||||||
|
flowRestApiEndpoint?: string; // REST API 엔드포인트 (단일)
|
||||||
|
flowRestApiJsonPath?: string; // REST API JSON 경로 (단일)
|
||||||
|
flowRestApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +59,10 @@ export function FlowStepPanel({
|
||||||
flowTableName,
|
flowTableName,
|
||||||
flowDbSourceType = "internal",
|
flowDbSourceType = "internal",
|
||||||
flowDbConnectionId,
|
flowDbConnectionId,
|
||||||
|
flowRestApiConnectionId,
|
||||||
|
flowRestApiEndpoint,
|
||||||
|
flowRestApiJsonPath,
|
||||||
|
flowRestApiConnections,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: FlowStepPanelProps) {
|
}: FlowStepPanelProps) {
|
||||||
|
|
@ -56,6 +73,9 @@ export function FlowStepPanel({
|
||||||
flowTableName,
|
flowTableName,
|
||||||
flowDbSourceType,
|
flowDbSourceType,
|
||||||
flowDbConnectionId,
|
flowDbConnectionId,
|
||||||
|
flowRestApiConnectionId,
|
||||||
|
flowRestApiEndpoint,
|
||||||
|
flowRestApiJsonPath,
|
||||||
final: step.tableName || flowTableName || "",
|
final: step.tableName || flowTableName || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -315,10 +335,11 @@ export function FlowStepPanel({
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
}, [step.id, flowTableName]); // flowTableName도 의존성 추가
|
}, [step.id, flowTableName]); // flowTableName도 의존성 추가
|
||||||
|
|
||||||
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 모두 지원
|
// 테이블 선택 시 컬럼 로드 - 내부/외부 DB 및 REST API 모두 지원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColumns = async () => {
|
const loadColumns = async () => {
|
||||||
if (!formData.tableName) {
|
// 다중 REST API인 경우 tableName 없이도 컬럼 로드 가능
|
||||||
|
if (!formData.tableName && flowDbSourceType !== "multi_restapi") {
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -329,8 +350,74 @@ export function FlowStepPanel({
|
||||||
tableName: formData.tableName,
|
tableName: formData.tableName,
|
||||||
flowDbSourceType,
|
flowDbSourceType,
|
||||||
flowDbConnectionId,
|
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인 경우
|
// 외부 DB인 경우
|
||||||
if (flowDbSourceType === "external" && flowDbConnectionId) {
|
if (flowDbSourceType === "external" && flowDbConnectionId) {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
|
|
@ -399,7 +486,7 @@ export function FlowStepPanel({
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColumns();
|
loadColumns();
|
||||||
}, [formData.tableName, flowDbSourceType, flowDbConnectionId]);
|
}, [formData.tableName, flowDbSourceType, flowDbConnectionId, flowRestApiConnectionId, flowRestApiEndpoint, flowRestApiJsonPath, flowRestApiConnections]);
|
||||||
|
|
||||||
// formData의 최신 값을 항상 참조하기 위한 ref
|
// formData의 최신 값을 항상 참조하기 위한 ref
|
||||||
const formDataRef = useRef(formData);
|
const formDataRef = useRef(formData);
|
||||||
|
|
@ -661,6 +748,10 @@ export function FlowStepPanel({
|
||||||
tableName={formData.tableName}
|
tableName={formData.tableName}
|
||||||
dbSourceType={flowDbSourceType}
|
dbSourceType={flowDbSourceType}
|
||||||
dbConnectionId={flowDbConnectionId}
|
dbConnectionId={flowDbConnectionId}
|
||||||
|
restApiConnectionId={flowRestApiConnectionId}
|
||||||
|
restApiEndpoint={flowRestApiEndpoint}
|
||||||
|
restApiJsonPath={flowRestApiJsonPath}
|
||||||
|
restApiConnections={flowRestApiConnections}
|
||||||
condition={formData.conditionJson}
|
condition={formData.conditionJson}
|
||||||
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
|
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
|
||||||
/>
|
/>
|
||||||
|
|
@ -852,7 +943,7 @@ export function FlowStepPanel({
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value !== "internal" && opt.value !== "external_db"}
|
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "rest_api"}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1044,6 +1135,132 @@ export function FlowStepPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
@ -22,8 +24,17 @@ import { useProfile } from "@/hooks/useProfile";
|
||||||
import { MenuItem } from "@/lib/api/menu";
|
import { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MainHeader } from "./MainHeader";
|
|
||||||
import { ProfileModal } from "./ProfileModal";
|
import { ProfileModal } from "./ProfileModal";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import { SideMenu } from "./SideMenu";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
// useAuth의 UserInfo 타입을 확장
|
// useAuth의 UserInfo 타입을 확장
|
||||||
interface ExtendedUserInfo {
|
interface ExtendedUserInfo {
|
||||||
|
|
@ -234,6 +245,21 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
selectImage,
|
selectImage,
|
||||||
removeImage,
|
removeImage,
|
||||||
saveProfile,
|
saveProfile,
|
||||||
|
// 운전자 관련
|
||||||
|
isDriver,
|
||||||
|
hasVehicle,
|
||||||
|
driverInfo,
|
||||||
|
driverFormData,
|
||||||
|
updateDriverFormData,
|
||||||
|
handleDriverStatusChange,
|
||||||
|
handleDriverAccountDelete,
|
||||||
|
handleDeleteVehicle,
|
||||||
|
openVehicleRegisterModal,
|
||||||
|
closeVehicleRegisterModal,
|
||||||
|
isVehicleRegisterModalOpen,
|
||||||
|
newVehicleData,
|
||||||
|
updateNewVehicleData,
|
||||||
|
handleRegisterVehicle,
|
||||||
} = useProfile(user, refreshUserData, refreshMenus);
|
} = useProfile(user, refreshUserData, refreshMenus);
|
||||||
|
|
||||||
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
||||||
|
|
@ -397,82 +423,152 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-white">
|
<div className="flex h-screen bg-white">
|
||||||
{/* MainHeader 컴포넌트 사용 */}
|
{/* 모바일 사이드바 오버레이 */}
|
||||||
<MainHeader
|
{sidebarOpen && isMobile && (
|
||||||
user={user}
|
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
onSidebarToggle={() => {
|
)}
|
||||||
// 모바일에서만 토글 동작
|
|
||||||
if (isMobile) {
|
|
||||||
setSidebarOpen(!sidebarOpen);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onProfileClick={openProfileModal}
|
|
||||||
onLogout={handleLogout}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-1 pt-14">
|
{/* 왼쪽 사이드바 */}
|
||||||
{/* 모바일 사이드바 오버레이 */}
|
<aside
|
||||||
{sidebarOpen && isMobile && (
|
className={`${
|
||||||
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
isMobile
|
||||||
|
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-0 left-0 z-40"
|
||||||
|
: "relative z-auto translate-x-0"
|
||||||
|
} flex h-screen w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
|
>
|
||||||
|
{/* 사이드바 최상단 - 로고 + 모바일 햄버거 메뉴 */}
|
||||||
|
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
||||||
|
<Logo />
|
||||||
|
{/* 모바일 햄버거 메뉴 버튼 */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
|
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||||
|
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
||||||
|
(user as ExtendedUserInfo)?.userType === "admin") && (
|
||||||
|
<div className="border-b border-slate-200 p-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleModeSwitch}
|
||||||
|
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
||||||
|
isAdminMode
|
||||||
|
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
|
||||||
|
: "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isAdminMode ? (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-4 w-4" />
|
||||||
|
사용자 메뉴로 전환
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
관리자 메뉴로 전환
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 왼쪽 사이드바 */}
|
{/* 메뉴 영역 */}
|
||||||
<aside
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
className={`${
|
<nav className="space-y-1 px-3">
|
||||||
isMobile
|
{loading ? (
|
||||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
<div className="animate-pulse space-y-2">
|
||||||
: "relative top-0 z-auto translate-x-0"
|
{[...Array(5)].map((_, i) => (
|
||||||
} flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
<div key={i} className="h-8 rounded bg-slate-200"></div>
|
||||||
>
|
))}
|
||||||
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
</div>
|
||||||
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
) : (
|
||||||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
uiMenus.map((menu) => renderMenu(menu))
|
||||||
(user as ExtendedUserInfo)?.userType === "admin") && (
|
)}
|
||||||
<div className="border-b border-slate-200 p-3">
|
</nav>
|
||||||
<Button
|
</div>
|
||||||
onClick={handleModeSwitch}
|
|
||||||
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
|
||||||
isAdminMode
|
|
||||||
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
|
|
||||||
: "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isAdminMode ? (
|
|
||||||
<>
|
|
||||||
<UserCheck className="h-4 w-4" />
|
|
||||||
사용자 메뉴로 전환
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
관리자 메뉴로 전환
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
{/* 사이드바 하단 - 사용자 프로필 */}
|
||||||
<nav className="space-y-1 px-3">
|
<div className="border-t border-slate-200 p-3">
|
||||||
{loading ? (
|
<DropdownMenu modal={false}>
|
||||||
<div className="animate-pulse space-y-2">
|
<DropdownMenuTrigger asChild>
|
||||||
{[...Array(5)].map((_, i) => (
|
<button className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-slate-100">
|
||||||
<div key={i} className="h-8 rounded bg-slate-200"></div>
|
{/* 프로필 아바타 */}
|
||||||
))}
|
<div className="relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full">
|
||||||
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||||
|
<img
|
||||||
|
src={user.photo}
|
||||||
|
alt={user.userName || "User"}
|
||||||
|
className="aspect-square h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700">
|
||||||
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{/* 사용자 정보 */}
|
||||||
uiMenus.map((menu) => renderMenu(menu))
|
<div className="min-w-0 flex-1">
|
||||||
)}
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
</nav>
|
{user.userName || "사용자"}
|
||||||
</div>
|
</p>
|
||||||
</aside>
|
<p className="truncate text-xs text-slate-500">
|
||||||
|
{user.deptName || user.email || user.userId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 프로필 사진 표시 */}
|
||||||
|
<div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
|
||||||
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||||
|
<img
|
||||||
|
src={user.photo}
|
||||||
|
alt={user.userName || "User"}
|
||||||
|
className="aspect-square h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-base font-semibold text-slate-700">
|
||||||
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
{/* 사용자 정보 */}
|
||||||
<main className="h-[calc(100vh-3.5rem)] min-w-0 flex-1 overflow-auto bg-white">
|
<div className="flex flex-col space-y-1">
|
||||||
{children}
|
<p className="text-sm leading-none font-medium">
|
||||||
</main>
|
{user.userName || "사용자"} ({user.userId || ""})
|
||||||
</div>
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs leading-none font-semibold">{user.email || ""}</p>
|
||||||
|
<p className="text-muted-foreground text-xs leading-none font-semibold">
|
||||||
|
{user.deptName && user.positionName
|
||||||
|
? `${user.deptName}, ${user.positionName}`
|
||||||
|
: user.deptName || user.positionName || "부서 정보 없음"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={openProfileModal}>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
<span>프로필</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>로그아웃</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||||
|
<main className="h-screen min-w-0 flex-1 overflow-auto bg-white">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* 프로필 수정 모달 */}
|
{/* 프로필 수정 모달 */}
|
||||||
<ProfileModal
|
<ProfileModal
|
||||||
|
|
@ -483,6 +579,20 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
departments={departments}
|
departments={departments}
|
||||||
alertModal={alertModal}
|
alertModal={alertModal}
|
||||||
|
isDriver={isDriver}
|
||||||
|
hasVehicle={hasVehicle}
|
||||||
|
driverInfo={driverInfo}
|
||||||
|
driverFormData={driverFormData}
|
||||||
|
onDriverFormChange={updateDriverFormData}
|
||||||
|
onDriverStatusChange={handleDriverStatusChange}
|
||||||
|
onDriverAccountDelete={handleDriverAccountDelete}
|
||||||
|
onDeleteVehicle={handleDeleteVehicle}
|
||||||
|
onOpenVehicleRegisterModal={openVehicleRegisterModal}
|
||||||
|
isVehicleRegisterModalOpen={isVehicleRegisterModalOpen}
|
||||||
|
newVehicleData={newVehicleData}
|
||||||
|
onCloseVehicleRegisterModal={closeVehicleRegisterModal}
|
||||||
|
onNewVehicleDataChange={updateNewVehicleData}
|
||||||
|
onRegisterVehicle={handleRegisterVehicle}
|
||||||
onClose={closeProfileModal}
|
onClose={closeProfileModal}
|
||||||
onFormChange={updateFormData}
|
onFormChange={updateFormData}
|
||||||
onImageSelect={selectImage}
|
onImageSelect={selectImage}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,20 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Camera, X } from "lucide-react";
|
import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
|
||||||
import { ProfileFormData } from "@/types/profile";
|
import { ProfileFormData } from "@/types/profile";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { VehicleRegisterData } from "@/lib/api/driver";
|
||||||
|
|
||||||
|
// 운전자 정보 타입
|
||||||
|
export interface DriverInfo {
|
||||||
|
vehicleNumber: string;
|
||||||
|
vehicleType: string | null;
|
||||||
|
licenseNumber: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
vehicleStatus: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// 알림 모달 컴포넌트
|
// 알림 모달 컴포넌트
|
||||||
interface AlertModalProps {
|
interface AlertModalProps {
|
||||||
|
|
@ -54,6 +66,15 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 운전자 폼 데이터 타입
|
||||||
|
export interface DriverFormData {
|
||||||
|
vehicleNumber: string;
|
||||||
|
vehicleType: string;
|
||||||
|
licenseNumber: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
branchName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProfileModalProps {
|
interface ProfileModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
user: any;
|
user: any;
|
||||||
|
|
@ -70,6 +91,23 @@ interface ProfileModalProps {
|
||||||
message: string;
|
message: string;
|
||||||
type: "success" | "error" | "info";
|
type: "success" | "error" | "info";
|
||||||
};
|
};
|
||||||
|
// 운전자 관련 props (선택적)
|
||||||
|
isDriver?: boolean;
|
||||||
|
hasVehicle?: boolean;
|
||||||
|
driverInfo?: DriverInfo | null;
|
||||||
|
driverFormData?: DriverFormData;
|
||||||
|
onDriverFormChange?: (field: keyof DriverFormData, value: string) => void;
|
||||||
|
onDriverStatusChange?: (status: "off" | "maintenance") => void;
|
||||||
|
onDriverAccountDelete?: () => void;
|
||||||
|
// 차량 삭제/등록 관련 props
|
||||||
|
onDeleteVehicle?: () => void;
|
||||||
|
onOpenVehicleRegisterModal?: () => void;
|
||||||
|
// 새 차량 등록 모달 관련 props
|
||||||
|
isVehicleRegisterModalOpen?: boolean;
|
||||||
|
newVehicleData?: VehicleRegisterData;
|
||||||
|
onCloseVehicleRegisterModal?: () => void;
|
||||||
|
onNewVehicleDataChange?: (field: keyof VehicleRegisterData, value: string) => void;
|
||||||
|
onRegisterVehicle?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onFormChange: (field: keyof ProfileFormData, value: string) => void;
|
onFormChange: (field: keyof ProfileFormData, value: string) => void;
|
||||||
onImageSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onImageSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
|
@ -89,6 +127,20 @@ export function ProfileModal({
|
||||||
isSaving,
|
isSaving,
|
||||||
departments,
|
departments,
|
||||||
alertModal,
|
alertModal,
|
||||||
|
isDriver = false,
|
||||||
|
hasVehicle = false,
|
||||||
|
driverInfo,
|
||||||
|
driverFormData,
|
||||||
|
onDriverFormChange,
|
||||||
|
onDriverStatusChange,
|
||||||
|
onDriverAccountDelete,
|
||||||
|
onDeleteVehicle,
|
||||||
|
onOpenVehicleRegisterModal,
|
||||||
|
isVehicleRegisterModalOpen = false,
|
||||||
|
newVehicleData,
|
||||||
|
onCloseVehicleRegisterModal,
|
||||||
|
onNewVehicleDataChange,
|
||||||
|
onRegisterVehicle,
|
||||||
onClose,
|
onClose,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
onImageSelect,
|
onImageSelect,
|
||||||
|
|
@ -96,6 +148,21 @@ export function ProfileModal({
|
||||||
onSave,
|
onSave,
|
||||||
onAlertClose,
|
onAlertClose,
|
||||||
}: ProfileModalProps) {
|
}: ProfileModalProps) {
|
||||||
|
// 차량 상태 한글 변환
|
||||||
|
const getStatusLabel = (status: string | null) => {
|
||||||
|
switch (status) {
|
||||||
|
case "off":
|
||||||
|
return "대기";
|
||||||
|
case "active":
|
||||||
|
return "운행중";
|
||||||
|
case "inactive":
|
||||||
|
return "공차";
|
||||||
|
case "maintenance":
|
||||||
|
return "정비";
|
||||||
|
default:
|
||||||
|
return status || "-";
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
|
@ -234,6 +301,152 @@ export function ProfileModal({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 운전자 정보 섹션 (공차중계 사용자만) */}
|
||||||
|
{isDriver && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Car className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">차량/운전자 정보</h3>
|
||||||
|
</div>
|
||||||
|
{/* 차량 유무에 따른 버튼 표시 */}
|
||||||
|
{hasVehicle ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDeleteVehicle}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
차량 삭제
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenVehicleRegisterModal}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
새 차량 등록
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 운전자 정보 (항상 수정 가능) */}
|
||||||
|
{driverFormData && onDriverFormChange && (
|
||||||
|
<>
|
||||||
|
{/* 차량 정보 - 차량이 있을 때만 수정 가능 */}
|
||||||
|
{hasVehicle ? (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vehicleNumber">차량번호</Label>
|
||||||
|
<Input
|
||||||
|
id="vehicleNumber"
|
||||||
|
value={driverFormData.vehicleNumber}
|
||||||
|
onChange={(e) => onDriverFormChange("vehicleNumber", e.target.value)}
|
||||||
|
placeholder="12가1234"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vehicleType">차종</Label>
|
||||||
|
<Input
|
||||||
|
id="vehicleType"
|
||||||
|
value={driverFormData.vehicleType}
|
||||||
|
onChange={(e) => onDriverFormChange("vehicleType", e.target.value)}
|
||||||
|
placeholder="1톤 카고"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 차량이 없는 경우: 안내 메시지 */
|
||||||
|
<div className="text-center py-4 text-muted-foreground border rounded-md bg-muted/30">
|
||||||
|
<Car className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">등록된 차량이 없습니다.</p>
|
||||||
|
<p className="text-xs mt-1">새 차량 등록 버튼을 눌러 차량을 등록하세요.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 운전자 개인 정보 - 항상 수정 가능 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="driverPhone">연락처</Label>
|
||||||
|
<Input
|
||||||
|
id="driverPhone"
|
||||||
|
value={driverFormData.phoneNumber}
|
||||||
|
onChange={(e) => onDriverFormChange("phoneNumber", e.target.value)}
|
||||||
|
placeholder="010-1234-5678"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="licenseNumber">면허번호</Label>
|
||||||
|
<Input
|
||||||
|
id="licenseNumber"
|
||||||
|
value={driverFormData.licenseNumber}
|
||||||
|
onChange={(e) => onDriverFormChange("licenseNumber", e.target.value)}
|
||||||
|
placeholder="12-34-567890-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="branchName">소속 지점</Label>
|
||||||
|
<Input
|
||||||
|
id="branchName"
|
||||||
|
value={driverFormData.branchName}
|
||||||
|
onChange={(e) => onDriverFormChange("branchName", e.target.value)}
|
||||||
|
placeholder="서울 본점"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차량 상태 - 차량이 있을 때만 표시 */}
|
||||||
|
{hasVehicle && driverInfo && onDriverStatusChange && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>현재 차량 상태</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-medium px-3 py-1 rounded-full bg-muted">
|
||||||
|
{getStatusLabel(driverInfo.vehicleStatus)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDriverStatusChange("off")}
|
||||||
|
disabled={driverInfo.vehicleStatus === "off"}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
대기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDriverStatusChange("maintenance")}
|
||||||
|
disabled={driverInfo.vehicleStatus === "maintenance"}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Wrench className="h-3 w-3" />
|
||||||
|
정비
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
* 운행/공차 상태는 공차등록 화면에서 변경하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<ResizableDialogFooter>
|
||||||
|
|
@ -255,6 +468,50 @@ export function ProfileModal({
|
||||||
message={alertModal.message}
|
message={alertModal.message}
|
||||||
type={alertModal.type}
|
type={alertModal.type}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 새 차량 등록 모달 */}
|
||||||
|
{isVehicleRegisterModalOpen && newVehicleData && onNewVehicleDataChange && onRegisterVehicle && onCloseVehicleRegisterModal && (
|
||||||
|
<ResizableDialog open={isVehicleRegisterModalOpen} onOpenChange={onCloseVehicleRegisterModal}>
|
||||||
|
<ResizableDialogContent className="sm:max-w-[400px]">
|
||||||
|
<ResizableDialogHeader>
|
||||||
|
<ResizableDialogTitle>새 차량 등록</ResizableDialogTitle>
|
||||||
|
<ResizableDialogDescription>
|
||||||
|
새로운 차량 정보를 입력해주세요.
|
||||||
|
</ResizableDialogDescription>
|
||||||
|
</ResizableDialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newVehicleNumber">차량번호 *</Label>
|
||||||
|
<Input
|
||||||
|
id="newVehicleNumber"
|
||||||
|
value={newVehicleData.vehicleNumber}
|
||||||
|
onChange={(e) => onNewVehicleDataChange("vehicleNumber", e.target.value)}
|
||||||
|
placeholder="12가1234"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newVehicleType">차종</Label>
|
||||||
|
<Input
|
||||||
|
id="newVehicleType"
|
||||||
|
value={newVehicleData.vehicleType || ""}
|
||||||
|
onChange={(e) => onNewVehicleDataChange("vehicleType", e.target.value)}
|
||||||
|
placeholder="1톤 카고"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResizableDialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onCloseVehicleRegisterModal}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onRegisterVehicle}>
|
||||||
|
등록
|
||||||
|
</Button>
|
||||||
|
</ResizableDialogFooter>
|
||||||
|
</ResizableDialogContent>
|
||||||
|
</ResizableDialog>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,11 +149,12 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||||
|
|
||||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||||
|
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
||||||
const ruleToSave = {
|
const ruleToSave = {
|
||||||
...currentRule,
|
...currentRule,
|
||||||
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
|
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("💾 채번 규칙 저장:", {
|
console.log("💾 채번 규칙 저장:", {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,21 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
}
|
}
|
||||||
}, [initialFormData]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
onSelectionChanged?.(selectedRows);
|
onSelectionChanged?.(selectedRows);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
||||||
leftScreenId: config?.leftScreenId,
|
leftScreenId: config?.leftScreenId,
|
||||||
rightScreenId: config?.rightScreenId,
|
rightScreenId: config?.rightScreenId,
|
||||||
configSplitRatio,
|
configSplitRatio,
|
||||||
|
parentDataMapping: config?.parentDataMapping,
|
||||||
configKeys: config ? Object.keys(config) : [],
|
configKeys: config ? Object.keys(config) : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -125,6 +126,8 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
||||||
splitPanelId={splitPanelId}
|
splitPanelId={splitPanelId}
|
||||||
leftScreenId={config?.leftScreenId || null}
|
leftScreenId={config?.leftScreenId || null}
|
||||||
rightScreenId={config?.rightScreenId || null}
|
rightScreenId={config?.rightScreenId || null}
|
||||||
|
parentDataMapping={config?.parentDataMapping || []}
|
||||||
|
linkedFilters={config?.linkedFilters || []}
|
||||||
>
|
>
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* 좌측 패널 */}
|
{/* 좌측 패널 */}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import { SaveModal } from "./SaveModal";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
|
||||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
|
|
@ -105,6 +106,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
||||||
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||||
|
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -575,12 +577,72 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize });
|
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
search: searchParams,
|
search: mergedSearchParams,
|
||||||
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -680,7 +742,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가
|
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 사용자 정보 로드
|
// 현재 사용자 정보 로드
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
|
||||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||||
import "@/lib/registry/components/ButtonRenderer";
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
|
|
@ -78,6 +79,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { userName: authUserName, user: authUser } = useAuth();
|
const { userName: authUserName, user: authUser } = useAuth();
|
||||||
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||||
|
|
||||||
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
||||||
const userName = externalUserName || authUserName;
|
const userName = externalUserName || authUserName;
|
||||||
|
|
@ -116,8 +118,30 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// 팝업 전용 formData 상태
|
// 팝업 전용 formData 상태
|
||||||
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// formData 결정 (외부에서 전달받은 것이 있으면 우선 사용)
|
// 🆕 분할 패널에서 매핑된 부모 데이터 가져오기
|
||||||
const formData = externalFormData || localFormData;
|
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 업데이트 함수
|
// formData 업데이트 함수
|
||||||
const updateFormData = useCallback(
|
const updateFormData = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -838,18 +838,53 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 화면의 기본 테이블/REST API 정보 로드
|
// 화면의 기본 테이블/REST API 정보 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadScreenDataSource = async () => {
|
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 데이터 소스인 경우
|
// REST API 데이터 소스인 경우
|
||||||
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
|
// 1. dataSourceType이 "restapi"인 경우
|
||||||
|
// 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우
|
||||||
|
// 3. restApiConnectionId가 있는 경우
|
||||||
|
const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
|
||||||
|
selectedScreen?.tableName?.startsWith("restapi_") ||
|
||||||
|
selectedScreen?.tableName?.startsWith("_restapi_") ||
|
||||||
|
!!selectedScreen?.restApiConnectionId;
|
||||||
|
|
||||||
|
console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi });
|
||||||
|
|
||||||
|
if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) {
|
||||||
try {
|
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(
|
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||||
selectedScreen.restApiConnectionId,
|
connectionId,
|
||||||
selectedScreen.restApiEndpoint,
|
selectedScreen?.restApiEndpoint,
|
||||||
selectedScreen.restApiJsonPath || "data",
|
selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경
|
||||||
);
|
);
|
||||||
|
|
||||||
// REST API 응답에서 컬럼 정보 생성
|
// REST API 응답에서 컬럼 정보 생성
|
||||||
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
|
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
|
||||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
tableName: `restapi_${connectionId}`,
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
columnLabel: col.columnLabel,
|
columnLabel: col.columnLabel,
|
||||||
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
|
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
|
||||||
|
|
@ -861,10 +896,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tableInfo: TableInfo = {
|
const tableInfo: TableInfo = {
|
||||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
tableName: `restapi_${connectionId}`,
|
||||||
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
|
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
|
||||||
columns,
|
columns,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("✅ [ScreenDesigner] REST API 컬럼 로드 완료:", {
|
||||||
|
tableName: tableInfo.tableName,
|
||||||
|
tableLabel: tableInfo.tableLabel,
|
||||||
|
columnsCount: columns.length,
|
||||||
|
columns: columns.map(c => c.columnName),
|
||||||
|
});
|
||||||
|
|
||||||
setTables([tableInfo]);
|
setTables([tableInfo]);
|
||||||
console.log("REST API 데이터 소스 로드 완료:", {
|
console.log("REST API 데이터 소스 로드 완료:", {
|
||||||
|
|
@ -1223,7 +1265,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
[layout, screenResolution, saveToHistory],
|
[layout, screenResolution, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 해상도 변경 핸들러 (자동 스케일링 포함)
|
// 해상도 변경 핸들러 (컴포넌트 크기/위치 유지)
|
||||||
const handleResolutionChange = useCallback(
|
const handleResolutionChange = useCallback(
|
||||||
(newResolution: ScreenResolution) => {
|
(newResolution: ScreenResolution) => {
|
||||||
const oldWidth = screenResolution.width;
|
const oldWidth = screenResolution.width;
|
||||||
|
|
@ -1231,122 +1273,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const newWidth = newResolution.width;
|
const newWidth = newResolution.width;
|
||||||
const newHeight = newResolution.height;
|
const newHeight = newResolution.height;
|
||||||
|
|
||||||
console.log("📱 해상도 변경 시작:", {
|
console.log("📱 해상도 변경:", {
|
||||||
from: `${oldWidth}x${oldHeight}`,
|
from: `${oldWidth}x${oldHeight}`,
|
||||||
to: `${newWidth}x${newHeight}`,
|
to: `${newWidth}x${newHeight}`,
|
||||||
hasComponents: layout.components.length > 0,
|
componentsCount: layout.components.length,
|
||||||
snapToGrid: layout.gridSettings?.snapToGrid || false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setScreenResolution(newResolution);
|
setScreenResolution(newResolution);
|
||||||
|
|
||||||
// 컴포넌트가 없으면 해상도만 변경
|
// 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지
|
||||||
if (layout.components.length === 0) {
|
|
||||||
const updatedLayout = {
|
|
||||||
...layout,
|
|
||||||
screenResolution: newResolution,
|
|
||||||
};
|
|
||||||
setLayout(updatedLayout);
|
|
||||||
saveToHistory(updatedLayout);
|
|
||||||
console.log("✅ 해상도 변경 완료 (컴포넌트 없음)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비율 계산
|
|
||||||
const scaleX = newWidth / oldWidth;
|
|
||||||
const scaleY = newHeight / oldHeight;
|
|
||||||
|
|
||||||
console.log("📐 스케일링 비율:", {
|
|
||||||
scaleX: `${(scaleX * 100).toFixed(2)}%`,
|
|
||||||
scaleY: `${(scaleY * 100).toFixed(2)}%`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컴포넌트 재귀적으로 스케일링하는 함수
|
|
||||||
const scaleComponent = (comp: ComponentData): ComponentData => {
|
|
||||||
// 위치 스케일링
|
|
||||||
const scaledPosition = {
|
|
||||||
x: comp.position.x * scaleX,
|
|
||||||
y: comp.position.y * scaleY,
|
|
||||||
z: comp.position.z || 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 크기 스케일링
|
|
||||||
const scaledSize = {
|
|
||||||
width: comp.size.width * scaleX,
|
|
||||||
height: comp.size.height * scaleY,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...comp,
|
|
||||||
position: scaledPosition,
|
|
||||||
size: scaledSize,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모든 컴포넌트 스케일링 (그룹의 자식도 자동으로 스케일링됨)
|
|
||||||
const scaledComponents = layout.components.map(scaleComponent);
|
|
||||||
|
|
||||||
console.log("🔄 컴포넌트 스케일링 완료:", {
|
|
||||||
totalComponents: scaledComponents.length,
|
|
||||||
groupComponents: scaledComponents.filter((c) => c.type === "group").length,
|
|
||||||
note: "그룹의 자식 컴포넌트도 모두 스케일링됨",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 격자 스냅이 활성화된 경우 격자에 맞춰 재조정
|
|
||||||
let finalComponents = scaledComponents;
|
|
||||||
if (layout.gridSettings?.snapToGrid) {
|
|
||||||
const newGridInfo = calculateGridInfo(newWidth, newHeight, {
|
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: layout.gridSettings.snapToGrid || false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gridUtilSettings = {
|
|
||||||
columns: layout.gridSettings.columns,
|
|
||||||
gap: layout.gridSettings.gap,
|
|
||||||
padding: layout.gridSettings.padding,
|
|
||||||
snapToGrid: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
finalComponents = scaledComponents.map((comp) => {
|
|
||||||
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
|
|
||||||
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
|
|
||||||
|
|
||||||
// gridColumns 재계산
|
|
||||||
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...comp,
|
|
||||||
position: snappedPosition,
|
|
||||||
size: snappedSize,
|
|
||||||
gridColumns: adjustedGridColumns,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("🧲 격자 스냅 적용 완료");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedLayout = {
|
const updatedLayout = {
|
||||||
...layout,
|
...layout,
|
||||||
components: finalComponents,
|
|
||||||
screenResolution: newResolution,
|
screenResolution: newResolution,
|
||||||
};
|
};
|
||||||
|
|
||||||
setLayout(updatedLayout);
|
setLayout(updatedLayout);
|
||||||
saveToHistory(updatedLayout);
|
saveToHistory(updatedLayout);
|
||||||
|
|
||||||
toast.success(`해상도 변경 완료! ${scaledComponents.length}개 컴포넌트가 자동으로 조정되었습니다.`, {
|
toast.success(`해상도가 변경되었습니다.`, {
|
||||||
description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`,
|
description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 해상도 변경 완료:", {
|
console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)");
|
||||||
newResolution: `${newWidth}x${newHeight}`,
|
|
||||||
scaledComponents: finalComponents.length,
|
|
||||||
scaleX: `${(scaleX * 100).toFixed(2)}%`,
|
|
||||||
scaleY: `${(scaleY * 100).toFixed(2)}%`,
|
|
||||||
note: "모든 컴포넌트가 비율에 맞게 자동 조정됨",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[layout, saveToHistory, screenResolution],
|
[layout, saveToHistory, screenResolution],
|
||||||
);
|
);
|
||||||
|
|
@ -4256,8 +4204,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
{/* 통합 패널 */}
|
{/* 통합 패널 */}
|
||||||
{panelStates.unified?.isOpen && (
|
{panelStates.unified?.isOpen && (
|
||||||
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm">
|
<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">
|
<div className="border-border flex items-center justify-between border-b px-4 py-3 shrink-0">
|
||||||
<h3 className="text-foreground text-sm font-semibold">패널</h3>
|
<h3 className="text-foreground text-sm font-semibold">패널</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => closePanel("unified")}
|
onClick={() => closePanel("unified")}
|
||||||
|
|
@ -4266,7 +4214,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
|
<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">
|
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-2 gap-1">
|
||||||
<TabsTrigger value="components" className="text-xs">
|
<TabsTrigger value="components" className="text-xs">
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { cn } from "@/lib/utils";
|
||||||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
|
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||||
import CreateScreenModal from "./CreateScreenModal";
|
import CreateScreenModal from "./CreateScreenModal";
|
||||||
import CopyScreenModal from "./CopyScreenModal";
|
import CopyScreenModal from "./CopyScreenModal";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
@ -119,11 +120,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
||||||
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
||||||
|
|
||||||
// 일괄삭제 관련 상태
|
// 휴지통 일괄삭제 관련 상태
|
||||||
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
||||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
|
||||||
|
// 활성 화면 일괄삭제 관련 상태
|
||||||
|
const [selectedActiveScreenIds, setSelectedActiveScreenIds] = useState<number[]>([]);
|
||||||
|
const [activeBulkDeleteDialogOpen, setActiveBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [activeBulkDeleteReason, setActiveBulkDeleteReason] = useState("");
|
||||||
|
const [activeBulkDeleting, setActiveBulkDeleting] = useState(false);
|
||||||
|
|
||||||
// 편집 관련 상태
|
// 편집 관련 상태
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
@ -132,10 +139,18 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
description: "",
|
description: "",
|
||||||
isActive: "Y",
|
isActive: "Y",
|
||||||
tableName: "",
|
tableName: "",
|
||||||
|
dataSourceType: "database" as "database" | "restapi",
|
||||||
|
restApiConnectionId: null as number | null,
|
||||||
|
restApiEndpoint: "",
|
||||||
|
restApiJsonPath: "data",
|
||||||
});
|
});
|
||||||
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
|
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||||
|
|
||||||
|
// REST API 연결 관련 상태 (편집용)
|
||||||
|
const [editRestApiConnections, setEditRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||||
|
const [editRestApiComboboxOpen, setEditRestApiComboboxOpen] = useState(false);
|
||||||
|
|
||||||
// 미리보기 관련 상태
|
// 미리보기 관련 상태
|
||||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||||
|
|
@ -272,11 +287,19 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
const handleEdit = async (screen: ScreenDefinition) => {
|
const handleEdit = async (screen: ScreenDefinition) => {
|
||||||
setScreenToEdit(screen);
|
setScreenToEdit(screen);
|
||||||
|
|
||||||
|
// 데이터 소스 타입 결정
|
||||||
|
const isRestApi = screen.dataSourceType === "restapi" || screen.tableName?.startsWith("_restapi_");
|
||||||
|
|
||||||
setEditFormData({
|
setEditFormData({
|
||||||
screenName: screen.screenName,
|
screenName: screen.screenName,
|
||||||
description: screen.description || "",
|
description: screen.description || "",
|
||||||
isActive: screen.isActive,
|
isActive: screen.isActive,
|
||||||
tableName: screen.tableName || "",
|
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);
|
setEditDialogOpen(true);
|
||||||
|
|
||||||
|
|
@ -298,14 +321,50 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTables(false);
|
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 () => {
|
const handleEditSave = async () => {
|
||||||
if (!screenToEdit) return;
|
if (!screenToEdit) return;
|
||||||
|
|
||||||
try {
|
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 호출
|
// 화면 정보 업데이트 API 호출
|
||||||
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
|
await screenApi.updateScreenInfo(screenToEdit.screenId, updateData);
|
||||||
|
|
||||||
// 선택된 테이블의 라벨 찾기
|
// 선택된 테이블의 라벨 찾기
|
||||||
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
|
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
|
||||||
|
|
@ -318,10 +377,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
screenName: editFormData.screenName,
|
screenName: editFormData.screenName,
|
||||||
tableName: editFormData.tableName,
|
tableName: updateData.tableName,
|
||||||
tableLabel: tableLabel,
|
tableLabel: tableLabel,
|
||||||
description: editFormData.description,
|
description: editFormData.description,
|
||||||
isActive: editFormData.isActive,
|
isActive: editFormData.isActive,
|
||||||
|
dataSourceType: editFormData.dataSourceType,
|
||||||
}
|
}
|
||||||
: s,
|
: s,
|
||||||
),
|
),
|
||||||
|
|
@ -425,7 +485,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 체크박스 선택 처리
|
// 휴지통 체크박스 선택 처리
|
||||||
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedScreenIds((prev) => [...prev, screenId]);
|
setSelectedScreenIds((prev) => [...prev, screenId]);
|
||||||
|
|
@ -434,7 +494,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전체 선택/해제
|
// 휴지통 전체 선택/해제
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
||||||
|
|
@ -443,7 +503,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 일괄삭제 실행
|
// 휴지통 일괄삭제 실행
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
if (selectedScreenIds.length === 0) {
|
if (selectedScreenIds.length === 0) {
|
||||||
alert("삭제할 화면을 선택해주세요.");
|
alert("삭제할 화면을 선택해주세요.");
|
||||||
|
|
@ -452,6 +512,70 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setBulkDeleteDialogOpen(true);
|
setBulkDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 활성 화면 체크박스 선택 처리
|
||||||
|
const handleActiveScreenCheck = (screenId: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedActiveScreenIds((prev) => [...prev, screenId]);
|
||||||
|
} else {
|
||||||
|
setSelectedActiveScreenIds((prev) => prev.filter((id) => id !== screenId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 활성 화면 전체 선택/해제
|
||||||
|
const handleActiveSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedActiveScreenIds(screens.map((screen) => screen.screenId));
|
||||||
|
} else {
|
||||||
|
setSelectedActiveScreenIds([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 활성 화면 일괄삭제 실행
|
||||||
|
const handleActiveBulkDelete = () => {
|
||||||
|
if (selectedActiveScreenIds.length === 0) {
|
||||||
|
alert("삭제할 화면을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveBulkDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 활성 화면 일괄삭제 확인
|
||||||
|
const confirmActiveBulkDelete = async () => {
|
||||||
|
if (selectedActiveScreenIds.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActiveBulkDeleting(true);
|
||||||
|
const result = await screenApi.bulkDeleteScreens(
|
||||||
|
selectedActiveScreenIds,
|
||||||
|
activeBulkDeleteReason || undefined,
|
||||||
|
true // 강제 삭제 (의존성 무시)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 삭제된 화면들을 목록에서 제거
|
||||||
|
setScreens((prev) => prev.filter((screen) => !selectedActiveScreenIds.includes(screen.screenId)));
|
||||||
|
|
||||||
|
setSelectedActiveScreenIds([]);
|
||||||
|
setActiveBulkDeleteDialogOpen(false);
|
||||||
|
setActiveBulkDeleteReason("");
|
||||||
|
|
||||||
|
// 결과 메시지 표시
|
||||||
|
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
||||||
|
if (result.skippedCount > 0) {
|
||||||
|
message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`;
|
||||||
|
}
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("일괄 삭제 실패:", error);
|
||||||
|
alert("일괄 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setActiveBulkDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const confirmBulkDelete = async () => {
|
const confirmBulkDelete = async () => {
|
||||||
if (selectedScreenIds.length === 0) return;
|
if (selectedScreenIds.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -579,7 +703,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 구조 */}
|
{/* 탭 구조 */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={(value) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
// 탭 전환 시 선택 상태 초기화
|
||||||
|
setSelectedActiveScreenIds([]);
|
||||||
|
setSelectedScreenIds([]);
|
||||||
|
}}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
||||||
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
||||||
|
|
@ -587,11 +716,47 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
{/* 활성 화면 탭 */}
|
{/* 활성 화면 탭 */}
|
||||||
<TabsContent value="active">
|
<TabsContent value="active">
|
||||||
|
{/* 선택 삭제 헤더 (선택된 항목이 있을 때만 표시) */}
|
||||||
|
{selectedActiveScreenIds.length > 0 && (
|
||||||
|
<div className="bg-muted/50 mb-4 flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedActiveScreenIds.length}개 화면 선택됨
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedActiveScreenIds([])}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
선택 해제
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleActiveBulkDelete}
|
||||||
|
disabled={activeBulkDeleting}
|
||||||
|
className="h-8 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{activeBulkDeleting ? "삭제 중..." : "선택 삭제"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="h-12 w-12 px-4 py-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
||||||
|
onCheckedChange={handleActiveSelectAll}
|
||||||
|
aria-label="전체 선택"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">화면명</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">화면명</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">테이블명</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">테이블명</TableHead>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||||
|
|
@ -605,9 +770,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
||||||
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
||||||
}`}
|
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`}
|
||||||
onClick={() => onDesignScreen(screen)}
|
onClick={() => onDesignScreen(screen)}
|
||||||
>
|
>
|
||||||
|
<TableCell className="h-16 px-4 py-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedActiveScreenIds.includes(screen.screenId)}
|
||||||
|
onCheckedChange={(checked) => handleActiveScreenCheck(screen.screenId, checked as boolean)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`${screen.screenName} 선택`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{screen.screenName}</div>
|
<div className="font-medium">{screen.screenName}</div>
|
||||||
|
|
@ -703,24 +876,57 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
<div className="space-y-4 lg:hidden">
|
||||||
{screens.map((screen) => (
|
{/* 선택 헤더 */}
|
||||||
<div
|
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
|
||||||
key={screen.screenId}
|
<div className="flex items-center gap-3">
|
||||||
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
|
<Checkbox
|
||||||
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
|
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
||||||
}`}
|
onCheckedChange={handleActiveSelectAll}
|
||||||
onClick={() => handleScreenSelect(screen)}
|
aria-label="전체 선택"
|
||||||
>
|
/>
|
||||||
{/* 헤더 */}
|
<span className="text-sm text-muted-foreground">전체 선택</span>
|
||||||
<div className="mb-4 flex items-start justify-between">
|
</div>
|
||||||
<div className="flex-1">
|
{selectedActiveScreenIds.length > 0 && (
|
||||||
<h3 className="text-base font-semibold">{screen.screenName}</h3>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleActiveBulkDelete}
|
||||||
|
disabled={activeBulkDeleting}
|
||||||
|
className="h-9 gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{activeBulkDeleting ? "삭제 중..." : `${selectedActiveScreenIds.length}개 삭제`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 목록 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{screens.map((screen) => (
|
||||||
|
<div
|
||||||
|
key={screen.screenId}
|
||||||
|
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
|
||||||
|
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
|
||||||
|
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30 border-primary/50" : ""}`}
|
||||||
|
onClick={() => handleScreenSelect(screen)}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedActiveScreenIds.includes(screen.screenId)}
|
||||||
|
onCheckedChange={(checked) => handleActiveScreenCheck(screen.screenId, checked as boolean)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="mt-1"
|
||||||
|
aria-label={`${screen.screenName} 선택`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-base font-semibold">{screen.screenName}</h3>
|
||||||
|
</div>
|
||||||
|
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
|
||||||
{screen.isActive === "Y" ? "활성" : "비활성"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
|
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
|
||||||
|
|
@ -809,11 +1015,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filteredScreens.length === 0 && (
|
{filteredScreens.length === 0 && (
|
||||||
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -1171,13 +1378,13 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 일괄삭제 확인 다이얼로그 */}
|
{/* 휴지통 일괄삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-destructive">
|
<AlertDialogDescription className="text-destructive">
|
||||||
⚠️ 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||||||
<br />
|
<br />
|
||||||
|
|
@ -1200,6 +1407,44 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 활성 화면 일괄삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={activeBulkDeleteDialogOpen} onOpenChange={setActiveBulkDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>선택 화면 삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
선택된 {selectedActiveScreenIds.length}개 화면을 휴지통으로 이동하시겠습니까?
|
||||||
|
<br />
|
||||||
|
휴지통에서 언제든지 복원할 수 있습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="activeBulkDeleteReason">삭제 사유 (선택사항)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="activeBulkDeleteReason"
|
||||||
|
placeholder="삭제 사유를 입력하세요..."
|
||||||
|
value={activeBulkDeleteReason}
|
||||||
|
onChange={(e) => setActiveBulkDeleteReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => {
|
||||||
|
setActiveBulkDeleteDialogOpen(false);
|
||||||
|
setActiveBulkDeleteReason("");
|
||||||
|
}}
|
||||||
|
disabled={activeBulkDeleting}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmActiveBulkDelete} variant="destructive" disabled={activeBulkDeleting}>
|
||||||
|
{activeBulkDeleting ? "삭제 중..." : `${selectedActiveScreenIds.length}개 휴지통으로 이동`}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 화면 편집 다이얼로그 */}
|
{/* 화면 편집 다이얼로그 */}
|
||||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
|
@ -1216,65 +1461,184 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
placeholder="화면명을 입력하세요"
|
placeholder="화면명을 입력하세요"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 소스 타입 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-tableName">테이블 *</Label>
|
<Label>데이터 소스 타입</Label>
|
||||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
<Select
|
||||||
<PopoverTrigger asChild>
|
value={editFormData.dataSourceType}
|
||||||
<Button
|
onValueChange={(value: "database" | "restapi") => {
|
||||||
variant="outline"
|
setEditFormData({
|
||||||
role="combobox"
|
...editFormData,
|
||||||
aria-expanded={tableComboboxOpen}
|
dataSourceType: value,
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
tableName: "",
|
||||||
disabled={loadingTables}
|
restApiConnectionId: null,
|
||||||
>
|
restApiEndpoint: "",
|
||||||
{loadingTables
|
restApiJsonPath: "data",
|
||||||
? "로딩 중..."
|
});
|
||||||
: editFormData.tableName
|
}}
|
||||||
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
|
>
|
||||||
: "테이블을 선택하세요"}
|
<SelectTrigger>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<SelectValue />
|
||||||
</Button>
|
</SelectTrigger>
|
||||||
</PopoverTrigger>
|
<SelectContent>
|
||||||
<PopoverContent
|
<SelectItem value="database">데이터베이스</SelectItem>
|
||||||
className="p-0"
|
<SelectItem value="restapi">REST API</SelectItem>
|
||||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
</SelectContent>
|
||||||
align="start"
|
</Select>
|
||||||
>
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* 데이터베이스 선택 (database 타입인 경우) */}
|
||||||
|
{editFormData.dataSourceType === "database" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-tableName">테이블 *</Label>
|
||||||
|
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboboxOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
disabled={loadingTables}
|
||||||
|
>
|
||||||
|
{loadingTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: editFormData.tableName
|
||||||
|
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
|
||||||
|
: "테이블을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setEditFormData({ ...editFormData, tableName: table.tableName });
|
||||||
|
setTableComboboxOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* REST API 선택 (restapi 타입인 경우) */}
|
||||||
|
{editFormData.dataSourceType === "restapi" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>REST API 연결 *</Label>
|
||||||
|
<Popover open={editRestApiComboboxOpen} onOpenChange={setEditRestApiComboboxOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={editRestApiComboboxOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{editFormData.restApiConnectionId
|
||||||
|
? editRestApiConnections.find((c) => c.id === editFormData.restApiConnectionId)?.connection_name || "선택된 연결"
|
||||||
|
: "REST API 연결 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="연결 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
|
연결을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{editRestApiConnections.map((conn) => (
|
||||||
|
<CommandItem
|
||||||
|
key={conn.id}
|
||||||
|
value={conn.connection_name}
|
||||||
|
onSelect={() => {
|
||||||
|
setEditFormData({ ...editFormData, restApiConnectionId: conn.id || null });
|
||||||
|
setEditRestApiComboboxOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
editFormData.restApiConnectionId === conn.id ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{conn.connection_name}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{conn.base_url}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-restApiEndpoint">API 엔드포인트</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-restApiEndpoint"
|
||||||
|
value={editFormData.restApiEndpoint}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, restApiEndpoint: e.target.value })}
|
||||||
|
placeholder="예: /api/data/list"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
데이터를 조회할 API 엔드포인트 경로입니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-restApiJsonPath">JSON 경로</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-restApiJsonPath"
|
||||||
|
value={editFormData.restApiJsonPath}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, restApiJsonPath: e.target.value })}
|
||||||
|
placeholder="예: data 또는 result.items"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
응답 JSON에서 데이터 배열의 경로입니다 (기본: data)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-description">설명</Label>
|
<Label htmlFor="edit-description">설명</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -1305,7 +1669,14 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim() || !editFormData.tableName.trim()}>
|
<Button
|
||||||
|
onClick={handleEditSave}
|
||||||
|
disabled={
|
||||||
|
!editFormData.screenName.trim() ||
|
||||||
|
(editFormData.dataSourceType === "database" && !editFormData.tableName.trim()) ||
|
||||||
|
(editFormData.dataSourceType === "restapi" && !editFormData.restApiConnectionId)
|
||||||
|
}
|
||||||
|
>
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||||
<SelectItem value="empty_vehicle">공차등록</SelectItem>
|
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
|
||||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -1664,190 +1664,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</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">
|
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||||
<h4 className="text-sm font-medium text-foreground">🚛 공차등록 설정</h4>
|
... 공차등록 설정 UI 생략 ...
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* 운행알림 및 종료 설정 */}
|
{/* 운행알림 및 종료 설정 */}
|
||||||
{(component.componentConfig?.action?.type || "save") === "operation_control" && (
|
{(component.componentConfig?.action?.type || "save") === "operation_control" && (
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2 } from "lucide-react";
|
import { X, Loader2 } from "lucide-react";
|
||||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface TabsWidgetProps {
|
interface TabsWidgetProps {
|
||||||
component: TabsComponent;
|
component: TabsComponent;
|
||||||
|
|
@ -48,6 +49,8 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
||||||
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||||
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
||||||
|
// 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱)
|
||||||
|
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||||
|
|
||||||
// 컴포넌트 탭 목록 변경 시 동기화
|
// 컴포넌트 탭 목록 변경 시 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -109,6 +112,14 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
console.log("🔄 탭 변경:", tabId);
|
console.log("🔄 탭 변경:", tabId);
|
||||||
setSelectedTab(tabId);
|
setSelectedTab(tabId);
|
||||||
|
|
||||||
|
// 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
||||||
|
setMountedTabs(prev => {
|
||||||
|
if (prev.has(tabId)) return prev;
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(tabId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
// 해당 탭의 화면 로드
|
// 해당 탭의 화면 로드
|
||||||
const tab = visibleTabs.find((t) => t.id === tabId);
|
const tab = visibleTabs.find((t) => t.id === tabId);
|
||||||
|
|
@ -191,72 +202,95 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{visibleTabs.map((tab) => (
|
{visibleTabs.map((tab) => {
|
||||||
<TabsContent key={tab.id} value={tab.id} className="h-full">
|
// 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩)
|
||||||
{tab.screenId ? (
|
const shouldRender = mountedTabs.has(tab.id);
|
||||||
loadingScreens[tab.screenId] ? (
|
const isActive = selectedTab === tab.id;
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
return (
|
||||||
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
<TabsContent
|
||||||
</div>
|
key={tab.id}
|
||||||
) : screenLayouts[tab.screenId] ? (
|
value={tab.id}
|
||||||
(() => {
|
forceMount // 🆕 DOM에 항상 유지
|
||||||
const layoutData = screenLayouts[tab.screenId];
|
className={cn(
|
||||||
const { components = [], screenResolution } = layoutData;
|
"h-full",
|
||||||
|
!isActive && "hidden" // 🆕 비활성 탭은 CSS로 숨김
|
||||||
console.log("🎯 렌더링할 화면 데이터:", {
|
)}
|
||||||
screenId: tab.screenId,
|
>
|
||||||
componentsCount: components.length,
|
{/* 한 번 마운트된 탭만 내용 렌더링 */}
|
||||||
screenResolution,
|
{shouldRender && (
|
||||||
});
|
<>
|
||||||
|
{tab.screenId ? (
|
||||||
const designWidth = screenResolution?.width || 1920;
|
loadingScreens[tab.screenId] ? (
|
||||||
const designHeight = screenResolution?.height || 1080;
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
return (
|
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
||||||
<div
|
|
||||||
className="relative h-full w-full overflow-auto bg-background"
|
|
||||||
style={{
|
|
||||||
minHeight: `${designHeight}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
width: `${designWidth}px`,
|
|
||||||
height: `${designHeight}px`,
|
|
||||||
margin: "0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{components.map((component: any) => (
|
|
||||||
<InteractiveScreenViewerDynamic
|
|
||||||
key={component.id}
|
|
||||||
component={component}
|
|
||||||
allComponents={components}
|
|
||||||
screenInfo={{
|
|
||||||
id: tab.screenId,
|
|
||||||
tableName: layoutData.tableName,
|
|
||||||
}}
|
|
||||||
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : screenLayouts[tab.screenId] ? (
|
||||||
|
(() => {
|
||||||
|
const layoutData = screenLayouts[tab.screenId];
|
||||||
|
const { components = [], screenResolution } = layoutData;
|
||||||
|
|
||||||
|
// 비활성 탭은 로그 생략
|
||||||
|
if (isActive) {
|
||||||
|
console.log("🎯 렌더링할 화면 데이터:", {
|
||||||
|
screenId: tab.screenId,
|
||||||
|
componentsCount: components.length,
|
||||||
|
screenResolution,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const designWidth = screenResolution?.width || 1920;
|
||||||
|
const designHeight = screenResolution?.height || 1080;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full overflow-auto bg-background"
|
||||||
|
style={{
|
||||||
|
minHeight: `${designHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width: `${designWidth}px`,
|
||||||
|
height: `${designHeight}px`,
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{components.map((component: any) => (
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
|
key={component.id}
|
||||||
|
component={component}
|
||||||
|
allComponents={components}
|
||||||
|
screenInfo={{
|
||||||
|
id: tab.screenId,
|
||||||
|
tableName: layoutData.tableName,
|
||||||
|
}}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
|
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()
|
</>
|
||||||
) : (
|
)}
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
</TabsContent>
|
||||||
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
);
|
||||||
</div>
|
})}
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
|
||||||
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -52,23 +52,12 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [color, setColor] = useState("none");
|
const [color, setColor] = useState("none");
|
||||||
|
|
||||||
// 라벨에서 코드 자동 생성
|
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
|
||||||
const generateCode = (label: string): string => {
|
const generateCode = (): string => {
|
||||||
// 한글을 영문으로 변환하거나, 영문/숫자만 추출하여 대문자로
|
// 항상 CATEGORY_TIMESTAMP_RANDOM 형식으로 고유 코드 생성
|
||||||
const cleaned = label
|
const timestamp = Date.now().toString().slice(-6);
|
||||||
.replace(/[^a-zA-Z0-9가-힣\s]/g, "") // 특수문자 제거
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
.trim()
|
return `CATEGORY_${timestamp}${random}`;
|
||||||
.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 = () => {
|
const handleSubmit = () => {
|
||||||
|
|
@ -76,7 +65,7 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueCode = generateCode(valueLabel);
|
const valueCode = generateCode();
|
||||||
|
|
||||||
onAdd({
|
onAdd({
|
||||||
tableName: "", // CategoryValueManager에서 오버라이드됨
|
tableName: "", // CategoryValueManager에서 오버라이드됨
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
|
|
||||||
onUpdate(value.valueId!, {
|
onUpdate(value.valueId!, {
|
||||||
valueLabel: valueLabel.trim(),
|
valueLabel: valueLabel.trim(),
|
||||||
description: description.trim(),
|
description: description.trim() || undefined, // 빈 문자열 대신 undefined
|
||||||
color: color,
|
color: color === "none" ? null : color, // "none"은 null로 (배지 없음)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ function SelectTrigger({
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,660 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getSummaryReport,
|
||||||
|
getDailyReport,
|
||||||
|
getMonthlyReport,
|
||||||
|
getDriverReport,
|
||||||
|
getRouteReport,
|
||||||
|
formatDistance,
|
||||||
|
formatDuration,
|
||||||
|
SummaryReport,
|
||||||
|
DailyStat,
|
||||||
|
MonthlyStat,
|
||||||
|
DriverStat,
|
||||||
|
RouteStat,
|
||||||
|
} from "@/lib/api/vehicleTrip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Car,
|
||||||
|
Route,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
MapPin,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
|
||||||
|
export default function VehicleReport() {
|
||||||
|
// 요약 통계
|
||||||
|
const [summary, setSummary] = useState<SummaryReport | null>(null);
|
||||||
|
const [summaryPeriod, setSummaryPeriod] = useState("month");
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
|
|
||||||
|
// 일별 통계
|
||||||
|
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
|
||||||
|
const [dailyStartDate, setDailyStartDate] = useState(
|
||||||
|
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
const [dailyEndDate, setDailyEndDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
const [dailyLoading, setDailyLoading] = useState(false);
|
||||||
|
|
||||||
|
// 월별 통계
|
||||||
|
const [monthlyData, setMonthlyData] = useState<MonthlyStat[]>([]);
|
||||||
|
const [monthlyYear, setMonthlyYear] = useState(new Date().getFullYear());
|
||||||
|
const [monthlyLoading, setMonthlyLoading] = useState(false);
|
||||||
|
|
||||||
|
// 운전자별 통계
|
||||||
|
const [driverData, setDriverData] = useState<DriverStat[]>([]);
|
||||||
|
const [driverLoading, setDriverLoading] = useState(false);
|
||||||
|
|
||||||
|
// 구간별 통계
|
||||||
|
const [routeData, setRouteData] = useState<RouteStat[]>([]);
|
||||||
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
|
|
||||||
|
// 요약 로드
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getSummaryReport(summaryPeriod);
|
||||||
|
if (response.success) {
|
||||||
|
setSummary(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("요약 통계 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}, [summaryPeriod]);
|
||||||
|
|
||||||
|
// 일별 로드
|
||||||
|
const loadDaily = useCallback(async () => {
|
||||||
|
setDailyLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getDailyReport({
|
||||||
|
startDate: dailyStartDate,
|
||||||
|
endDate: dailyEndDate,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
setDailyData(response.data?.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("일별 통계 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setDailyLoading(false);
|
||||||
|
}
|
||||||
|
}, [dailyStartDate, dailyEndDate]);
|
||||||
|
|
||||||
|
// 월별 로드
|
||||||
|
const loadMonthly = useCallback(async () => {
|
||||||
|
setMonthlyLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getMonthlyReport({ year: monthlyYear });
|
||||||
|
if (response.success) {
|
||||||
|
setMonthlyData(response.data?.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("월별 통계 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setMonthlyLoading(false);
|
||||||
|
}
|
||||||
|
}, [monthlyYear]);
|
||||||
|
|
||||||
|
// 운전자별 로드
|
||||||
|
const loadDrivers = useCallback(async () => {
|
||||||
|
setDriverLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getDriverReport({ limit: 20 });
|
||||||
|
if (response.success) {
|
||||||
|
setDriverData(response.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("운전자별 통계 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setDriverLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 구간별 로드
|
||||||
|
const loadRoutes = useCallback(async () => {
|
||||||
|
setRouteLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getRouteReport({ limit: 20 });
|
||||||
|
if (response.success) {
|
||||||
|
setRouteData(response.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("구간별 통계 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setRouteLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadSummary();
|
||||||
|
}, [loadSummary]);
|
||||||
|
|
||||||
|
// 기간 레이블
|
||||||
|
const getPeriodLabel = (period: string) => {
|
||||||
|
switch (period) {
|
||||||
|
case "today":
|
||||||
|
return "오늘";
|
||||||
|
case "week":
|
||||||
|
return "최근 7일";
|
||||||
|
case "month":
|
||||||
|
return "최근 30일";
|
||||||
|
case "year":
|
||||||
|
return "올해";
|
||||||
|
default:
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 요약 통계 카드 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">요약 통계</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={summaryPeriod} onValueChange={setSummaryPeriod}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="today">오늘</SelectItem>
|
||||||
|
<SelectItem value="week">최근 7일</SelectItem>
|
||||||
|
<SelectItem value="month">최근 30일</SelectItem>
|
||||||
|
<SelectItem value="year">올해</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadSummary}
|
||||||
|
disabled={summaryLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${summaryLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Car className="h-3 w-3" />
|
||||||
|
총 운행
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold">
|
||||||
|
{summary.totalTrips.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{getPeriodLabel(summaryPeriod)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
완료율
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold">
|
||||||
|
{summary.completionRate}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{summary.completedTrips} / {summary.totalTrips}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Route className="h-3 w-3" />
|
||||||
|
총 거리
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold">
|
||||||
|
{formatDistance(summary.totalDistance)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
평균 {formatDistance(summary.avgDistance)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
총 시간
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold">
|
||||||
|
{formatDuration(summary.totalDuration)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
평균 {formatDuration(Math.round(summary.avgDuration))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
운전자
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold">
|
||||||
|
{summary.activeDrivers}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">활동 중</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Car className="h-3 w-3" />
|
||||||
|
진행 중
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-green-600">
|
||||||
|
{summary.activeTrips}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">현재 운행</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 통계 탭 */}
|
||||||
|
<Tabs defaultValue="daily" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="daily" onClick={loadDaily}>
|
||||||
|
일별 통계
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="monthly" onClick={loadMonthly}>
|
||||||
|
월별 통계
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="drivers" onClick={loadDrivers}>
|
||||||
|
운전자별
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="routes" onClick={loadRoutes}>
|
||||||
|
구간별
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 일별 통계 */}
|
||||||
|
<TabsContent value="daily">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">일별 운행 통계</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Label className="text-xs">시작</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dailyStartDate}
|
||||||
|
onChange={(e) => setDailyStartDate(e.target.value)}
|
||||||
|
className="h-8 w-[130px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Label className="text-xs">종료</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dailyEndDate}
|
||||||
|
onChange={(e) => setDailyEndDate(e.target.value)}
|
||||||
|
className="h-8 w-[130px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={loadDaily} disabled={dailyLoading}>
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>날짜</TableHead>
|
||||||
|
<TableHead className="text-right">운행 수</TableHead>
|
||||||
|
<TableHead className="text-right">완료</TableHead>
|
||||||
|
<TableHead className="text-right">취소</TableHead>
|
||||||
|
<TableHead className="text-right">총 거리</TableHead>
|
||||||
|
<TableHead className="text-right">평균 거리</TableHead>
|
||||||
|
<TableHead className="text-right">총 시간</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dailyLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : dailyData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
dailyData.map((row) => (
|
||||||
|
<TableRow key={row.date}>
|
||||||
|
<TableCell>
|
||||||
|
{format(new Date(row.date), "MM/dd (E)", {
|
||||||
|
locale: ko,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.tripCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.completedCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.cancelledCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.totalDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.avgDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDuration(row.totalDuration)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 월별 통계 */}
|
||||||
|
<TabsContent value="monthly">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">월별 운행 통계</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(monthlyYear)}
|
||||||
|
onValueChange={(v) => setMonthlyYear(parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[0, 1, 2].map((offset) => {
|
||||||
|
const year = new Date().getFullYear() - offset;
|
||||||
|
return (
|
||||||
|
<SelectItem key={year} value={String(year)}>
|
||||||
|
{year}년
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={loadMonthly}
|
||||||
|
disabled={monthlyLoading}
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>월</TableHead>
|
||||||
|
<TableHead className="text-right">운행 수</TableHead>
|
||||||
|
<TableHead className="text-right">완료</TableHead>
|
||||||
|
<TableHead className="text-right">취소</TableHead>
|
||||||
|
<TableHead className="text-right">총 거리</TableHead>
|
||||||
|
<TableHead className="text-right">평균 거리</TableHead>
|
||||||
|
<TableHead className="text-right">운전자 수</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{monthlyLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : monthlyData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
monthlyData.map((row) => (
|
||||||
|
<TableRow key={row.month}>
|
||||||
|
<TableCell>{row.month}월</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.tripCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.completedCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.cancelledCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.totalDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.avgDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.driverCount}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 운전자별 통계 */}
|
||||||
|
<TabsContent value="drivers">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">운전자별 통계</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={loadDrivers}
|
||||||
|
disabled={driverLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-1 h-4 w-4 ${driverLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>운전자</TableHead>
|
||||||
|
<TableHead className="text-right">운행 수</TableHead>
|
||||||
|
<TableHead className="text-right">완료</TableHead>
|
||||||
|
<TableHead className="text-right">총 거리</TableHead>
|
||||||
|
<TableHead className="text-right">평균 거리</TableHead>
|
||||||
|
<TableHead className="text-right">총 시간</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{driverLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : driverData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
driverData.map((row) => (
|
||||||
|
<TableRow key={row.userId}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{row.userName}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.tripCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.completedCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.totalDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.avgDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDuration(row.totalDuration)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 구간별 통계 */}
|
||||||
|
<TabsContent value="routes">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">구간별 통계</CardTitle>
|
||||||
|
<Button size="sm" onClick={loadRoutes} disabled={routeLoading}>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-1 h-4 w-4 ${routeLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
출발지
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
도착지
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">운행 수</TableHead>
|
||||||
|
<TableHead className="text-right">총 거리</TableHead>
|
||||||
|
<TableHead className="text-right">평균 거리</TableHead>
|
||||||
|
<TableHead className="text-right">평균 시간</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{routeLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : routeData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
routeData.map((row, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell>{row.departureName}</TableCell>
|
||||||
|
<TableCell>{row.destinationName}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{row.tripCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.totalDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDistance(row.avgDistance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDuration(Math.round(row.avgDuration))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,531 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getTripList,
|
||||||
|
getTripDetail,
|
||||||
|
formatDistance,
|
||||||
|
formatDuration,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusColor,
|
||||||
|
TripSummary,
|
||||||
|
TripDetail,
|
||||||
|
TripListFilters,
|
||||||
|
} from "@/lib/api/vehicleTrip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
MapPin,
|
||||||
|
Clock,
|
||||||
|
Route,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export default function VehicleTripHistory() {
|
||||||
|
// 상태
|
||||||
|
const [trips, setTrips] = useState<TripSummary[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [filters, setFilters] = useState<TripListFilters>({
|
||||||
|
status: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
departure: "",
|
||||||
|
arrival: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 상세 모달
|
||||||
|
const [selectedTrip, setSelectedTrip] = useState<TripDetail | null>(null);
|
||||||
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadTrips = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getTripList({
|
||||||
|
...filters,
|
||||||
|
status: filters.status || undefined,
|
||||||
|
startDate: filters.startDate || undefined,
|
||||||
|
endDate: filters.endDate || undefined,
|
||||||
|
departure: filters.departure || undefined,
|
||||||
|
arrival: filters.arrival || undefined,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: (page - 1) * PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setTrips(response.data || []);
|
||||||
|
setTotal(response.total || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("운행 이력 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTrips();
|
||||||
|
}, [loadTrips]);
|
||||||
|
|
||||||
|
// 상세 조회
|
||||||
|
const handleViewDetail = async (tripId: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailModalOpen(true);
|
||||||
|
try {
|
||||||
|
const response = await getTripDetail(tripId);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setSelectedTrip(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("운행 상세 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 변경
|
||||||
|
const handleFilterChange = (key: keyof TripListFilters, value: string) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1);
|
||||||
|
loadTrips();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setFilters({
|
||||||
|
status: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
departure: "",
|
||||||
|
arrival: "",
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">검색 조건</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{/* 상태 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">상태</Label>
|
||||||
|
<Select
|
||||||
|
value={filters.status || "all"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleFilterChange("status", v === "all" ? "" : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="active">운행 중</SelectItem>
|
||||||
|
<SelectItem value="completed">완료</SelectItem>
|
||||||
|
<SelectItem value="cancelled">취소됨</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시작일 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">시작일</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.startDate || ""}
|
||||||
|
onChange={(e) => handleFilterChange("startDate", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종료일 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">종료일</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.endDate || ""}
|
||||||
|
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출발지 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">출발지</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="출발지"
|
||||||
|
value={filters.departure || ""}
|
||||||
|
onChange={(e) => handleFilterChange("departure", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도착지 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">도착지</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="도착지"
|
||||||
|
value={filters.arrival || ""}
|
||||||
|
onChange={(e) => handleFilterChange("arrival", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button onClick={handleSearch} size="sm">
|
||||||
|
<Search className="mr-1 h-4 w-4" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="mr-1 h-4 w-4" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
운행 이력 ({total.toLocaleString()}건)
|
||||||
|
</CardTitle>
|
||||||
|
<Button onClick={loadTrips} variant="ghost" size="sm">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[120px]">운행ID</TableHead>
|
||||||
|
<TableHead>운전자</TableHead>
|
||||||
|
<TableHead>출발지</TableHead>
|
||||||
|
<TableHead>도착지</TableHead>
|
||||||
|
<TableHead>시작 시간</TableHead>
|
||||||
|
<TableHead>종료 시간</TableHead>
|
||||||
|
<TableHead className="text-right">거리</TableHead>
|
||||||
|
<TableHead className="text-right">시간</TableHead>
|
||||||
|
<TableHead className="text-center">상태</TableHead>
|
||||||
|
<TableHead className="w-[80px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="h-24 text-center">
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : trips.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="h-24 text-center">
|
||||||
|
운행 이력이 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
trips.map((trip) => (
|
||||||
|
<TableRow key={trip.trip_id}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{trip.trip_id.substring(0, 15)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{trip.user_name || trip.user_id}</TableCell>
|
||||||
|
<TableCell>{trip.departure_name || trip.departure || "-"}</TableCell>
|
||||||
|
<TableCell>{trip.destination_name || trip.arrival || "-"}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{format(new Date(trip.start_time), "MM/dd HH:mm", {
|
||||||
|
locale: ko,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{trip.end_time
|
||||||
|
? format(new Date(trip.end_time), "MM/dd HH:mm", {
|
||||||
|
locale: ko,
|
||||||
|
})
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{trip.total_distance
|
||||||
|
? formatDistance(Number(trip.total_distance))
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{trip.duration_minutes
|
||||||
|
? formatDuration(trip.duration_minutes)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={getStatusColor(trip.status)}>
|
||||||
|
{getStatusLabel(trip.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewDetail(trip.trip_id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 상세 모달 */}
|
||||||
|
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>운행 상세 정보</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : selectedTrip ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 요약 정보 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
출발지
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">
|
||||||
|
{selectedTrip.summary.departure_name ||
|
||||||
|
selectedTrip.summary.departure ||
|
||||||
|
"-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
도착지
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">
|
||||||
|
{selectedTrip.summary.destination_name ||
|
||||||
|
selectedTrip.summary.arrival ||
|
||||||
|
"-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Route className="h-3 w-3" />
|
||||||
|
총 거리
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">
|
||||||
|
{selectedTrip.summary.total_distance
|
||||||
|
? formatDistance(Number(selectedTrip.summary.total_distance))
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
운행 시간
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">
|
||||||
|
{selectedTrip.summary.duration_minutes
|
||||||
|
? formatDuration(selectedTrip.summary.duration_minutes)
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 운행 정보 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 font-medium">운행 정보</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">운행 ID</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{selectedTrip.summary.trip_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">운전자</span>
|
||||||
|
<span>
|
||||||
|
{selectedTrip.summary.user_name ||
|
||||||
|
selectedTrip.summary.user_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">시작 시간</span>
|
||||||
|
<span>
|
||||||
|
{format(
|
||||||
|
new Date(selectedTrip.summary.start_time),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
{ locale: ko }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">종료 시간</span>
|
||||||
|
<span>
|
||||||
|
{selectedTrip.summary.end_time
|
||||||
|
? format(
|
||||||
|
new Date(selectedTrip.summary.end_time),
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
{ locale: ko }
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">상태</span>
|
||||||
|
<Badge className={getStatusColor(selectedTrip.summary.status)}>
|
||||||
|
{getStatusLabel(selectedTrip.summary.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">위치 기록 수</span>
|
||||||
|
<span>{selectedTrip.summary.location_count}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 경로 데이터 */}
|
||||||
|
{selectedTrip.route && selectedTrip.route.length > 0 && (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 font-medium">
|
||||||
|
경로 데이터 ({selectedTrip.route.length}개 지점)
|
||||||
|
</h4>
|
||||||
|
<div className="max-h-48 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px]">#</TableHead>
|
||||||
|
<TableHead>위도</TableHead>
|
||||||
|
<TableHead>경도</TableHead>
|
||||||
|
<TableHead>정확도</TableHead>
|
||||||
|
<TableHead>이전 거리</TableHead>
|
||||||
|
<TableHead>기록 시간</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{selectedTrip.route.map((loc, idx) => (
|
||||||
|
<TableRow key={loc.id}>
|
||||||
|
<TableCell className="text-xs">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{loc.latitude.toFixed(6)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{loc.longitude.toFixed(6)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{loc.accuracy ? `${loc.accuracy.toFixed(0)}m` : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{loc.distance_from_prev
|
||||||
|
? formatDistance(Number(loc.distance_from_prev))
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{format(new Date(loc.recorded_at), "HH:mm:ss", {
|
||||||
|
locale: ko,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
데이터를 불러올 수 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -17,6 +17,24 @@ export interface SplitPanelDataReceiver {
|
||||||
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
|
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부모 데이터 매핑 설정
|
||||||
|
* 좌측 화면에서 선택한 데이터를 우측 화면 저장 시 자동으로 포함
|
||||||
|
*/
|
||||||
|
export interface ParentDataMapping {
|
||||||
|
sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code)
|
||||||
|
targetColumn: string; // 우측 화면 저장 시 사용할 컬럼명 (예: equipment_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 필터 설정
|
||||||
|
* 좌측 화면에서 선택한 데이터로 우측 화면의 테이블을 자동 필터링
|
||||||
|
*/
|
||||||
|
export interface LinkedFilter {
|
||||||
|
sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code)
|
||||||
|
targetColumn: string; // 우측 화면 필터링에 사용할 컬럼명 (예: equipment_code)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 분할 패널 컨텍스트 값
|
* 분할 패널 컨텍스트 값
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,6 +72,22 @@ interface SplitPanelContextValue {
|
||||||
addItemIds: (ids: string[]) => void;
|
addItemIds: (ids: string[]) => void;
|
||||||
removeItemIds: (ids: string[]) => void;
|
removeItemIds: (ids: string[]) => void;
|
||||||
clearItemIds: () => void;
|
clearItemIds: () => void;
|
||||||
|
|
||||||
|
// 🆕 좌측 선택 데이터 관리 (우측 화면 저장 시 부모 키 전달용)
|
||||||
|
selectedLeftData: Record<string, any> | null;
|
||||||
|
setSelectedLeftData: (data: Record<string, any> | null) => void;
|
||||||
|
|
||||||
|
// 🆕 부모 데이터 매핑 설정
|
||||||
|
parentDataMapping: ParentDataMapping[];
|
||||||
|
|
||||||
|
// 🆕 매핑된 부모 데이터 가져오기 (우측 화면 저장 시 사용)
|
||||||
|
getMappedParentData: () => Record<string, any>;
|
||||||
|
|
||||||
|
// 🆕 연결 필터 설정 (좌측 선택 → 우측 테이블 필터링)
|
||||||
|
linkedFilters: LinkedFilter[];
|
||||||
|
|
||||||
|
// 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용)
|
||||||
|
getLinkedFilterValues: () => Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||||
|
|
@ -62,6 +96,8 @@ interface SplitPanelProviderProps {
|
||||||
splitPanelId: string;
|
splitPanelId: string;
|
||||||
leftScreenId: number | null;
|
leftScreenId: number | null;
|
||||||
rightScreenId: number | null;
|
rightScreenId: number | null;
|
||||||
|
parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정
|
||||||
|
linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +108,8 @@ export function SplitPanelProvider({
|
||||||
splitPanelId,
|
splitPanelId,
|
||||||
leftScreenId,
|
leftScreenId,
|
||||||
rightScreenId,
|
rightScreenId,
|
||||||
|
parentDataMapping = [],
|
||||||
|
linkedFilters = [],
|
||||||
children,
|
children,
|
||||||
}: SplitPanelProviderProps) {
|
}: SplitPanelProviderProps) {
|
||||||
// 좌측/우측 화면의 데이터 수신자 맵
|
// 좌측/우측 화면의 데이터 수신자 맵
|
||||||
|
|
@ -83,6 +121,9 @@ export function SplitPanelProvider({
|
||||||
|
|
||||||
// 🆕 우측에 추가된 항목 ID 상태
|
// 🆕 우측에 추가된 항목 ID 상태
|
||||||
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
|
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 🆕 좌측에서 선택된 데이터 상태
|
||||||
|
const [selectedLeftData, setSelectedLeftData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 수신자 등록
|
* 데이터 수신자 등록
|
||||||
|
|
@ -232,6 +273,82 @@ export function SplitPanelProvider({
|
||||||
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
|
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 좌측 선택 데이터 설정
|
||||||
|
*/
|
||||||
|
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
|
||||||
|
logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, {
|
||||||
|
hasData: !!data,
|
||||||
|
dataKeys: data ? Object.keys(data) : [],
|
||||||
|
});
|
||||||
|
setSelectedLeftData(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 매핑된 부모 데이터 가져오기
|
||||||
|
* 우측 화면에서 저장 시 이 함수를 호출하여 부모 키 값을 가져옴
|
||||||
|
*
|
||||||
|
* 동작 방식:
|
||||||
|
* 1. 좌측 데이터의 모든 컬럼을 자동으로 전달 (동일한 컬럼명이면 자동 매핑)
|
||||||
|
* 2. 명시적 매핑이 있으면 소스→타겟 변환 적용 (다른 컬럼명으로 매핑 시)
|
||||||
|
*/
|
||||||
|
const getMappedParentData = useCallback((): Record<string, any> => {
|
||||||
|
if (!selectedLeftData) {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, {
|
||||||
|
autoMappedKeys: Object.keys(selectedLeftData),
|
||||||
|
explicitMappings: parentDataMapping.length,
|
||||||
|
finalKeys: Object.keys(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 객체 메모이제이션 (무한 루프 방지)
|
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||||
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
||||||
splitPanelId,
|
splitPanelId,
|
||||||
|
|
@ -247,6 +364,14 @@ export function SplitPanelProvider({
|
||||||
addItemIds,
|
addItemIds,
|
||||||
removeItemIds,
|
removeItemIds,
|
||||||
clearItemIds,
|
clearItemIds,
|
||||||
|
// 🆕 좌측 선택 데이터 관련
|
||||||
|
selectedLeftData,
|
||||||
|
setSelectedLeftData: handleSetSelectedLeftData,
|
||||||
|
parentDataMapping,
|
||||||
|
getMappedParentData,
|
||||||
|
// 🆕 연결 필터 관련
|
||||||
|
linkedFilters,
|
||||||
|
getLinkedFilterValues,
|
||||||
}), [
|
}), [
|
||||||
splitPanelId,
|
splitPanelId,
|
||||||
leftScreenId,
|
leftScreenId,
|
||||||
|
|
@ -260,6 +385,12 @@ export function SplitPanelProvider({
|
||||||
addItemIds,
|
addItemIds,
|
||||||
removeItemIds,
|
removeItemIds,
|
||||||
clearItemIds,
|
clearItemIds,
|
||||||
|
selectedLeftData,
|
||||||
|
handleSetSelectedLeftData,
|
||||||
|
parentDataMapping,
|
||||||
|
getMappedParentData,
|
||||||
|
linkedFilters,
|
||||||
|
getLinkedFilterValues,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@ import { useState, useCallback, useEffect } from "react";
|
||||||
import { ProfileFormData, ProfileModalState } from "@/types/profile";
|
import { ProfileFormData, ProfileModalState } from "@/types/profile";
|
||||||
import { LAYOUT_CONFIG, MESSAGES } from "@/constants/layout";
|
import { LAYOUT_CONFIG, MESSAGES } from "@/constants/layout";
|
||||||
import { apiCall } from "@/lib/api/client";
|
import { apiCall } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
getDriverProfile,
|
||||||
|
updateDriverProfile,
|
||||||
|
updateDriverStatus,
|
||||||
|
deleteDriverAccount,
|
||||||
|
deleteDriverVehicle,
|
||||||
|
registerDriverVehicle,
|
||||||
|
DriverProfile,
|
||||||
|
VehicleRegisterData,
|
||||||
|
} from "@/lib/api/driver";
|
||||||
|
import { DriverInfo, DriverFormData } from "@/components/layout/ProfileModal";
|
||||||
|
|
||||||
// 알림 모달 상태 타입
|
// 알림 모달 상태 타입
|
||||||
interface AlertModalState {
|
interface AlertModalState {
|
||||||
|
|
@ -48,6 +59,26 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
|
// 운전자 정보 상태
|
||||||
|
const [isDriver, setIsDriver] = useState(false);
|
||||||
|
const [hasVehicle, setHasVehicle] = useState(false); // 차량 보유 여부
|
||||||
|
const [driverInfo, setDriverInfo] = useState<DriverInfo | null>(null);
|
||||||
|
const [driverFormData, setDriverFormData] = useState<DriverFormData>({
|
||||||
|
vehicleNumber: "",
|
||||||
|
vehicleType: "",
|
||||||
|
licenseNumber: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
branchName: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 차량 등록 모달 상태
|
||||||
|
const [isVehicleRegisterModalOpen, setIsVehicleRegisterModalOpen] = useState(false);
|
||||||
|
const [newVehicleData, setNewVehicleData] = useState<VehicleRegisterData>({
|
||||||
|
vehicleNumber: "",
|
||||||
|
vehicleType: "",
|
||||||
|
branchName: "",
|
||||||
|
});
|
||||||
|
|
||||||
// 알림 모달 표시 함수
|
// 알림 모달 표시 함수
|
||||||
const showAlert = useCallback((title: string, message: string, type: "success" | "error" | "info" = "info") => {
|
const showAlert = useCallback((title: string, message: string, type: "success" | "error" | "info" = "info") => {
|
||||||
setAlertModal({
|
setAlertModal({
|
||||||
|
|
@ -75,6 +106,41 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 운전자 정보 로드 함수
|
||||||
|
const loadDriverInfo = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await getDriverProfile();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setIsDriver(true);
|
||||||
|
// 차량 보유 여부 확인
|
||||||
|
const vehicleExists = !!response.data.vehicleNumber;
|
||||||
|
setHasVehicle(vehicleExists);
|
||||||
|
setDriverInfo({
|
||||||
|
vehicleNumber: response.data.vehicleNumber,
|
||||||
|
vehicleType: response.data.vehicleType,
|
||||||
|
licenseNumber: response.data.licenseNumber,
|
||||||
|
phoneNumber: response.data.phoneNumber,
|
||||||
|
vehicleStatus: response.data.vehicleStatus,
|
||||||
|
branchName: response.data.branchName,
|
||||||
|
});
|
||||||
|
setDriverFormData({
|
||||||
|
vehicleNumber: response.data.vehicleNumber || "",
|
||||||
|
vehicleType: response.data.vehicleType || "",
|
||||||
|
licenseNumber: response.data.licenseNumber || "",
|
||||||
|
phoneNumber: response.data.phoneNumber || "",
|
||||||
|
branchName: response.data.branchName || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsDriver(false);
|
||||||
|
setHasVehicle(false);
|
||||||
|
setDriverInfo(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("운전자 정보 로드 실패:", error);
|
||||||
|
setIsDriver(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로필 모달 열기
|
* 프로필 모달 열기
|
||||||
*/
|
*/
|
||||||
|
|
@ -82,6 +148,8 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
if (user) {
|
if (user) {
|
||||||
// 부서 목록 로드
|
// 부서 목록 로드
|
||||||
loadDepartments();
|
loadDepartments();
|
||||||
|
// 운전자 정보 로드
|
||||||
|
loadDriverInfo();
|
||||||
|
|
||||||
setModalState((prev) => ({
|
setModalState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -98,7 +166,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [user, loadDepartments]);
|
}, [user, loadDepartments, loadDriverInfo]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로필 모달 닫기
|
* 프로필 모달 닫기
|
||||||
|
|
@ -125,6 +193,138 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운전자 폼 데이터 변경
|
||||||
|
*/
|
||||||
|
const updateDriverFormData = useCallback((field: keyof DriverFormData, value: string) => {
|
||||||
|
setDriverFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차량 상태 변경 (대기/정비)
|
||||||
|
*/
|
||||||
|
const handleDriverStatusChange = useCallback(
|
||||||
|
async (status: "off" | "maintenance") => {
|
||||||
|
try {
|
||||||
|
const response = await updateDriverStatus(status);
|
||||||
|
if (response.success) {
|
||||||
|
showAlert("상태 변경", response.message || "차량 상태가 변경되었습니다.", "success");
|
||||||
|
// 운전자 정보 새로고침
|
||||||
|
await loadDriverInfo();
|
||||||
|
} else {
|
||||||
|
showAlert("상태 변경 실패", response.message || "상태 변경에 실패했습니다.", "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("차량 상태 변경 실패:", error);
|
||||||
|
showAlert("오류", "상태 변경 중 오류가 발생했습니다.", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showAlert, loadDriverInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴
|
||||||
|
*/
|
||||||
|
const handleDriverAccountDelete = useCallback(async () => {
|
||||||
|
if (!confirm("정말로 탈퇴하시겠습니까?\n차량 정보가 함께 삭제되며, 이 작업은 되돌릴 수 없습니다.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await deleteDriverAccount();
|
||||||
|
if (response.success) {
|
||||||
|
showAlert("탈퇴 완료", "회원 탈퇴가 완료되었습니다.", "success");
|
||||||
|
// 로그아웃 처리
|
||||||
|
window.location.href = "/login";
|
||||||
|
} else {
|
||||||
|
showAlert("탈퇴 실패", response.message || "회원 탈퇴에 실패했습니다.", "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회원 탈퇴 실패:", error);
|
||||||
|
showAlert("오류", "회원 탈퇴 중 오류가 발생했습니다.", "error");
|
||||||
|
}
|
||||||
|
}, [showAlert]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차량 삭제
|
||||||
|
*/
|
||||||
|
const handleDeleteVehicle = useCallback(async () => {
|
||||||
|
if (!confirm("이 차량을 더 이상 사용하지 않습니까?\n차량 정보가 삭제됩니다.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await deleteDriverVehicle();
|
||||||
|
if (response.success) {
|
||||||
|
showAlert("삭제 완료", "차량이 삭제되었습니다.", "success");
|
||||||
|
// 운전자 정보 새로고침
|
||||||
|
await loadDriverInfo();
|
||||||
|
} else {
|
||||||
|
showAlert("삭제 실패", response.message || "차량 삭제에 실패했습니다.", "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("차량 삭제 실패:", error);
|
||||||
|
showAlert("오류", "차량 삭제 중 오류가 발생했습니다.", "error");
|
||||||
|
}
|
||||||
|
}, [showAlert, loadDriverInfo]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 차량 등록 모달 열기
|
||||||
|
*/
|
||||||
|
const openVehicleRegisterModal = useCallback(() => {
|
||||||
|
setNewVehicleData({
|
||||||
|
vehicleNumber: "",
|
||||||
|
vehicleType: "",
|
||||||
|
branchName: driverFormData.branchName || "", // 기존 소속 지점 유지
|
||||||
|
});
|
||||||
|
setIsVehicleRegisterModalOpen(true);
|
||||||
|
}, [driverFormData.branchName]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 차량 등록 모달 닫기
|
||||||
|
*/
|
||||||
|
const closeVehicleRegisterModal = useCallback(() => {
|
||||||
|
setIsVehicleRegisterModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 차량 데이터 변경
|
||||||
|
*/
|
||||||
|
const updateNewVehicleData = useCallback((field: keyof VehicleRegisterData, value: string) => {
|
||||||
|
setNewVehicleData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 차량 등록 처리
|
||||||
|
*/
|
||||||
|
const handleRegisterVehicle = useCallback(async () => {
|
||||||
|
if (!newVehicleData.vehicleNumber) {
|
||||||
|
showAlert("입력 오류", "차량번호는 필수입니다.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await registerDriverVehicle(newVehicleData);
|
||||||
|
if (response.success) {
|
||||||
|
showAlert("등록 완료", "차량이 등록되었습니다.", "success");
|
||||||
|
setIsVehicleRegisterModalOpen(false);
|
||||||
|
// 운전자 정보 새로고침
|
||||||
|
await loadDriverInfo();
|
||||||
|
} else {
|
||||||
|
showAlert("등록 실패", response.message || "차량 등록에 실패했습니다.", "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("차량 등록 실패:", error);
|
||||||
|
showAlert("오류", "차량 등록 중 오류가 발생했습니다.", "error");
|
||||||
|
}
|
||||||
|
}, [newVehicleData, showAlert, loadDriverInfo]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 선택 처리
|
* 이미지 선택 처리
|
||||||
*/
|
*/
|
||||||
|
|
@ -229,6 +429,22 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
// API 호출 (JWT 토큰 자동 포함)
|
// API 호출 (JWT 토큰 자동 포함)
|
||||||
const response = await apiCall("PUT", "/admin/profile", updateData);
|
const response = await apiCall("PUT", "/admin/profile", updateData);
|
||||||
|
|
||||||
|
// 운전자 정보도 저장 (운전자인 경우)
|
||||||
|
if (isDriver) {
|
||||||
|
const driverResponse = await updateDriverProfile({
|
||||||
|
userName: modalState.formData.userName,
|
||||||
|
phoneNumber: driverFormData.phoneNumber,
|
||||||
|
licenseNumber: driverFormData.licenseNumber,
|
||||||
|
vehicleNumber: driverFormData.vehicleNumber,
|
||||||
|
vehicleType: driverFormData.vehicleType,
|
||||||
|
branchName: driverFormData.branchName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!driverResponse.success) {
|
||||||
|
console.warn("운전자 정보 저장 실패:", driverResponse.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.success || (response as any).result) {
|
if (response.success || (response as any).result) {
|
||||||
// locale이 변경된 경우 전역 변수와 localStorage 업데이트
|
// locale이 변경된 경우 전역 변수와 localStorage 업데이트
|
||||||
const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale;
|
const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale;
|
||||||
|
|
@ -265,7 +481,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
} finally {
|
} finally {
|
||||||
setModalState((prev) => ({ ...prev, isSaving: false }));
|
setModalState((prev) => ({ ...prev, isSaving: false }));
|
||||||
}
|
}
|
||||||
}, [user, modalState.selectedFile, modalState.selectedImage, modalState.formData, refreshUserData, showAlert]);
|
}, [user, modalState.selectedFile, modalState.selectedImage, modalState.formData, refreshUserData, showAlert, isDriver, driverFormData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 상태
|
// 상태
|
||||||
|
|
@ -279,6 +495,16 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
alertModal,
|
alertModal,
|
||||||
closeAlert,
|
closeAlert,
|
||||||
|
|
||||||
|
// 운전자 관련 상태
|
||||||
|
isDriver,
|
||||||
|
hasVehicle,
|
||||||
|
driverInfo,
|
||||||
|
driverFormData,
|
||||||
|
|
||||||
|
// 새 차량 등록 모달 상태
|
||||||
|
isVehicleRegisterModalOpen,
|
||||||
|
newVehicleData,
|
||||||
|
|
||||||
// 액션
|
// 액션
|
||||||
openProfileModal,
|
openProfileModal,
|
||||||
closeProfileModal,
|
closeProfileModal,
|
||||||
|
|
@ -286,5 +512,15 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
selectImage,
|
selectImage,
|
||||||
removeImage,
|
removeImage,
|
||||||
saveProfile,
|
saveProfile,
|
||||||
|
|
||||||
|
// 운전자 관련 액션
|
||||||
|
updateDriverFormData,
|
||||||
|
handleDriverStatusChange,
|
||||||
|
handleDriverAccountDelete,
|
||||||
|
handleDeleteVehicle,
|
||||||
|
openVehicleRegisterModal,
|
||||||
|
closeVehicleRegisterModal,
|
||||||
|
updateNewVehicleData,
|
||||||
|
handleRegisterVehicle,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
// 공차중계 운전자 API
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface DriverProfile {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
licenseNumber: string;
|
||||||
|
vehicleNumber: string;
|
||||||
|
vehicleType: string | null;
|
||||||
|
vehicleStatus: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverProfileUpdateData {
|
||||||
|
userName?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
licenseNumber?: string;
|
||||||
|
vehicleNumber?: string;
|
||||||
|
vehicleType?: string;
|
||||||
|
branchName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운전자 프로필 조회
|
||||||
|
*/
|
||||||
|
export async function getDriverProfile(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: DriverProfile;
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/driver/profile");
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "프로필 조회에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운전자 프로필 수정
|
||||||
|
*/
|
||||||
|
export async function updateDriverProfile(
|
||||||
|
data: DriverProfileUpdateData
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put("/driver/profile", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "프로필 수정에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차량 상태 변경 (대기/정비)
|
||||||
|
*/
|
||||||
|
export async function updateDriverStatus(
|
||||||
|
status: "off" | "maintenance"
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put("/driver/status", { status });
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "상태 변경에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차량 삭제 (기록 보존)
|
||||||
|
*/
|
||||||
|
export async function deleteDriverVehicle(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete("/driver/vehicle");
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "차량 삭제에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 차량 등록
|
||||||
|
*/
|
||||||
|
export interface VehicleRegisterData {
|
||||||
|
vehicleNumber: string;
|
||||||
|
vehicleType?: string;
|
||||||
|
branchName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDriverVehicle(
|
||||||
|
data: VehicleRegisterData
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/driver/vehicle", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "차량 등록에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴
|
||||||
|
*/
|
||||||
|
export async function deleteDriverAccount(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete("/driver/account");
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "회원 탈퇴에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +112,22 @@ export const screenApi = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
|
bulkDeleteScreens: async (
|
||||||
|
screenIds: number[],
|
||||||
|
deleteReason?: string,
|
||||||
|
force?: boolean,
|
||||||
|
): Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errors: Array<{ screenId: number; error: string }>;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.delete("/screen-management/screens/bulk/delete", {
|
||||||
|
data: { screenIds, deleteReason, force },
|
||||||
|
});
|
||||||
|
return response.data.result;
|
||||||
|
},
|
||||||
|
|
||||||
// 휴지통 화면 목록 조회
|
// 휴지통 화면 목록 조회
|
||||||
getDeletedScreens: async (params: {
|
getDeletedScreens: async (params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 이력 API 클라이언트
|
||||||
|
*/
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// 타입 정의
|
||||||
|
export interface TripSummary {
|
||||||
|
id: number;
|
||||||
|
trip_id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name?: string;
|
||||||
|
vehicle_id?: number;
|
||||||
|
vehicle_number?: string;
|
||||||
|
departure?: string;
|
||||||
|
arrival?: string;
|
||||||
|
departure_name?: string;
|
||||||
|
destination_name?: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string;
|
||||||
|
total_distance: number;
|
||||||
|
duration_minutes?: number;
|
||||||
|
status: "active" | "completed" | "cancelled";
|
||||||
|
location_count: number;
|
||||||
|
company_code: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripLocation {
|
||||||
|
id: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracy?: number;
|
||||||
|
speed?: number;
|
||||||
|
distance_from_prev?: number;
|
||||||
|
trip_status: "start" | "tracking" | "end";
|
||||||
|
recorded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripDetail {
|
||||||
|
summary: TripSummary;
|
||||||
|
route: TripLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripListFilters {
|
||||||
|
userId?: string;
|
||||||
|
vehicleId?: number;
|
||||||
|
status?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
departure?: string;
|
||||||
|
arrival?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartTripParams {
|
||||||
|
vehicleId?: number;
|
||||||
|
departure?: string;
|
||||||
|
arrival?: string;
|
||||||
|
departureName?: string;
|
||||||
|
destinationName?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndTripParams {
|
||||||
|
tripId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddLocationParams {
|
||||||
|
tripId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracy?: number;
|
||||||
|
speed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 함수들
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 시작
|
||||||
|
*/
|
||||||
|
export async function startTrip(params: StartTripParams) {
|
||||||
|
const response = await apiClient.post("/vehicle/trip/start", params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 종료
|
||||||
|
*/
|
||||||
|
export async function endTrip(params: EndTripParams) {
|
||||||
|
const response = await apiClient.post("/vehicle/trip/end", params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 기록 추가 (연속 추적)
|
||||||
|
*/
|
||||||
|
export async function addTripLocation(params: AddLocationParams) {
|
||||||
|
const response = await apiClient.post("/vehicle/trip/location", params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 운행 조회
|
||||||
|
*/
|
||||||
|
export async function getActiveTrip() {
|
||||||
|
const response = await apiClient.get("/vehicle/trip/active");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 취소
|
||||||
|
*/
|
||||||
|
export async function cancelTrip(tripId: string) {
|
||||||
|
const response = await apiClient.post("/vehicle/trip/cancel", { tripId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 이력 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getTripList(filters?: TripListFilters) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
if (filters.userId) params.append("userId", filters.userId);
|
||||||
|
if (filters.vehicleId) params.append("vehicleId", String(filters.vehicleId));
|
||||||
|
if (filters.status) params.append("status", filters.status);
|
||||||
|
if (filters.startDate) params.append("startDate", filters.startDate);
|
||||||
|
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||||
|
if (filters.departure) params.append("departure", filters.departure);
|
||||||
|
if (filters.arrival) params.append("arrival", filters.arrival);
|
||||||
|
if (filters.limit) params.append("limit", String(filters.limit));
|
||||||
|
if (filters.offset) params.append("offset", String(filters.offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/vehicle/trips?${queryString}` : "/vehicle/trips";
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 상세 조회 (경로 포함)
|
||||||
|
*/
|
||||||
|
export async function getTripDetail(tripId: string): Promise<{ success: boolean; data?: TripDetail; message?: string }> {
|
||||||
|
const response = await apiClient.get(`/vehicle/trips/${tripId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 거리 포맷팅 (km)
|
||||||
|
*/
|
||||||
|
export function formatDistance(distanceKm: number): string {
|
||||||
|
if (distanceKm < 1) {
|
||||||
|
return `${Math.round(distanceKm * 1000)}m`;
|
||||||
|
}
|
||||||
|
return `${distanceKm.toFixed(2)}km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 시간 포맷팅
|
||||||
|
*/
|
||||||
|
export function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}분`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 한글 변환
|
||||||
|
*/
|
||||||
|
export function getStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "운행 중";
|
||||||
|
case "completed":
|
||||||
|
return "완료";
|
||||||
|
case "cancelled":
|
||||||
|
return "취소됨";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태별 색상
|
||||||
|
*/
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case "completed":
|
||||||
|
return "bg-blue-100 text-blue-800";
|
||||||
|
case "cancelled":
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== 리포트 API ==============
|
||||||
|
|
||||||
|
export interface DailyStat {
|
||||||
|
date: string;
|
||||||
|
tripCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
cancelledCount: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
avgDistance: number;
|
||||||
|
avgDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStat {
|
||||||
|
weekNumber: number;
|
||||||
|
weekStart: string;
|
||||||
|
weekEnd: string;
|
||||||
|
tripCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
avgDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyStat {
|
||||||
|
month: number;
|
||||||
|
tripCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
cancelledCount: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
avgDistance: number;
|
||||||
|
avgDuration: number;
|
||||||
|
driverCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryReport {
|
||||||
|
period: string;
|
||||||
|
totalTrips: number;
|
||||||
|
completedTrips: number;
|
||||||
|
activeTrips: number;
|
||||||
|
cancelledTrips: number;
|
||||||
|
completionRate: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
avgDistance: number;
|
||||||
|
avgDuration: number;
|
||||||
|
activeDrivers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverStat {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
tripCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
avgDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteStat {
|
||||||
|
departure: string;
|
||||||
|
arrival: string;
|
||||||
|
departureName: string;
|
||||||
|
destinationName: string;
|
||||||
|
tripCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
totalDistance: number;
|
||||||
|
avgDistance: number;
|
||||||
|
avgDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약 통계 조회 (대시보드용)
|
||||||
|
*/
|
||||||
|
export async function getSummaryReport(period?: string) {
|
||||||
|
const url = period ? `/vehicle/reports/summary?period=${period}` : "/vehicle/reports/summary";
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getDailyReport(filters?: { startDate?: string; endDate?: string; userId?: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||||
|
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||||
|
if (filters?.userId) params.append("userId", filters.userId);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/vehicle/reports/daily?${queryString}` : "/vehicle/reports/daily";
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getWeeklyReport(filters?: { year?: number; month?: number; userId?: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.year) params.append("year", String(filters.year));
|
||||||
|
if (filters?.month) params.append("month", String(filters.month));
|
||||||
|
if (filters?.userId) params.append("userId", filters.userId);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/vehicle/reports/weekly?${queryString}` : "/vehicle/reports/weekly";
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getMonthlyReport(filters?: { year?: number; userId?: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.year) params.append("year", String(filters.year));
|
||||||
|
if (filters?.userId) params.append("userId", filters.userId);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/vehicle/reports/monthly?${queryString}` : "/vehicle/reports/monthly";
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운전자별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getDriverReport(filters?: { startDate?: string; endDate?: string; limit?: number }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||||
|
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||||
|
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/vehicle/reports/by-driver?${queryString}` : "/vehicle/reports/by-driver";
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구간별 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getRouteReport(filters?: { startDate?: string; endDate?: string; limit?: number }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.startDate) params.append("startDate", filters.startDate);
|
||||||
|
if (filters?.endDate) params.append("endDate", filters.endDate);
|
||||||
|
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `/vehicle/reports/by-route?${queryString}` : "/vehicle/reports/by-route";
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -374,6 +374,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
height: component.size?.height ? `${component.size.height}px` : component.style?.height,
|
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 = {
|
const rendererProps = {
|
||||||
component,
|
component,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|
@ -396,7 +401,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
formData,
|
formData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
||||||
tableName,
|
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
|
||||||
|
tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName,
|
||||||
menuId, // 🆕 메뉴 ID
|
menuId, // 🆕 메뉴 ID
|
||||||
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
||||||
selectedScreen, // 🆕 화면 정보
|
selectedScreen, // 🆕 화면 정보
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
effectiveScreenId,
|
effectiveScreenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
|
||||||
|
// 조건 완화: 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이 없으면 탭 안
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: formData || {},
|
formData: formData || {},
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
|
|
@ -720,6 +739,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
flowSelectedStepId,
|
flowSelectedStepId,
|
||||||
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
||||||
componentConfigs,
|
componentConfigs,
|
||||||
|
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
||||||
|
splitPanelParentData,
|
||||||
} as ButtonActionContext;
|
} as ButtonActionContext;
|
||||||
|
|
||||||
// 확인이 필요한 액션인지 확인
|
// 확인이 필요한 액션인지 확인
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo } from "react";
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { CardDisplayConfig } from "./types";
|
import { CardDisplayConfig } from "./types";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { getFullImageUrl, apiClient } from "@/lib/api/client";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { useModalDataStore } from "@/stores/modalDataStore";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options";
|
||||||
|
|
||||||
export interface CardDisplayComponentProps extends ComponentRendererProps {
|
export interface CardDisplayComponentProps extends ComponentRendererProps {
|
||||||
config?: CardDisplayConfig;
|
config?: CardDisplayConfig;
|
||||||
|
|
@ -38,10 +45,47 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
tableColumns = [],
|
tableColumns = [],
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음)
|
||||||
|
const screenContext = useScreenContextOptional();
|
||||||
|
const splitPanelContext = useSplitPanelContext();
|
||||||
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
||||||
|
// TableOptions Context (검색 필터 위젯 연동용)
|
||||||
|
let tableOptionsContext: ReturnType<typeof useTableOptions> | null = null;
|
||||||
|
try {
|
||||||
|
tableOptionsContext = useTableOptions();
|
||||||
|
} catch (e) {
|
||||||
|
// Context가 없으면 (디자이너 모드) 무시
|
||||||
|
}
|
||||||
|
|
||||||
// 테이블 데이터 상태 관리
|
// 테이블 데이터 상태 관리
|
||||||
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
||||||
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
|
||||||
|
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
|
||||||
|
|
||||||
|
// 필터 상태 변경 래퍼 (로깅용)
|
||||||
|
const setFilters = useCallback((newFilters: TableFilter[]) => {
|
||||||
|
console.log("🎴 [CardDisplay] setFilters 호출됨:", {
|
||||||
|
componentId: component.id,
|
||||||
|
filtersCount: newFilters.length,
|
||||||
|
filters: newFilters,
|
||||||
|
});
|
||||||
|
setFiltersInternal(newFilters);
|
||||||
|
}, [component.id]);
|
||||||
|
|
||||||
|
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
|
||||||
|
const [columnMeta, setColumnMeta] = useState<
|
||||||
|
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
||||||
|
>({});
|
||||||
|
const [categoryMappings, setCategoryMappings] = useState<
|
||||||
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 상세보기 모달 상태
|
// 상세보기 모달 상태
|
||||||
const [viewModalOpen, setViewModalOpen] = useState(false);
|
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||||
|
|
@ -108,44 +152,78 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
||||||
|
|
||||||
if (!tableNameToUse) {
|
if (!tableNameToUse) {
|
||||||
// console.log("📋 CardDisplay: 테이블명이 설정되지 않음", {
|
|
||||||
// tableName,
|
|
||||||
// componentTableName: component.componentConfig?.tableName,
|
|
||||||
// });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("📋 CardDisplay: 사용할 테이블명", {
|
|
||||||
// tableName,
|
|
||||||
// componentTableName: component.componentConfig?.tableName,
|
|
||||||
// finalTableName: tableNameToUse,
|
|
||||||
// });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// console.log(`📋 CardDisplay: ${tableNameToUse} 테이블 데이터 로딩 시작`);
|
|
||||||
|
|
||||||
// 테이블 데이터와 컬럼 정보를 병렬로 로드
|
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
|
||||||
const [dataResponse, columnsResponse] = await Promise.all([
|
const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([
|
||||||
tableTypeApi.getTableData(tableNameToUse, {
|
tableTypeApi.getTableData(tableNameToUse, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 50, // 카드 표시용으로 적당한 개수
|
size: 50, // 카드 표시용으로 적당한 개수
|
||||||
}),
|
}),
|
||||||
tableTypeApi.getColumns(tableNameToUse),
|
tableTypeApi.getColumns(tableNameToUse),
|
||||||
|
tableTypeApi.getColumnInputTypes(tableNameToUse),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// console.log(`📋 CardDisplay: ${tableNameToUse} 데이터 로딩 완료`, {
|
|
||||||
// total: dataResponse.total,
|
|
||||||
// dataLength: dataResponse.data.length,
|
|
||||||
// columnsLength: columnsResponse.length,
|
|
||||||
// sampleData: dataResponse.data.slice(0, 2),
|
|
||||||
// sampleColumns: columnsResponse.slice(0, 3),
|
|
||||||
// });
|
|
||||||
|
|
||||||
setLoadedTableData(dataResponse.data);
|
setLoadedTableData(dataResponse.data);
|
||||||
setLoadedTableColumns(columnsResponse);
|
setLoadedTableColumns(columnsResponse);
|
||||||
|
|
||||||
|
// 컬럼 메타 정보 설정 (inputType 포함)
|
||||||
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||||
|
inputTypesResponse.forEach((item: any) => {
|
||||||
|
meta[item.columnName || item.column_name] = {
|
||||||
|
webType: item.webType || item.web_type,
|
||||||
|
inputType: item.inputType || item.input_type,
|
||||||
|
codeCategory: item.codeCategory || item.code_category,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta);
|
||||||
|
setColumnMeta(meta);
|
||||||
|
|
||||||
|
// 카테고리 타입 컬럼 찾기 및 매핑 로드
|
||||||
|
const categoryColumns = Object.entries(meta)
|
||||||
|
.filter(([_, m]) => m.inputType === "category")
|
||||||
|
.map(([columnName]) => columnName);
|
||||||
|
|
||||||
|
console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns);
|
||||||
|
|
||||||
|
if (categoryColumns.length > 0) {
|
||||||
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
|
|
||||||
|
for (const columnName of categoryColumns) {
|
||||||
|
try {
|
||||||
|
console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`);
|
||||||
|
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
||||||
|
|
||||||
|
console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
response.data.data.forEach((item: any) => {
|
||||||
|
// API 응답 형식: valueCode, valueLabel (camelCase)
|
||||||
|
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
||||||
|
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
||||||
|
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
|
||||||
|
const rawColor = item.color ?? item.badge_color;
|
||||||
|
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
||||||
|
mapping[code] = { label, color };
|
||||||
|
console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`);
|
||||||
|
});
|
||||||
|
mappings[columnName] = mapping;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings);
|
||||||
|
setCategoryMappings(mappings);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ CardDisplay: ${tableNameToUse} 데이터 로딩 실패`, error);
|
console.error(`❌ CardDisplay: 데이터 로딩 실패`, error);
|
||||||
setLoadedTableData([]);
|
setLoadedTableData([]);
|
||||||
setLoadedTableColumns([]);
|
setLoadedTableColumns([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -196,38 +274,324 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
|
|
||||||
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
||||||
const displayData = useMemo(() => {
|
const displayData = useMemo(() => {
|
||||||
// console.log("📋 CardDisplay: displayData 결정 중", {
|
|
||||||
// dataSource: componentConfig.dataSource,
|
|
||||||
// loadedTableDataLength: loadedTableData.length,
|
|
||||||
// tableDataLength: tableData.length,
|
|
||||||
// staticDataLength: componentConfig.staticData?.length || 0,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
||||||
if (loadedTableData.length > 0) {
|
if (loadedTableData.length > 0) {
|
||||||
// console.log("📋 CardDisplay: 로드된 테이블 데이터 사용", loadedTableData.slice(0, 2));
|
|
||||||
return loadedTableData;
|
return loadedTableData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// props로 전달받은 테이블 데이터가 있으면 사용
|
// props로 전달받은 테이블 데이터가 있으면 사용
|
||||||
if (tableData.length > 0) {
|
if (tableData.length > 0) {
|
||||||
// console.log("📋 CardDisplay: props 테이블 데이터 사용", tableData.slice(0, 2));
|
|
||||||
return tableData;
|
return tableData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (componentConfig.staticData && componentConfig.staticData.length > 0) {
|
if (componentConfig.staticData && componentConfig.staticData.length > 0) {
|
||||||
// console.log("📋 CardDisplay: 정적 데이터 사용", componentConfig.staticData.slice(0, 2));
|
|
||||||
return componentConfig.staticData;
|
return componentConfig.staticData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터가 없으면 빈 배열 반환
|
// 데이터가 없으면 빈 배열 반환
|
||||||
// console.log("📋 CardDisplay: 표시할 데이터가 없음");
|
|
||||||
return [];
|
return [];
|
||||||
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
|
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
|
||||||
|
|
||||||
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
|
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
|
||||||
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
|
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) => {
|
||||||
|
// 단일 선택: 새로운 Set 생성 (기존 선택 초기화)
|
||||||
|
const newSelectedRows = new Set<string>();
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 선택 시 해당 카드만 선택
|
||||||
|
newSelectedRows.add(cardKey);
|
||||||
|
}
|
||||||
|
// checked가 false면 빈 Set (선택 해제)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
splitPanelContext.setSelectedLeftData(null);
|
||||||
|
console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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]);
|
||||||
|
|
||||||
|
// TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용)
|
||||||
|
const tableId = `card-display-${component.id}`;
|
||||||
|
const tableNameToUse = tableName || component.componentConfig?.tableName || '';
|
||||||
|
const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이";
|
||||||
|
|
||||||
|
// ref로 최신 데이터 참조 (useCallback 의존성 문제 해결)
|
||||||
|
const loadedTableDataRef = useRef(loadedTableData);
|
||||||
|
const categoryMappingsRef = useRef(categoryMappings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadedTableDataRef.current = loadedTableData;
|
||||||
|
}, [loadedTableData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
categoryMappingsRef.current = categoryMappings;
|
||||||
|
}, [categoryMappings]);
|
||||||
|
|
||||||
|
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
|
||||||
|
// 초기 로드 여부 추적
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableNameToUse || isDesignMode) return;
|
||||||
|
|
||||||
|
// 초기 로드는 별도 useEffect에서 처리하므로 스킵
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFilteredData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 필터 값을 검색 파라미터로 변환
|
||||||
|
const searchParams: Record<string, any> = {};
|
||||||
|
filters.forEach(filter => {
|
||||||
|
if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
|
||||||
|
searchParams[filter.columnName] = filter.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", {
|
||||||
|
tableName: tableNameToUse,
|
||||||
|
filtersCount: filters.length,
|
||||||
|
searchParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
|
||||||
|
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
search: searchParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoadedTableData(dataResponse.data);
|
||||||
|
|
||||||
|
// 데이터 건수 업데이트
|
||||||
|
if (tableOptionsContext) {
|
||||||
|
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [CardDisplay] 필터 적용 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
|
||||||
|
loadFilteredData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filters, tableNameToUse, isDesignMode, tableId]);
|
||||||
|
|
||||||
|
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
|
||||||
|
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
|
||||||
|
if (!tableNameToUse) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 현재 로드된 데이터에서 고유 값 추출
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
loadedTableDataRef.current.forEach(row => {
|
||||||
|
const value = row[columnName];
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
uniqueValues.add(String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 매핑이 있으면 라벨 적용
|
||||||
|
const mapping = categoryMappingsRef.current[columnName];
|
||||||
|
return Array.from(uniqueValues).map(value => ({
|
||||||
|
value,
|
||||||
|
label: mapping?.[value]?.label || value,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [tableNameToUse]);
|
||||||
|
|
||||||
|
// TableOptionsContext에 등록
|
||||||
|
// registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화)
|
||||||
|
const registerTableRef = useRef(tableOptionsContext?.registerTable);
|
||||||
|
const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable);
|
||||||
|
|
||||||
|
// setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용)
|
||||||
|
const setFiltersRef = useRef(setFiltersInternal);
|
||||||
|
const getColumnUniqueValuesRef = useRef(getColumnUniqueValues);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerTableRef.current = tableOptionsContext?.registerTable;
|
||||||
|
unregisterTableRef.current = tableOptionsContext?.unregisterTable;
|
||||||
|
}, [tableOptionsContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFiltersRef.current = setFiltersInternal;
|
||||||
|
}, [setFiltersInternal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getColumnUniqueValuesRef.current = getColumnUniqueValues;
|
||||||
|
}, [getColumnUniqueValues]);
|
||||||
|
|
||||||
|
// 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록)
|
||||||
|
const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!registerTableRef.current || !unregisterTableRef.current) return;
|
||||||
|
if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return;
|
||||||
|
|
||||||
|
// 컬럼 정보를 TableColumn 형식으로 변환
|
||||||
|
const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||||
|
inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text',
|
||||||
|
visible: true,
|
||||||
|
width: 200,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
|
||||||
|
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
|
||||||
|
console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", {
|
||||||
|
tableId,
|
||||||
|
filtersCount: newFilters.length,
|
||||||
|
});
|
||||||
|
setFiltersRef.current(newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnUniqueValuesWrapper = async (columnName: string) => {
|
||||||
|
return getColumnUniqueValuesRef.current(columnName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registration = {
|
||||||
|
tableId,
|
||||||
|
label: tableLabel,
|
||||||
|
tableName: tableNameToUse,
|
||||||
|
columns,
|
||||||
|
dataCount: loadedTableData.length,
|
||||||
|
onFilterChange: onFilterChangeWrapper,
|
||||||
|
onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원
|
||||||
|
onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원
|
||||||
|
getColumnUniqueValues: getColumnUniqueValuesWrapper,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📋 [CardDisplay] TableOptionsContext에 등록:", {
|
||||||
|
tableId,
|
||||||
|
tableName: tableNameToUse,
|
||||||
|
columnsCount: columns.length,
|
||||||
|
dataCount: loadedTableData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerTableRef.current(registration);
|
||||||
|
|
||||||
|
const unregister = unregisterTableRef.current;
|
||||||
|
const currentTableId = tableId;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId);
|
||||||
|
unregister(currentTableId);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
isDesignMode,
|
||||||
|
tableId,
|
||||||
|
tableNameToUse,
|
||||||
|
tableLabel,
|
||||||
|
columnsKey, // 컬럼 변경 시에만 재등록
|
||||||
|
]);
|
||||||
|
|
||||||
// 로딩 중인 경우 로딩 표시
|
// 로딩 중인 경우 로딩 표시
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -261,26 +625,19 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카드 스타일 - 통일된 디자인 시스템 적용
|
// 카드 스타일 - 컴팩트한 디자인
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
border: "2px solid #e5e7eb", // 더 명확한 테두리
|
border: "1px solid #e5e7eb",
|
||||||
borderRadius: "12px", // 통일된 라운드 처리
|
borderRadius: "8px",
|
||||||
padding: "24px", // 더 여유로운 패딩
|
padding: "16px",
|
||||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
||||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션
|
transition: "all 0.2s ease",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
minHeight: "240px", // 최소 높이 더 증가
|
|
||||||
cursor: isDesignMode ? "pointer" : "default",
|
cursor: isDesignMode ? "pointer" : "default",
|
||||||
// 호버 효과를 위한 추가 스타일
|
|
||||||
"&:hover": {
|
|
||||||
transform: "translateY(-2px)",
|
|
||||||
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
|
||||||
borderColor: "#f59e0b", // 호버 시 오렌지 테두리
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 텍스트 자르기 함수
|
// 텍스트 자르기 함수
|
||||||
|
|
@ -290,17 +647,80 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
return text.substring(0, maxLength) + "...";
|
return text.substring(0, maxLength) + "...";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 매핑에서 값 가져오기
|
// 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환)
|
||||||
const getColumnValue = (data: any, columnName?: string) => {
|
const getColumnValueAsString = (data: any, columnName?: string): string => {
|
||||||
if (!columnName) return "";
|
if (!columnName) return "";
|
||||||
return data[columnName] || "";
|
const value = data[columnName];
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
|
||||||
|
// 카테고리 타입인 경우 매핑된 라벨 반환
|
||||||
|
const meta = columnMeta[columnName];
|
||||||
|
if (meta?.inputType === "category") {
|
||||||
|
const mapping = categoryMappings[columnName];
|
||||||
|
const valueStr = String(value);
|
||||||
|
const categoryData = mapping?.[valueStr];
|
||||||
|
return categoryData?.label || valueStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시)
|
||||||
|
const getColumnValue = (data: any, columnName?: string): React.ReactNode => {
|
||||||
|
if (!columnName) return "";
|
||||||
|
const value = data[columnName];
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
|
||||||
|
// 카테고리 타입인 경우 매핑된 라벨과 배지로 표시
|
||||||
|
const meta = columnMeta[columnName];
|
||||||
|
if (meta?.inputType === "category") {
|
||||||
|
const mapping = categoryMappings[columnName];
|
||||||
|
const valueStr = String(value);
|
||||||
|
const categoryData = mapping?.[valueStr];
|
||||||
|
const displayLabel = categoryData?.label || valueStr;
|
||||||
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
|
// 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음)
|
||||||
|
if (!displayColor || displayColor === "none") {
|
||||||
|
return displayLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: displayColor,
|
||||||
|
borderColor: displayColor,
|
||||||
|
}}
|
||||||
|
className="text-white text-xs"
|
||||||
|
>
|
||||||
|
{displayLabel}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
||||||
const getColumnLabel = (columnName: string) => {
|
const getColumnLabel = (columnName: string) => {
|
||||||
if (!actualTableColumns || actualTableColumns.length === 0) return columnName;
|
if (!actualTableColumns || actualTableColumns.length === 0) {
|
||||||
const column = actualTableColumns.find((col) => col.columnName === columnName);
|
// 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환
|
||||||
return column?.columnLabel || columnName;
|
return formatColumnName(columnName);
|
||||||
|
}
|
||||||
|
const column = actualTableColumns.find(
|
||||||
|
(col) => col.columnName === columnName || col.column_name === columnName
|
||||||
|
);
|
||||||
|
// 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨)
|
||||||
|
const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label;
|
||||||
|
return label || formatColumnName(columnName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분)
|
||||||
|
const formatColumnName = (columnName: string) => {
|
||||||
|
// 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화
|
||||||
|
return columnName
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
||||||
|
|
@ -327,12 +747,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
onClick?.();
|
onClick?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardClick = (data: any) => {
|
|
||||||
if (componentConfig.onCardClick) {
|
|
||||||
componentConfig.onCardClick(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용)
|
// DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용)
|
||||||
const safeDomProps = filterDOMProps(props);
|
const safeDomProps = filterDOMProps(props);
|
||||||
|
|
||||||
|
|
@ -405,99 +819,145 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayData.map((data, index) => {
|
displayData.map((data, index) => {
|
||||||
// 타이틀, 서브타이틀, 설명 값 결정 (원래 카드 레이아웃과 동일한 로직)
|
// 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시)
|
||||||
const titleValue =
|
const titleValue =
|
||||||
getColumnValue(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
|
getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
|
||||||
|
|
||||||
const subtitleValue =
|
const subtitleValue =
|
||||||
getColumnValue(data, componentConfig.columnMapping?.subtitleColumn) ||
|
getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) ||
|
||||||
getAutoFallbackValue(data, "subtitle");
|
getAutoFallbackValue(data, "subtitle");
|
||||||
|
|
||||||
const descriptionValue =
|
const descriptionValue =
|
||||||
getColumnValue(data, componentConfig.columnMapping?.descriptionColumn) ||
|
getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) ||
|
||||||
getAutoFallbackValue(data, "description");
|
getAutoFallbackValue(data, "description");
|
||||||
|
|
||||||
const imageValue = componentConfig.columnMapping?.imageColumn
|
// 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시
|
||||||
? getColumnValue(data, componentConfig.columnMapping.imageColumn)
|
const imageColumn = componentConfig.columnMapping?.imageColumn ||
|
||||||
: data.avatar || data.image || "";
|
Object.keys(data).find(key => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
return lowerKey.includes('image') || lowerKey.includes('photo') ||
|
||||||
|
lowerKey.includes('avatar') || lowerKey.includes('thumbnail') ||
|
||||||
|
lowerKey.includes('picture') || lowerKey.includes('img');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이미지 값 가져오기 (직접 접근 + 폴백)
|
||||||
|
const imageValue = imageColumn
|
||||||
|
? data[imageColumn]
|
||||||
|
: (data.image_path || data.imagePath || data.avatar || data.image || data.photo || "");
|
||||||
|
|
||||||
|
// 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우
|
||||||
|
const shouldShowImage = componentConfig.cardStyle?.showImage !== false;
|
||||||
|
|
||||||
|
// 이미지 URL 생성 (TableListComponent와 동일한 로직 사용)
|
||||||
|
const imageUrl = imageValue ? getFullImageUrl(imageValue) : "";
|
||||||
|
|
||||||
|
const cardKey = getCardKey(data, index);
|
||||||
|
const isCardSelected = selectedRows.has(cardKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={data.id || index}
|
key={cardKey}
|
||||||
style={cardStyle}
|
style={{
|
||||||
className="card-hover group cursor-pointer"
|
...cardStyle,
|
||||||
onClick={() => handleCardClick(data)}
|
borderColor: isCardSelected ? "#000" : "#e5e7eb",
|
||||||
|
borderWidth: isCardSelected ? "2px" : "1px",
|
||||||
|
boxShadow: isCardSelected
|
||||||
|
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
|
||||||
|
: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
||||||
|
flexDirection: "row", // 가로 배치
|
||||||
|
}}
|
||||||
|
className="card-hover group cursor-pointer transition-all duration-150"
|
||||||
|
onClick={() => handleCardClick(data, index)}
|
||||||
>
|
>
|
||||||
{/* 카드 이미지 - 통일된 디자인 */}
|
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
|
||||||
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
{shouldShowImage && (
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="flex-shrink-0 flex items-center justify-center mr-4">
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-primary/10 to-primary/20 shadow-sm border-2 border-background">
|
{imageUrl ? (
|
||||||
<span className="text-2xl text-primary">👤</span>
|
<img
|
||||||
</div>
|
src={imageUrl}
|
||||||
|
alt={titleValue || "이미지"}
|
||||||
|
className="h-16 w-16 rounded-lg object-cover border border-gray-200"
|
||||||
|
onError={(e) => {
|
||||||
|
// 이미지 로드 실패 시 기본 아이콘으로 대체
|
||||||
|
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<span className="text-2xl text-primary">👤</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 카드 타이틀 - 통일된 디자인 */}
|
{/* 우측 컨텐츠 영역 */}
|
||||||
{componentConfig.cardStyle?.showTitle && (
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<div className="mb-3">
|
{/* 타이틀 + 서브타이틀 */}
|
||||||
<h3 className="text-xl font-bold text-foreground leading-tight">{titleValue}</h3>
|
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
|
||||||
</div>
|
<div className="mb-1 flex items-center gap-2 flex-wrap">
|
||||||
)}
|
{componentConfig.cardStyle?.showTitle && (
|
||||||
|
<h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
|
||||||
{/* 카드 서브타이틀 - 통일된 디자인 */}
|
)}
|
||||||
{componentConfig.cardStyle?.showSubtitle && (
|
{componentConfig.cardStyle?.showSubtitle && subtitleValue && (
|
||||||
<div className="mb-3">
|
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
|
||||||
<p className="text-sm font-semibold text-primary bg-primary/10 px-3 py-1 rounded-full inline-block">{subtitleValue}</p>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 카드 설명 - 통일된 디자인 */}
|
|
||||||
{componentConfig.cardStyle?.showDescription && (
|
|
||||||
<div className="mb-4 flex-1">
|
|
||||||
<p className="text-sm leading-relaxed text-foreground bg-muted p-3 rounded-lg">
|
|
||||||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 추가 표시 컬럼들 - 통일된 디자인 */}
|
|
||||||
{componentConfig.columnMapping?.displayColumns &&
|
|
||||||
componentConfig.columnMapping.displayColumns.length > 0 && (
|
|
||||||
<div className="space-y-2 border-t border-border pt-4">
|
|
||||||
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
|
||||||
const value = getColumnValue(data, columnName);
|
|
||||||
if (!value) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={idx} className="flex justify-between items-center text-sm bg-background/50 px-3 py-2 rounded-lg border border-border">
|
|
||||||
<span className="text-muted-foreground font-medium capitalize">{getColumnLabel(columnName)}:</span>
|
|
||||||
<span className="font-semibold text-foreground bg-muted px-2 py-1 rounded-md text-xs">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 카드 액션 (선택사항) */}
|
{/* 추가 표시 컬럼들 - 가로 배치 */}
|
||||||
<div className="mt-3 flex justify-end space-x-2">
|
{componentConfig.columnMapping?.displayColumns &&
|
||||||
<button
|
componentConfig.columnMapping.displayColumns.length > 0 && (
|
||||||
className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
onClick={(e) => {
|
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
||||||
e.stopPropagation();
|
const value = getColumnValue(data, columnName);
|
||||||
handleCardView(data);
|
if (!value) return null;
|
||||||
}}
|
|
||||||
>
|
return (
|
||||||
상세보기
|
<div key={idx} className="flex items-center gap-1">
|
||||||
</button>
|
<span>{getColumnLabel(columnName)}:</span>
|
||||||
<button
|
<span className="font-medium text-foreground">{value}</span>
|
||||||
className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
</div>
|
||||||
onClick={(e) => {
|
);
|
||||||
e.stopPropagation();
|
})}
|
||||||
handleCardEdit(data);
|
</div>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
편집
|
{/* 카드 설명 */}
|
||||||
</button>
|
{componentConfig.cardStyle?.showDescription && descriptionValue && (
|
||||||
|
<div className="mt-1 flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 액션 - 설정에 따라 표시 */}
|
||||||
|
{(componentConfig.cardStyle?.showActions ?? true) && (
|
||||||
|
<div className="mt-2 flex justify-end space-x-2">
|
||||||
|
{(componentConfig.cardStyle?.showViewButton ?? true) && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCardView(data);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
상세보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(componentConfig.cardStyle?.showEditButton ?? true) && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCardEdit(data);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -521,16 +981,48 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{Object.entries(selectedData)
|
{Object.entries(selectedData)
|
||||||
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
||||||
.map(([key, value]) => (
|
.map(([key, value]) => {
|
||||||
<div key={key} className="bg-muted rounded-lg p-3">
|
// 카테고리 타입인 경우 배지로 표시
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
const meta = columnMeta[key];
|
||||||
{key.replace(/_/g, ' ')}
|
let displayValue: React.ReactNode = String(value);
|
||||||
|
|
||||||
|
if (meta?.inputType === "category") {
|
||||||
|
const mapping = categoryMappings[key];
|
||||||
|
const valueStr = String(value);
|
||||||
|
const categoryData = mapping?.[valueStr];
|
||||||
|
const displayLabel = categoryData?.label || valueStr;
|
||||||
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
|
// 색상이 있고 "none"이 아닌 경우에만 배지로 표시
|
||||||
|
if (displayColor && displayColor !== "none") {
|
||||||
|
displayValue = (
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: displayColor,
|
||||||
|
borderColor: displayColor,
|
||||||
|
}}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{displayLabel}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 배지 없음: 일반 텍스트로 표시
|
||||||
|
displayValue = displayLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="bg-muted rounded-lg p-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||||
|
{getColumnLabel(key)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground break-words">
|
);
|
||||||
{String(value)}
|
})
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,37 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
액션 버튼 표시
|
액션 버튼 표시
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 개별 버튼 설정 (액션 버튼이 활성화된 경우에만 표시) */}
|
||||||
|
{(config.cardStyle?.showActions ?? true) && (
|
||||||
|
<div className="ml-4 space-y-2 border-l-2 border-gray-200 pl-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="showViewButton"
|
||||||
|
checked={config.cardStyle?.showViewButton ?? true}
|
||||||
|
onChange={(e) => handleNestedChange("cardStyle.showViewButton", e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="showViewButton" className="text-xs text-gray-600">
|
||||||
|
상세보기 버튼
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="showEditButton"
|
||||||
|
checked={config.cardStyle?.showEditButton ?? true}
|
||||||
|
onChange={(e) => handleNestedChange("cardStyle.showEditButton", e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="showEditButton" className="text-xs text-gray-600">
|
||||||
|
편집 버튼
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ export interface CardStyleConfig {
|
||||||
maxDescriptionLength?: number;
|
maxDescriptionLength?: number;
|
||||||
imagePosition?: "top" | "left" | "right";
|
imagePosition?: "top" | "left" | "right";
|
||||||
imageSize?: "small" | "medium" | "large";
|
imageSize?: "small" | "medium" | "large";
|
||||||
showActions?: boolean; // 액션 버튼 표시 여부
|
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
||||||
|
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
||||||
|
showEditButton?: boolean; // 편집 버튼 표시 여부
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,63 @@
|
||||||
# RepeatScreenModal 컴포넌트 v3
|
# RepeatScreenModal 컴포넌트 v3.1
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
||||||
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
|
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
|
||||||
|
|
||||||
## v3 주요 변경사항
|
## v3.1 주요 변경사항 (2025-11-28)
|
||||||
|
|
||||||
|
### 1. 외부 테이블 데이터 소스
|
||||||
|
|
||||||
|
테이블 행에서 **외부 테이블의 데이터를 조회**하여 표시할 수 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
예시: 수주 관리에서 출하 계획 이력 조회
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 카드: 품목 A │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [행 1] 헤더: 품목코드, 품목명 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [행 2] 테이블: shipment_plan 테이블에서 조회 │
|
||||||
|
│ → sales_order_id로 조인하여 출하 계획 이력 표시 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 테이블 행 CRUD
|
||||||
|
|
||||||
|
테이블 행에서 **행 추가/수정/삭제** 기능을 지원합니다.
|
||||||
|
|
||||||
|
- **추가**: 새 행 추가 버튼으로 빈 행 생성
|
||||||
|
- **수정**: 편집 가능한 컬럼 직접 수정
|
||||||
|
- **삭제**: 행 삭제 (확인 팝업 옵션)
|
||||||
|
|
||||||
|
### 3. Footer 버튼 영역
|
||||||
|
|
||||||
|
모달 하단에 **커스터마이징 가능한 버튼 영역**을 제공합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 카드 내용... │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [초기화] [취소] [저장] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 집계 연산식 지원
|
||||||
|
|
||||||
|
집계 행에서 **컬럼 간 사칙연산**을 지원합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 예: 미출하 수량 = 수주수량 - 출하수량
|
||||||
|
{
|
||||||
|
sourceType: "formula",
|
||||||
|
formula: "{order_qty} - {ship_qty}",
|
||||||
|
label: "미출하 수량"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3 주요 변경사항 (기존)
|
||||||
|
|
||||||
### 자유 레이아웃 시스템
|
### 자유 레이아웃 시스템
|
||||||
|
|
||||||
|
|
@ -33,29 +86,7 @@
|
||||||
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
|
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
|
||||||
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
|
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
|
||||||
|
|
||||||
### 자유로운 조합
|
---
|
||||||
|
|
||||||
```
|
|
||||||
예시 1: 헤더 + 집계 + 테이블 (출하계획)
|
|
||||||
├── [행 1] 헤더: 품목코드, 품목명
|
|
||||||
├── [행 2] 집계: 총수주잔량, 현재고
|
|
||||||
└── [행 3] 테이블: 수주별 출하계획
|
|
||||||
|
|
||||||
예시 2: 집계만
|
|
||||||
└── [행 1] 집계: 총매출, 총비용, 순이익
|
|
||||||
|
|
||||||
예시 3: 테이블만
|
|
||||||
└── [행 1] 테이블: 품목 목록
|
|
||||||
|
|
||||||
예시 4: 테이블 2개
|
|
||||||
├── [행 1] 테이블: 입고 내역
|
|
||||||
└── [행 2] 테이블: 출고 내역
|
|
||||||
|
|
||||||
예시 5: 헤더 + 헤더 + 필드
|
|
||||||
├── [행 1] 헤더: 기본 정보 (읽기전용)
|
|
||||||
├── [행 2] 헤더: 상세 정보 (읽기전용)
|
|
||||||
└── [행 3] 필드: 입력 필드 (편집가능)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 설정 방법
|
## 설정 방법
|
||||||
|
|
||||||
|
|
@ -107,13 +138,34 @@
|
||||||
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
|
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
|
||||||
- **스타일**: 배경색, 폰트 크기
|
- **스타일**: 배경색, 폰트 크기
|
||||||
|
|
||||||
#### 테이블 행 설정
|
#### 테이블 행 설정 (v3.1 확장)
|
||||||
|
|
||||||
- **테이블 제목**: 선택사항
|
- **테이블 제목**: 선택사항
|
||||||
- **헤더 표시**: 테이블 헤더 표시 여부
|
- **헤더 표시**: 테이블 헤더 표시 여부
|
||||||
|
- **외부 테이블 데이터 소스**: (v3.1 신규)
|
||||||
|
- 소스 테이블: 조회할 외부 테이블
|
||||||
|
- 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키
|
||||||
|
- 정렬: 정렬 컬럼 및 방향
|
||||||
|
- **CRUD 설정**: (v3.1 신규)
|
||||||
|
- 추가: 새 행 추가 허용
|
||||||
|
- 수정: 행 수정 허용
|
||||||
|
- 삭제: 행 삭제 허용 (확인 팝업 옵션)
|
||||||
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
|
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
|
||||||
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
|
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
|
||||||
|
|
||||||
|
### 5. Footer 탭 (v3.1 신규)
|
||||||
|
|
||||||
|
- **Footer 사용**: Footer 영역 활성화
|
||||||
|
- **위치**: 컨텐츠 아래 / 하단 고정 (sticky)
|
||||||
|
- **정렬**: 왼쪽 / 가운데 / 오른쪽
|
||||||
|
- **버튼 설정**:
|
||||||
|
- 라벨: 버튼 텍스트
|
||||||
|
- 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀
|
||||||
|
- 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트
|
||||||
|
- 아이콘: 저장 / X / 초기화 / 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 데이터 흐름
|
## 데이터 흐름
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -125,16 +177,22 @@
|
||||||
↓
|
↓
|
||||||
4. 각 그룹에 대해 집계값 계산
|
4. 각 그룹에 대해 집계값 계산
|
||||||
↓
|
↓
|
||||||
5. 카드 렌더링 (contentRows 기반)
|
5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1)
|
||||||
↓
|
↓
|
||||||
6. 사용자 편집
|
6. 카드 렌더링 (contentRows 기반)
|
||||||
↓
|
↓
|
||||||
7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장
|
7. 사용자 편집 (CRUD 포함)
|
||||||
|
↓
|
||||||
|
8. Footer 버튼 또는 기본 저장 버튼으로 저장
|
||||||
|
↓
|
||||||
|
9. 기본 데이터 + 외부 테이블 데이터 일괄 저장
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
|
|
||||||
### 출하계획 등록
|
### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
|
|
@ -167,40 +225,185 @@
|
||||||
type: "aggregation",
|
type: "aggregation",
|
||||||
aggregationLayout: "horizontal",
|
aggregationLayout: "horizontal",
|
||||||
aggregationFields: [
|
aggregationFields: [
|
||||||
{ aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
|
{ sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
|
||||||
{ aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" }
|
{ sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "row-3",
|
id: "row-3",
|
||||||
type: "table",
|
type: "table",
|
||||||
tableTitle: "수주 목록",
|
tableTitle: "출하 계획 이력",
|
||||||
showTableHeader: true,
|
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: [
|
tableColumns: [
|
||||||
{ id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false },
|
{ id: "tc1", field: "plan_date", label: "계획일", type: "date", editable: true },
|
||||||
{ id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false },
|
{ id: "tc2", field: "plan_qty", label: "계획수량", type: "number", editable: true },
|
||||||
{ id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false },
|
{ id: "tc3", field: "status", label: "상태", type: "text", editable: false },
|
||||||
{
|
{ id: "tc4", field: "memo", label: "비고", type: "text", editable: true }
|
||||||
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` 설정도 계속 지원됩니다.
|
v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다.
|
||||||
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
|
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 주의사항
|
## 주의사항
|
||||||
|
|
||||||
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
|
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
|
||||||
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
|
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
|
||||||
3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.
|
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 분리
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -23,6 +23,9 @@ export interface RepeatScreenModalProps {
|
||||||
// === 🆕 v3: 자유 레이아웃 ===
|
// === 🆕 v3: 자유 레이아웃 ===
|
||||||
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
|
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
|
||||||
|
|
||||||
|
// === 🆕 v3.1: Footer 버튼 설정 ===
|
||||||
|
footerConfig?: FooterConfig; // Footer 영역 설정
|
||||||
|
|
||||||
// === (레거시 호환) ===
|
// === (레거시 호환) ===
|
||||||
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
|
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
|
||||||
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
|
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
|
||||||
|
|
@ -33,6 +36,34 @@ export interface RepeatScreenModalProps {
|
||||||
onChange?: (newData: any[]) => void;
|
onChange?: (newData: any[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.1: Footer 설정
|
||||||
|
*/
|
||||||
|
export interface FooterConfig {
|
||||||
|
enabled: boolean; // Footer 사용 여부
|
||||||
|
buttons?: FooterButtonConfig[]; // Footer 버튼들
|
||||||
|
position?: "sticky" | "static"; // sticky: 하단 고정, static: 컨텐츠 아래
|
||||||
|
alignment?: "left" | "center" | "right"; // 버튼 정렬
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.1: Footer 버튼 설정
|
||||||
|
*/
|
||||||
|
export interface FooterButtonConfig {
|
||||||
|
id: string; // 버튼 고유 ID
|
||||||
|
label: string; // 버튼 라벨
|
||||||
|
action: "save" | "cancel" | "close" | "reset" | "custom"; // 액션 타입
|
||||||
|
variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일
|
||||||
|
icon?: string; // 아이콘 (lucide 아이콘명)
|
||||||
|
disabled?: boolean; // 비활성화 여부
|
||||||
|
|
||||||
|
// custom 액션일 때
|
||||||
|
customAction?: {
|
||||||
|
type: string; // 커스텀 액션 타입
|
||||||
|
config?: Record<string, any>; // 커스텀 설정
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 소스 설정
|
* 데이터 소스 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -79,28 +110,206 @@ export interface CardContentRowConfig {
|
||||||
tableTitle?: string; // 테이블 제목
|
tableTitle?: string; // 테이블 제목
|
||||||
showTableHeader?: boolean; // 테이블 헤더 표시 여부
|
showTableHeader?: boolean; // 테이블 헤더 표시 여부
|
||||||
tableMaxHeight?: string; // 테이블 최대 높이
|
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: 집계 표시 설정
|
* 🆕 v3: 집계 표시 설정
|
||||||
*/
|
*/
|
||||||
export interface AggregationDisplayConfig {
|
export interface AggregationDisplayConfig {
|
||||||
aggregationResultField: string; // 그룹핑 설정의 resultField 참조
|
// 값 소스 타입
|
||||||
|
sourceType: "aggregation" | "formula" | "external" | "externalFormula";
|
||||||
|
|
||||||
|
// === sourceType: "aggregation" (기존 그룹핑 집계 결과 참조) ===
|
||||||
|
aggregationResultField?: string; // 그룹핑 설정의 resultField 참조
|
||||||
|
|
||||||
|
// === sourceType: "formula" (컬럼 간 연산) ===
|
||||||
|
formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}")
|
||||||
|
|
||||||
|
// === sourceType: "external" (외부 테이블 조회) ===
|
||||||
|
externalSource?: ExternalValueSource;
|
||||||
|
|
||||||
|
// === sourceType: "externalFormula" (외부 테이블 + 연산) ===
|
||||||
|
externalSources?: ExternalValueSource[]; // 여러 외부 소스
|
||||||
|
externalFormula?: string; // 외부 값들을 조합한 연산식 (예: "{inv_qty} + {prod_qty}")
|
||||||
|
|
||||||
|
// 표시 설정
|
||||||
label: string; // 표시 라벨
|
label: string; // 표시 라벨
|
||||||
icon?: string; // 아이콘 (lucide 아이콘명)
|
icon?: string; // 아이콘 (lucide 아이콘명)
|
||||||
backgroundColor?: string; // 배경색
|
backgroundColor?: string; // 배경색
|
||||||
textColor?: string; // 텍스트 색상
|
textColor?: string; // 텍스트 색상
|
||||||
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
|
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 {
|
export interface AggregationConfig {
|
||||||
sourceField: string; // 원본 필드 (예: "balance_qty")
|
// === 집계 소스 타입 ===
|
||||||
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
|
sourceType: "column" | "formula"; // column: 테이블 컬럼 집계, formula: 연산식 (가상 집계)
|
||||||
|
|
||||||
|
// === sourceType: "column" (테이블 컬럼 집계) ===
|
||||||
|
sourceTable?: string; // 집계할 테이블 (기본: dataSource.sourceTable, 외부 테이블도 가능)
|
||||||
|
sourceField?: string; // 원본 필드 (예: "balance_qty")
|
||||||
|
type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
|
||||||
|
|
||||||
|
// === sourceType: "formula" (가상 집계 - 연산식) ===
|
||||||
|
// 연산식 문법:
|
||||||
|
// - {resultField}: 다른 집계 결과 참조 (예: {total_balance})
|
||||||
|
// - {테이블.컬럼}: 테이블의 컬럼 직접 참조 (예: {sales_order_mng.order_qty})
|
||||||
|
// - SUM({컬럼}): 기본 테이블 행들의 합계
|
||||||
|
// - SUM_EXT({컬럼}): 외부 테이블 행들의 합계 (externalTableData)
|
||||||
|
// - 산술 연산: +, -, *, /, ()
|
||||||
|
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
|
||||||
|
|
||||||
|
// === 공통 ===
|
||||||
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||||
label: string; // 표시 라벨 (예: "총수주잔량")
|
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||||
|
|
||||||
|
// === 🆕 v3.9: 저장 설정 ===
|
||||||
|
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.9: 집계 결과 저장 설정
|
||||||
|
* 집계된 값을 다른 테이블에 동기화
|
||||||
|
*/
|
||||||
|
export interface AggregationSaveConfig {
|
||||||
|
enabled: boolean; // 저장 활성화 여부
|
||||||
|
autoSave: boolean; // 자동 저장 (레이아웃에 없어도 저장)
|
||||||
|
|
||||||
|
// 저장 대상
|
||||||
|
targetTable: string; // 저장할 테이블 (예: "sales_order_mng")
|
||||||
|
targetColumn: string; // 저장할 컬럼 (예: "plan_qty_total")
|
||||||
|
|
||||||
|
// 조인 키 (어떤 레코드를 업데이트할지)
|
||||||
|
joinKey: {
|
||||||
|
sourceField: string; // 현재 카드의 조인 키 (예: "id" 또는 "sales_order_id")
|
||||||
|
targetField: string; // 대상 테이블의 키 (예: "id")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -120,7 +329,7 @@ export interface TableLayoutConfig {
|
||||||
*/
|
*/
|
||||||
export interface TableColumnConfig {
|
export interface TableColumnConfig {
|
||||||
id: string; // 컬럼 고유 ID
|
id: string; // 컬럼 고유 ID
|
||||||
field: string; // 필드명
|
field: string; // 필드명 (소스 테이블 컬럼 또는 조인 테이블 컬럼)
|
||||||
label: string; // 헤더 라벨
|
label: string; // 헤더 라벨
|
||||||
type: "text" | "number" | "date" | "select" | "badge"; // 타입
|
type: "text" | "number" | "date" | "select" | "badge"; // 타입
|
||||||
width?: string; // 너비 (예: "100px", "20%")
|
width?: string; // 너비 (예: "100px", "20%")
|
||||||
|
|
@ -128,6 +337,10 @@ export interface TableColumnConfig {
|
||||||
editable: boolean; // 편집 가능 여부
|
editable: boolean; // 편집 가능 여부
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
|
|
||||||
|
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
||||||
|
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
||||||
|
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
||||||
|
|
||||||
// Select 타입 옵션
|
// Select 타입 옵션
|
||||||
selectOptions?: { value: string; label: string }[];
|
selectOptions?: { value: string; label: string }[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown, Plus, Trash2, Link2 } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ParentDataMapping, LinkedFilter } from "@/contexts/SplitPanelContext";
|
||||||
|
|
||||||
interface ScreenSplitPanelConfigPanelProps {
|
interface ScreenSplitPanelConfigPanelProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -29,6 +31,18 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
const [leftOpen, setLeftOpen] = useState(false);
|
const [leftOpen, setLeftOpen] = useState(false);
|
||||||
const [rightOpen, setRightOpen] = useState(false);
|
const [rightOpen, setRightOpen] = useState(false);
|
||||||
|
|
||||||
|
// 좌측 화면의 테이블 컬럼 목록
|
||||||
|
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 [localConfig, setLocalConfig] = useState({
|
const [localConfig, setLocalConfig] = useState({
|
||||||
screenId: config.screenId || 0,
|
screenId: config.screenId || 0,
|
||||||
leftScreenId: config.leftScreenId || 0,
|
leftScreenId: config.leftScreenId || 0,
|
||||||
|
|
@ -37,6 +51,8 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
resizable: config.resizable ?? true,
|
resizable: config.resizable ?? true,
|
||||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||||
buttonPosition: config.buttonPosition || "center",
|
buttonPosition: config.buttonPosition || "center",
|
||||||
|
parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[],
|
||||||
|
linkedFilters: config.linkedFilters || [] as LinkedFilter[],
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -51,10 +67,165 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
resizable: config.resizable ?? true,
|
resizable: config.resizable ?? true,
|
||||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||||
buttonPosition: config.buttonPosition || "center",
|
buttonPosition: config.buttonPosition || "center",
|
||||||
|
parentDataMapping: config.parentDataMapping || [],
|
||||||
|
linkedFilters: config.linkedFilters || [],
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
// 좌측 화면이 변경되면 해당 화면의 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLeftScreenColumns = async () => {
|
||||||
|
if (!localConfig.leftScreenId) {
|
||||||
|
setLeftScreenColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingLeftColumns(true);
|
||||||
|
|
||||||
|
// 좌측 화면 정보 조회
|
||||||
|
const screenData = await screenApi.getScreen(localConfig.leftScreenId);
|
||||||
|
if (!screenData?.tableName) {
|
||||||
|
console.warn("좌측 화면에 테이블이 설정되지 않았습니다.");
|
||||||
|
setLeftScreenColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 컬럼 조회
|
||||||
|
const columnsResponse = await getTableColumns(screenData.tableName);
|
||||||
|
if (columnsResponse.success && columnsResponse.data?.columns) {
|
||||||
|
const columns = columnsResponse.data.columns.map((col: any) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
|
||||||
|
}));
|
||||||
|
setLeftScreenColumns(columns);
|
||||||
|
console.log("📋 좌측 화면 컬럼 로드 완료:", columns.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("좌측 화면 컬럼 로드 실패:", error);
|
||||||
|
setLeftScreenColumns([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingLeftColumns(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(() => {
|
useEffect(() => {
|
||||||
const loadScreens = async () => {
|
const loadScreens = async () => {
|
||||||
|
|
@ -94,17 +265,77 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 부모 데이터 매핑 추가
|
||||||
|
const addParentDataMapping = () => {
|
||||||
|
const newMapping: ParentDataMapping = {
|
||||||
|
sourceColumn: "",
|
||||||
|
targetColumn: "",
|
||||||
|
};
|
||||||
|
const newMappings = [...(localConfig.parentDataMapping || []), newMapping];
|
||||||
|
updateConfig("parentDataMapping", newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 데이터 매핑 수정
|
||||||
|
const updateParentDataMapping = (index: number, field: keyof ParentDataMapping, value: string) => {
|
||||||
|
const newMappings = [...(localConfig.parentDataMapping || [])];
|
||||||
|
newMappings[index] = {
|
||||||
|
...newMappings[index],
|
||||||
|
[field]: value,
|
||||||
|
};
|
||||||
|
updateConfig("parentDataMapping", newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 데이터 매핑 삭제
|
||||||
|
const removeParentDataMapping = (index: number) => {
|
||||||
|
const newMappings = (localConfig.parentDataMapping || []).filter((_: any, i: number) => i !== index);
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Tabs defaultValue="layout" className="w-full">
|
<Tabs defaultValue="layout" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="layout" className="gap-2">
|
<TabsTrigger value="layout" className="gap-1 text-xs">
|
||||||
<Layout className="h-4 w-4" />
|
<Layout className="h-3 w-3" />
|
||||||
레이아웃
|
레이아웃
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="screens" className="gap-2">
|
<TabsTrigger value="screens" className="gap-1 text-xs">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-3 w-3" />
|
||||||
화면 설정
|
화면
|
||||||
|
</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>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -295,7 +526,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
|
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
<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 className="text-xs text-amber-800 dark:text-amber-200">
|
||||||
💡 <strong>데이터 전달 방법:</strong> 좌측 화면에 테이블과 버튼을 배치하고, 버튼의 액션을
|
데이터 전달 방법: 좌측 화면에 테이블과 버튼을 배치하고, 버튼의 액션을
|
||||||
"transferData"로 설정하세요.
|
"transferData"로 설정하세요.
|
||||||
<br />
|
<br />
|
||||||
버튼 설정에서 소스 컴포넌트(테이블), 타겟 화면, 필드 매핑을 지정할 수 있습니다.
|
버튼 설정에서 소스 컴포넌트(테이블), 타겟 화면, 필드 매핑을 지정할 수 있습니다.
|
||||||
|
|
@ -306,6 +537,290 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addParentDataMapping}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
매핑 추가
|
||||||
|
</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>
|
||||||
|
<br />
|
||||||
|
컬럼명이 다른 경우에만 위에서 매핑을 추가하세요.
|
||||||
|
<br />
|
||||||
|
예: 좌측 <code className="bg-blue-100 px-1 rounded">user_id</code> → 우측 <code className="bg-blue-100 px-1 rounded">created_by</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 설정 요약 */}
|
{/* 설정 요약 */}
|
||||||
|
|
@ -343,6 +858,14 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
||||||
<span className="text-muted-foreground">크기 조절:</span>
|
<span className="text-muted-foreground">크기 조절:</span>
|
||||||
<span className="font-medium">{localConfig.resizable ? "가능" : "불가능"}</span>
|
<span className="font-medium">{localConfig.resizable ? "가능" : "불가능"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">데이터 매핑:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{(localConfig.parentDataMapping || []).length > 0
|
||||||
|
? `${localConfig.parentDataMapping.length}개 설정`
|
||||||
|
: "미설정"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,4 @@ if (process.env.NODE_ENV === "development") {
|
||||||
SectionPaperRenderer.enableHotReload();
|
SectionPaperRenderer.enableHotReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -732,16 +732,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
) : allOptions.length > 0 ? (
|
) : allOptions.length > 0 ? (
|
||||||
allOptions.map((option, index) => {
|
allOptions.map((option, index) => {
|
||||||
const isSelected = selectedValues.includes(option.value);
|
const isOptionSelected = selectedValues.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${option.value}-${index}`}
|
key={`${option.value}-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||||
isSelected && "bg-blue-50 font-medium"
|
isOptionSelected && "bg-blue-50 font-medium"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newVals = isSelected
|
const newVals = isOptionSelected
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
: [...selectedValues, option.value];
|
: [...selectedValues, option.value];
|
||||||
setSelectedValues(newVals);
|
setSelectedValues(newVals);
|
||||||
|
|
@ -754,9 +754,21 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isOptionSelected}
|
||||||
onChange={() => {}}
|
value={option.value}
|
||||||
className="h-4 w-4"
|
onChange={(e) => {
|
||||||
|
// 체크박스 직접 클릭 시에도 올바른 값으로 처리
|
||||||
|
e.stopPropagation();
|
||||||
|
const newVals = isOptionSelected
|
||||||
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
|
: [...selectedValues, option.value];
|
||||||
|
setSelectedValues(newVals);
|
||||||
|
const newValue = newVals.join(",");
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 pointer-events-auto"
|
||||||
/>
|
/>
|
||||||
<span>{option.label || option.value}</span>
|
<span>{option.label || option.value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,4 @@ export function useCalculation(calculationRules: CalculationRule[] = []) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1075,16 +1075,113 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const sortBy = sortColumn || undefined;
|
const sortBy = sortColumn || undefined;
|
||||||
const sortOrder = sortDirection;
|
const sortOrder = sortDirection;
|
||||||
const search = searchTerm || undefined;
|
const search = searchTerm || undefined;
|
||||||
const filters = Object.keys(searchValues).length > 0 ? searchValues : 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 entityJoinColumns = (tableConfig.columns || [])
|
// 🆕 REST API 데이터 소스 처리
|
||||||
.filter((col) => col.additionalJoinInfo)
|
const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_");
|
||||||
.map((col) => ({
|
|
||||||
sourceTable: col.additionalJoinInfo!.sourceTable,
|
let response: any;
|
||||||
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
|
||||||
joinAlias: col.additionalJoinInfo!.joinAlias,
|
if (isRestApiTable) {
|
||||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
// REST API 데이터 소스인 경우
|
||||||
}));
|
const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/);
|
||||||
|
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
|
||||||
|
|
||||||
|
if (connectionId) {
|
||||||
|
console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId });
|
||||||
|
|
||||||
|
// REST API 연결 정보 가져오기 및 데이터 조회
|
||||||
|
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
|
||||||
|
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||||
|
connectionId,
|
||||||
|
undefined, // endpoint - 연결 정보에서 가져옴
|
||||||
|
"response", // jsonPath - 기본값 response
|
||||||
|
);
|
||||||
|
|
||||||
|
response = {
|
||||||
|
data: restApiData.rows || [],
|
||||||
|
total: restApiData.total || restApiData.rows?.length || 0,
|
||||||
|
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("✅ [TableList] REST API 응답:", {
|
||||||
|
dataLength: response.data.length,
|
||||||
|
total: response.total
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 DB 테이블인 경우 (기존 로직)
|
||||||
|
const entityJoinColumns = (tableConfig.columns || [])
|
||||||
|
.filter((col) => col.additionalJoinInfo)
|
||||||
|
.map((col) => ({
|
||||||
|
sourceTable: col.additionalJoinInfo!.sourceTable,
|
||||||
|
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
||||||
|
joinAlias: col.additionalJoinInfo!.joinAlias,
|
||||||
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||||
|
}));
|
||||||
|
|
||||||
// console.log("🔍 [TableList] API 호출 시작", {
|
// console.log("🔍 [TableList] API 호출 시작", {
|
||||||
// tableName: tableConfig.selectedTable,
|
// tableName: tableConfig.selectedTable,
|
||||||
|
|
@ -1095,12 +1192,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||||
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
search: filters,
|
search: hasFilters ? filters : undefined,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||||
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||||
|
|
@ -1144,6 +1241,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
totalItems: response.total || 0,
|
totalItems: response.total || 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("데이터 가져오기 실패:", err);
|
console.error("데이터 가져오기 실패:", err);
|
||||||
setData([]);
|
setData([]);
|
||||||
|
|
@ -1164,6 +1262,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
searchValues,
|
searchValues,
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
|
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fetchTableDataDebounced = useCallback(
|
const fetchTableDataDebounced = useCallback(
|
||||||
|
|
@ -1466,6 +1565,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||||
|
|
||||||
|
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||||
|
if (splitPanelContext && splitPanelPosition === "left") {
|
||||||
|
if (!isCurrentlySelected) {
|
||||||
|
// 선택된 경우: 데이터 저장
|
||||||
|
splitPanelContext.setSelectedLeftData(row);
|
||||||
|
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
|
||||||
|
row,
|
||||||
|
parentDataMapping: splitPanelContext.parentDataMapping,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 선택 해제된 경우: 데이터 초기화
|
||||||
|
splitPanelContext.setSelectedLeftData(null);
|
||||||
|
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2111,6 +2226,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
refreshKey,
|
refreshKey,
|
||||||
refreshTrigger, // 강제 새로고침 트리거
|
refreshTrigger, // 강제 새로고침 트리거
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
|
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침
|
||||||
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
|
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
|
@ -41,6 +41,7 @@ interface TableSearchWidgetProps {
|
||||||
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
||||||
filterMode?: "dynamic" | "preset"; // 필터 모드
|
filterMode?: "dynamic" | "preset"; // 필터 모드
|
||||||
presetFilters?: PresetFilter[]; // 고정 필터 목록
|
presetFilters?: PresetFilter[]; // 고정 필터 목록
|
||||||
|
targetPanelPosition?: "left" | "right" | "auto"; // 분할 패널에서 대상 패널 위치 (기본: "left")
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
screenId?: number; // 화면 ID
|
screenId?: number; // 화면 ID
|
||||||
|
|
@ -82,19 +83,90 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||||
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
|
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
|
||||||
const presetFilters = component.componentConfig?.presetFilters ?? [];
|
const presetFilters = component.componentConfig?.presetFilters ?? [];
|
||||||
|
const targetPanelPosition = component.componentConfig?.targetPanelPosition ?? "left"; // 기본값: 좌측 패널
|
||||||
|
|
||||||
// Map을 배열로 변환
|
// Map을 배열로 변환
|
||||||
const tableList = Array.from(registeredTables.values());
|
const allTableList = Array.from(registeredTables.values());
|
||||||
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
|
|
||||||
|
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
|
||||||
// 첫 번째 테이블 자동 선택
|
const tableList = useMemo(() => {
|
||||||
useEffect(() => {
|
// "auto"면 모든 테이블 반환
|
||||||
const tables = Array.from(registeredTables.values());
|
if (targetPanelPosition === "auto") {
|
||||||
|
return allTableList;
|
||||||
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
|
|
||||||
setSelectedTableId(tables[0].tableId);
|
|
||||||
}
|
}
|
||||||
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
|
||||||
|
// 테이블 ID 패턴으로 필터링
|
||||||
|
// card-display-XXX: 좌측 패널 (카드 디스플레이)
|
||||||
|
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
|
||||||
|
const filteredTables = allTableList.filter(table => {
|
||||||
|
const tableId = table.tableId.toLowerCase();
|
||||||
|
|
||||||
|
if (targetPanelPosition === "left") {
|
||||||
|
// 좌측 패널 대상: card-display만
|
||||||
|
return tableId.includes("card-display") || tableId.includes("card");
|
||||||
|
} else if (targetPanelPosition === "right") {
|
||||||
|
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
|
||||||
|
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
|
||||||
|
return !isCardDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
|
||||||
|
if (filteredTables.length === 0) {
|
||||||
|
console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", {
|
||||||
|
targetPanelPosition,
|
||||||
|
allTablesCount: allTableList.length,
|
||||||
|
allTableIds: allTableList.map(t => t.tableId),
|
||||||
|
});
|
||||||
|
return allTableList;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔍 [TableSearchWidget] 테이블 필터링:", {
|
||||||
|
targetPanelPosition,
|
||||||
|
allTablesCount: allTableList.length,
|
||||||
|
filteredCount: filteredTables.length,
|
||||||
|
filteredTableIds: filteredTables.map(t => t.tableId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredTables;
|
||||||
|
}, [allTableList, targetPanelPosition]);
|
||||||
|
|
||||||
|
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
||||||
|
const currentTable = useMemo(() => {
|
||||||
|
if (!selectedTableId) return undefined;
|
||||||
|
|
||||||
|
// 먼저 tableList(필터링된 목록)에서 찾기
|
||||||
|
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
|
||||||
|
if (tableFromList) {
|
||||||
|
return tableFromList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableList에 없으면 전체에서 찾기 (폴백)
|
||||||
|
return getTable(selectedTableId);
|
||||||
|
}, [selectedTableId, tableList, getTable]);
|
||||||
|
|
||||||
|
// 대상 패널의 첫 번째 테이블 자동 선택
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectFirstTable || tableList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 선택된 테이블이 대상 패널에 있는지 확인
|
||||||
|
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
|
||||||
|
|
||||||
|
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
|
||||||
|
if (!selectedTableId || !isCurrentTableInTarget) {
|
||||||
|
const targetTable = tableList[0];
|
||||||
|
console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", {
|
||||||
|
targetPanelPosition,
|
||||||
|
selectedTableId: targetTable.tableId,
|
||||||
|
tableName: targetTable.tableName,
|
||||||
|
});
|
||||||
|
setSelectedTableId(targetTable.tableId);
|
||||||
|
}
|
||||||
|
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
|
||||||
|
|
||||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -302,6 +374,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("🔍 [TableSearchWidget] 필터 적용:", {
|
||||||
|
currentTableId: currentTable?.tableId,
|
||||||
|
currentTableName: currentTable?.tableName,
|
||||||
|
filtersCount: filtersWithValues.length,
|
||||||
|
filtersWithValues,
|
||||||
|
});
|
||||||
currentTable?.onFilterChange(filtersWithValues);
|
currentTable?.onFilterChange(filtersWithValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,16 @@ export function TableSearchWidgetConfigPanel({
|
||||||
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
|
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
|
||||||
currentConfig.presetFilters ?? []
|
currentConfig.presetFilters ?? []
|
||||||
);
|
);
|
||||||
|
const [localTargetPanelPosition, setLocalTargetPanelPosition] = useState<"left" | "right" | "auto">(
|
||||||
|
currentConfig.targetPanelPosition ?? "left"
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
|
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
|
||||||
setLocalShowSelector(currentConfig.showTableSelector ?? true);
|
setLocalShowSelector(currentConfig.showTableSelector ?? true);
|
||||||
setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
|
setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
|
||||||
setLocalPresetFilters(currentConfig.presetFilters ?? []);
|
setLocalPresetFilters(currentConfig.presetFilters ?? []);
|
||||||
|
setLocalTargetPanelPosition(currentConfig.targetPanelPosition ?? "left");
|
||||||
}, [currentConfig]);
|
}, [currentConfig]);
|
||||||
|
|
||||||
// 설정 업데이트 헬퍼
|
// 설정 업데이트 헬퍼
|
||||||
|
|
@ -164,6 +168,40 @@ export function TableSearchWidgetConfigPanel({
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 패널 위치 (분할 패널용) */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label className="text-xs sm:text-sm font-medium">대상 패널 위치 (분할 패널)</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-2">
|
||||||
|
분할 패널이 있는 화면에서 검색 필터가 어떤 패널의 컴포넌트를 대상으로 할지 선택합니다.
|
||||||
|
</p>
|
||||||
|
<RadioGroup
|
||||||
|
value={localTargetPanelPosition}
|
||||||
|
onValueChange={(value: "left" | "right" | "auto") => {
|
||||||
|
setLocalTargetPanelPosition(value);
|
||||||
|
handleUpdate("targetPanelPosition", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="left" id="target-left" />
|
||||||
|
<Label htmlFor="target-left" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||||
|
좌측 패널 (카드 디스플레이 등)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="right" id="target-right" />
|
||||||
|
<Label htmlFor="target-right" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||||
|
우측 패널 (테이블 리스트 등)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="auto" id="target-auto" />
|
||||||
|
<Label htmlFor="target-auto" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||||
|
자동 (모든 테이블 대상)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 필터 모드 선택 */}
|
{/* 필터 모드 선택 */}
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<Label className="text-xs sm:text-sm font-medium">필터 모드</Label>
|
<Label className="text-xs sm:text-sm font-medium">필터 모드</Label>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
|
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import type { ExtendedControlContext } from "@/types/control-management";
|
import type { ExtendedControlContext } from "@/types/control-management";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,7 +25,7 @@ export type ButtonActionType =
|
||||||
| "excel_upload" // 엑셀 업로드
|
| "excel_upload" // 엑셀 업로드
|
||||||
| "barcode_scan" // 바코드 스캔
|
| "barcode_scan" // 바코드 스캔
|
||||||
| "code_merge" // 코드 병합
|
| "code_merge" // 코드 병합
|
||||||
| "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경)
|
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
||||||
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
||||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||||
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
|
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||||
|
|
@ -163,6 +164,10 @@ export interface ButtonActionConfig {
|
||||||
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
|
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
|
||||||
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
|
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
|
||||||
|
|
||||||
|
// 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용)
|
||||||
|
emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true)
|
||||||
|
emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
|
||||||
|
|
||||||
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
|
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
|
||||||
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
||||||
editModalTitle?: string; // 편집 모달 제목
|
editModalTitle?: string; // 편집 모달 제목
|
||||||
|
|
@ -256,6 +261,9 @@ export interface ButtonActionContext {
|
||||||
|
|
||||||
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
||||||
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
|
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
|
||||||
|
|
||||||
|
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
||||||
|
splitPanelParentData?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -350,8 +358,8 @@ export class ButtonActionExecutor {
|
||||||
case "transferData":
|
case "transferData":
|
||||||
return await this.handleTransferData(config, context);
|
return await this.handleTransferData(config, context);
|
||||||
|
|
||||||
case "empty_vehicle":
|
// case "empty_vehicle":
|
||||||
return await this.handleEmptyVehicle(config, context);
|
// return await this.handleEmptyVehicle(config, context);
|
||||||
|
|
||||||
case "operation_control":
|
case "operation_control":
|
||||||
return await this.handleOperationControl(config, context);
|
return await this.handleOperationControl(config, context);
|
||||||
|
|
@ -556,8 +564,7 @@ export class ButtonActionExecutor {
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||||
// console.log("🔍 채번 규칙 할당 체크 시작");
|
console.log("🔍 채번 규칙 할당 체크 시작");
|
||||||
// console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
|
|
||||||
|
|
||||||
const fieldsWithNumbering: Record<string, string> = {};
|
const fieldsWithNumbering: Record<string, string> = {};
|
||||||
|
|
||||||
|
|
@ -566,26 +573,49 @@ export class ButtonActionExecutor {
|
||||||
if (key.endsWith("_numberingRuleId") && value) {
|
if (key.endsWith("_numberingRuleId") && value) {
|
||||||
const fieldName = key.replace("_numberingRuleId", "");
|
const fieldName = key.replace("_numberingRuleId", "");
|
||||||
fieldsWithNumbering[fieldName] = value as string;
|
fieldsWithNumbering[fieldName] = value as string;
|
||||||
// console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
|
console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
||||||
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
|
console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
|
||||||
|
|
||||||
// 사용자 입력 값 유지 (재할당하지 않음)
|
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
|
||||||
// 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로
|
|
||||||
// 저장 시점에는 사용자가 수정한 값을 그대로 사용
|
|
||||||
if (Object.keys(fieldsWithNumbering).length > 0) {
|
if (Object.keys(fieldsWithNumbering).length > 0) {
|
||||||
console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering));
|
console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)");
|
||||||
console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)");
|
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("✅ 채번 규칙 할당 완료");
|
console.log("✅ 채번 규칙 할당 완료");
|
||||||
// console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
|
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
|
||||||
|
|
||||||
|
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
|
||||||
|
const splitPanelData = context.splitPanelParentData || {};
|
||||||
|
if (Object.keys(splitPanelData).length > 0) {
|
||||||
|
console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData);
|
||||||
|
}
|
||||||
|
|
||||||
const dataWithUserInfo = {
|
const dataWithUserInfo = {
|
||||||
...formData,
|
...splitPanelData, // 분할 패널 부모 데이터 먼저 적용
|
||||||
|
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
|
||||||
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||||||
created_by: writerValue, // created_by는 항상 로그인한 사람
|
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||||||
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
||||||
|
|
@ -659,11 +689,122 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveResult = await DynamicFormApi.saveFormData({
|
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
||||||
screenId,
|
const repeatScreenModalKeys = Object.keys(context.formData).filter((key) =>
|
||||||
tableName,
|
key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations"
|
||||||
data: dataWithUserInfo,
|
);
|
||||||
});
|
|
||||||
|
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
||||||
|
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
||||||
|
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
|
||||||
|
|
||||||
|
if (shouldSkipMainSave) {
|
||||||
|
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
|
||||||
|
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
|
||||||
|
} else {
|
||||||
|
saveResult = await DynamicFormApi.saveFormData({
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
data: dataWithUserInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repeatScreenModalKeys.length > 0) {
|
||||||
|
console.log("📦 [handleSave] RepeatScreenModal 데이터 저장 시작:", repeatScreenModalKeys);
|
||||||
|
|
||||||
|
// 🆕 formData에서 채번 규칙으로 생성된 값들 추출 (예: shipment_plan_no)
|
||||||
|
const numberingFields: Record<string, any> = {};
|
||||||
|
for (const [fieldKey, value] of Object.entries(context.formData)) {
|
||||||
|
// _numberingRuleId로 끝나는 키가 있으면 해당 필드는 채번 규칙 값
|
||||||
|
if (context.formData[`${fieldKey}_numberingRuleId`]) {
|
||||||
|
numberingFields[fieldKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("📦 [handleSave] 채번 규칙 필드:", numberingFields);
|
||||||
|
|
||||||
|
for (const key of repeatScreenModalKeys) {
|
||||||
|
const targetTable = key.replace("_repeatScreenModal_", "");
|
||||||
|
const rows = context.formData[key] as any[];
|
||||||
|
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) continue;
|
||||||
|
|
||||||
|
console.log(`📦 [handleSave] ${targetTable} 테이블 저장:`, rows);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const { _isNew, _targetTable, id, ...dataToSave } = row;
|
||||||
|
|
||||||
|
// 사용자 정보 추가 + 채번 규칙 값 병합
|
||||||
|
const dataWithMeta = {
|
||||||
|
...dataToSave,
|
||||||
|
...numberingFields, // 채번 규칙 값 (shipment_plan_no 등)
|
||||||
|
created_by: context.userId,
|
||||||
|
updated_by: context.userId,
|
||||||
|
company_code: context.companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isNew) {
|
||||||
|
// INSERT
|
||||||
|
console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta);
|
||||||
|
const insertResult = await apiClient.post(
|
||||||
|
`/table-management/tables/${targetTable}/add`,
|
||||||
|
dataWithMeta
|
||||||
|
);
|
||||||
|
console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data);
|
||||||
|
} else if (id) {
|
||||||
|
// UPDATE
|
||||||
|
const originalData = { id };
|
||||||
|
const updatedData = { ...dataWithMeta, id };
|
||||||
|
console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData });
|
||||||
|
const updateResult = await apiClient.put(
|
||||||
|
`/table-management/tables/${targetTable}/edit`,
|
||||||
|
{ originalData, updatedData }
|
||||||
|
);
|
||||||
|
console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ [handleSave] ${targetTable} 저장 실패:`, error.response?.data || error.message);
|
||||||
|
// 개별 실패는 전체 저장을 중단하지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.9: RepeatScreenModal 집계 저장 처리
|
||||||
|
const aggregationConfigs = context.formData._repeatScreenModal_aggregations as Array<{
|
||||||
|
resultField: string;
|
||||||
|
aggregatedValue: number;
|
||||||
|
targetTable: string;
|
||||||
|
targetColumn: string;
|
||||||
|
joinKey: { sourceField: string; targetField: string };
|
||||||
|
sourceValue: any;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (aggregationConfigs && aggregationConfigs.length > 0) {
|
||||||
|
console.log("📊 [handleSave] 집계 저장 시작:", aggregationConfigs);
|
||||||
|
|
||||||
|
for (const config of aggregationConfigs) {
|
||||||
|
const { targetTable, targetColumn, joinKey, aggregatedValue, sourceValue } = config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalData = { [joinKey.targetField]: sourceValue };
|
||||||
|
const updatedData = {
|
||||||
|
[targetColumn]: aggregatedValue,
|
||||||
|
[joinKey.targetField]: sourceValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`);
|
||||||
|
|
||||||
|
const updateResult = await apiClient.put(
|
||||||
|
`/table-management/tables/${targetTable}/edit`,
|
||||||
|
{ originalData, updatedData }
|
||||||
|
);
|
||||||
|
console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!saveResult.success) {
|
if (!saveResult.success) {
|
||||||
|
|
@ -1200,6 +1341,7 @@ export class ButtonActionExecutor {
|
||||||
// 🆕 선택된 행 데이터 수집
|
// 🆕 선택된 행 데이터 수집
|
||||||
const selectedData = context.selectedRowsData || [];
|
const selectedData = context.selectedRowsData || [];
|
||||||
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
||||||
|
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
|
||||||
|
|
||||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||||
const modalEvent = new CustomEvent("openScreenModal", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
|
|
@ -1211,6 +1353,8 @@ export class ButtonActionExecutor {
|
||||||
// 🆕 선택된 행 데이터 전달
|
// 🆕 선택된 행 데이터 전달
|
||||||
selectedData: selectedData,
|
selectedData: selectedData,
|
||||||
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||||
|
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
|
||||||
|
splitPanelParentData: context.splitPanelParentData || {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3501,6 +3645,7 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
||||||
|
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
||||||
*/
|
*/
|
||||||
private static async saveLocationToHistory(
|
private static async saveLocationToHistory(
|
||||||
tripId: string | null,
|
tripId: string | null,
|
||||||
|
|
@ -3517,13 +3662,15 @@ export class ButtonActionExecutor {
|
||||||
try {
|
try {
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords;
|
||||||
|
|
||||||
const locationData = {
|
const locationData = {
|
||||||
latitude: position.coords.latitude,
|
latitude,
|
||||||
longitude: position.coords.longitude,
|
longitude,
|
||||||
accuracy: position.coords.accuracy,
|
accuracy,
|
||||||
altitude: position.coords.altitude,
|
altitude,
|
||||||
speed: position.coords.speed,
|
speed,
|
||||||
heading: position.coords.heading,
|
heading,
|
||||||
tripId,
|
tripId,
|
||||||
tripStatus,
|
tripStatus,
|
||||||
departure,
|
departure,
|
||||||
|
|
@ -3536,6 +3683,7 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
console.log("📍 [saveLocationToHistory] 위치 저장:", locationData);
|
console.log("📍 [saveLocationToHistory] 위치 저장:", locationData);
|
||||||
|
|
||||||
|
// 1. vehicle_location_history에 저장
|
||||||
const response = await apiClient.post(`/dynamic-form/location-history`, locationData);
|
const response = await apiClient.post(`/dynamic-form/location-history`, locationData);
|
||||||
|
|
||||||
if (response.data?.success) {
|
if (response.data?.success) {
|
||||||
|
|
@ -3544,6 +3692,41 @@ export class ButtonActionExecutor {
|
||||||
console.warn("⚠️ 위치 이력 저장 실패:", response.data);
|
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();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 위치 이력 저장 오류:", error);
|
console.error("❌ 위치 이력 저장 오류:", error);
|
||||||
|
|
@ -3673,13 +3856,18 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 공차 추적용 watchId 저장
|
||||||
|
private static emptyVehicleWatchId: number | null = null;
|
||||||
|
private static emptyVehicleTripId: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공차등록 액션 처리
|
* 공차등록 액션 처리
|
||||||
* - 위치 수집 + 상태 변경 (예: status → inactive)
|
* - 위치 수집 + 상태 변경 (예: status → inactive)
|
||||||
|
* - 연속 위치 추적 시작 (vehicle_location_history에 저장)
|
||||||
*/
|
*/
|
||||||
private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
|
console.log("📍 공차등록 액션 실행:", { config, context });
|
||||||
|
|
||||||
// 브라우저 Geolocation API 지원 확인
|
// 브라우저 Geolocation API 지원 확인
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
|
|
@ -3708,7 +3896,7 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
toast.dismiss(loadingToastId);
|
toast.dismiss(loadingToastId);
|
||||||
|
|
||||||
const { latitude, longitude, accuracy } = position.coords;
|
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
|
||||||
const timestamp = new Date(position.timestamp);
|
const timestamp = new Date(position.timestamp);
|
||||||
|
|
||||||
console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
|
console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
|
||||||
|
|
@ -3777,8 +3965,15 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`);
|
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) {
|
} catch (saveError) {
|
||||||
console.error("❌ 위치정보 자동 저장 실패:", saveError);
|
console.error("❌ 위치정보 자동 저장 실패:", saveError);
|
||||||
toast.error("위치 정보 저장에 실패했습니다.");
|
toast.error("위치 정보 저장에 실패했습니다.");
|
||||||
|
|
@ -3795,7 +3990,7 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 위치정보 가져오기 실패:", error);
|
console.error("❌ 공차등록 실패:", error);
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
|
|
||||||
// GeolocationPositionError 처리
|
// GeolocationPositionError 처리
|
||||||
|
|
@ -3821,6 +4016,122 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공차 상태에서 연속 위치 추적 시작
|
||||||
|
*/
|
||||||
|
private static async startEmptyVehicleTracking(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext,
|
||||||
|
initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null }
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 기존 추적이 있으면 중지
|
||||||
|
if (this.emptyVehicleWatchId !== null) {
|
||||||
|
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
|
||||||
|
this.emptyVehicleWatchId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
// Trip ID 생성 (공차용)
|
||||||
|
const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
||||||
|
this.emptyVehicleTripId = tripId;
|
||||||
|
|
||||||
|
const userId = context.userId || "";
|
||||||
|
const companyCode = context.companyCode || "";
|
||||||
|
const departure = context.formData?.departure || "";
|
||||||
|
const arrival = context.formData?.arrival || "";
|
||||||
|
const departureName = context.formData?.departure_name || "";
|
||||||
|
const destinationName = context.formData?.destination_name || "";
|
||||||
|
|
||||||
|
// 시작 위치 기록
|
||||||
|
try {
|
||||||
|
await apiClient.post("/dynamic-form/location-history", {
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
latitude: initialPosition.latitude,
|
||||||
|
longitude: initialPosition.longitude,
|
||||||
|
accuracy: initialPosition.accuracy,
|
||||||
|
speed: initialPosition.speed,
|
||||||
|
heading: initialPosition.heading,
|
||||||
|
altitude: initialPosition.altitude,
|
||||||
|
tripStatus: "empty_start", // 공차 시작
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
console.log("📍 공차 시작 위치 기록 완료:", tripId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추적 간격 (기본 10초)
|
||||||
|
const trackingInterval = config.emptyVehicleTrackingInterval || 10000;
|
||||||
|
|
||||||
|
// watchPosition으로 연속 추적
|
||||||
|
this.emptyVehicleWatchId = navigator.geolocation.watchPosition(
|
||||||
|
async (position) => {
|
||||||
|
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post("/dynamic-form/location-history", {
|
||||||
|
tripId: this.emptyVehicleTripId,
|
||||||
|
userId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy,
|
||||||
|
speed,
|
||||||
|
heading,
|
||||||
|
altitude,
|
||||||
|
tripStatus: "empty_tracking", // 공차 추적 중
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("⚠️ 공차 위치 기록 실패:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("❌ 공차 위치 추적 오류:", error.message);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: trackingInterval,
|
||||||
|
maximumAge: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 공차 위치 추적 시작 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공차 위치 추적 중지 (운행 전환 시 호출)
|
||||||
|
*/
|
||||||
|
public static stopEmptyVehicleTracking(): void {
|
||||||
|
if (this.emptyVehicleWatchId !== null) {
|
||||||
|
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
|
||||||
|
console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId });
|
||||||
|
this.emptyVehicleWatchId = null;
|
||||||
|
this.emptyVehicleTripId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 공차 추적 Trip ID 반환
|
||||||
|
*/
|
||||||
|
public static getEmptyVehicleTripId(): string | null {
|
||||||
|
return this.emptyVehicleTripId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지)
|
* 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지)
|
||||||
*/
|
*/
|
||||||
|
|
@ -3885,7 +4196,13 @@ export class ButtonActionExecutor {
|
||||||
*/
|
*/
|
||||||
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
|
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
||||||
|
|
||||||
|
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
|
||||||
|
if (this.emptyVehicleWatchId !== null) {
|
||||||
|
this.stopEmptyVehicleTracking();
|
||||||
|
console.log("🛑 공차 추적 종료 후 운행 시작");
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 연속 위치 추적 모드 처리
|
// 🆕 연속 위치 추적 모드 처리
|
||||||
if (config.updateWithTracking) {
|
if (config.updateWithTracking) {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,28 @@ export interface FlowConditionGroup {
|
||||||
conditions: FlowCondition[];
|
conditions: FlowCondition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 다중 REST API 연결 설정
|
||||||
|
// ============================================
|
||||||
|
export interface RestApiConnectionConfig {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
endpoint: string;
|
||||||
|
jsonPath: string;
|
||||||
|
alias: string; // 컬럼 접두어 (예: "api1_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 다중 외부 DB 연결 설정
|
||||||
|
// ============================================
|
||||||
|
export interface ExternalDbConnectionConfig {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
dbType: string;
|
||||||
|
tableName: string;
|
||||||
|
alias: string; // 컬럼 접두어 (예: "db1_")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 플로우 정의
|
// 플로우 정의
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -41,6 +63,17 @@ export interface FlowDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tableName: 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;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -52,6 +85,17 @@ export interface CreateFlowDefinitionRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tableName: 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 {
|
export interface UpdateFlowDefinitionRequest {
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export const OPERATION_OPTIONS = [
|
||||||
export const INTEGRATION_TYPE_OPTIONS = [
|
export const INTEGRATION_TYPE_OPTIONS = [
|
||||||
{ value: "internal", label: "내부 DB (기본)" },
|
{ value: "internal", label: "내부 DB (기본)" },
|
||||||
{ value: "external_db", label: "외부 DB 연동" },
|
{ value: "external_db", label: "외부 DB 연동" },
|
||||||
{ value: "rest_api", label: "REST API (추후 지원)" },
|
{ value: "rest_api", label: "REST API 연동" },
|
||||||
{ value: "webhook", label: "Webhook (추후 지원)" },
|
{ value: "webhook", label: "Webhook (추후 지원)" },
|
||||||
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
|
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -1678,3 +1678,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
## 결론
|
## 결론
|
||||||
|
|
||||||
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -525,3 +525,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
- ✅ 매핑 엔진 완성
|
- ✅ 매핑 엔진 완성
|
||||||
|
|
||||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -512,3 +512,4 @@ function ScreenViewPage() {
|
||||||
**충돌 위험도: 낮음 (🟢)**
|
**충돌 위험도: 낮음 (🟢)**
|
||||||
|
|
||||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue