Merge remote-tracking branch 'origin/main' into ksh

This commit is contained in:
SeongHyun Kim 2025-12-03 18:48:37 +09:00
commit de8b643277
60 changed files with 5446 additions and 955 deletions

View File

@ -73,6 +73,7 @@ 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 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"; // 임시 주석
@ -238,6 +239,7 @@ 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/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석

View File

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

View File

@ -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 : "알 수 없는 오류",
},
});
}
}
} }

View File

@ -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: "회원 탈퇴 처리 중 오류가 발생했습니다.",
});
}
}
}

View File

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

View File

@ -66,11 +66,12 @@ export class FlowController {
return; return;
} }
// REST API인 경우 테이블 존재 확인 스킵 // REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
const isRestApi = dbSourceType === "restapi"; const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { 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) {
@ -92,6 +93,7 @@ export class FlowController {
restApiConnectionId, restApiConnectionId,
restApiEndpoint, restApiEndpoint,
restApiJsonPath, restApiJsonPath,
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
}, },
userId, userId,
userCompanyCode userCompanyCode

View File

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

View File

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

View File

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

View File

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

View File

@ -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); // 단일 화면 복사 (하위 호환용)

View File

@ -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 || "회원가입 중 오류가 발생했습니다.",
};
}
}
} }

View File

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

View File

@ -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 : "알 수 없는 오류",
},
};
}
}
} }

View File

@ -30,6 +30,7 @@ export class FlowDefinitionService {
restApiConnectionId: request.restApiConnectionId, restApiConnectionId: request.restApiConnectionId,
restApiEndpoint: request.restApiEndpoint, restApiEndpoint: request.restApiEndpoint,
restApiJsonPath: request.restApiJsonPath, restApiJsonPath: request.restApiJsonPath,
restApiConnections: request.restApiConnections,
companyCode, companyCode,
userId, userId,
}); });
@ -38,9 +39,9 @@ export class FlowDefinitionService {
INSERT INTO flow_definition ( INSERT INTO flow_definition (
name, description, table_name, db_source_type, db_connection_id, name, description, table_name, db_source_type, db_connection_id,
rest_api_connection_id, rest_api_endpoint, rest_api_json_path, rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
company_code, created_by rest_api_connections, company_code, created_by
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING * RETURNING *
`; `;
@ -52,7 +53,8 @@ export class FlowDefinitionService {
request.dbConnectionId || null, request.dbConnectionId || null,
request.restApiConnectionId || null, request.restApiConnectionId || null,
request.restApiEndpoint || null, request.restApiEndpoint || null,
request.restApiJsonPath || "data", request.restApiJsonPath || "response",
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
companyCode, companyCode,
userId, userId,
]; ];
@ -209,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,
@ -216,10 +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 관련 필드 // REST API 관련 필드 (단일)
restApiConnectionId: row.rest_api_connection_id, restApiConnectionId: row.rest_api_connection_id,
restApiEndpoint: row.rest_api_endpoint, restApiEndpoint: row.rest_api_endpoint,
restApiJsonPath: row.rest_api_json_path, 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,

View File

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

View File

@ -892,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 };
}
/** /**
* *
*/ */
@ -1517,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,
@ -1530,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(`로드된 컴포넌트:`, {
@ -1539,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;
@ -1558,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;
}
// ======================================== // ========================================
// 템플릿 관리 // 템플릿 관리
// ======================================== // ========================================

View File

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

View File

@ -2,18 +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" | "restapi"; // 데이터 소스 타입 dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
// REST API 관련 필드 // REST API 관련 필드 (단일)
restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우)
restApiEndpoint?: string; // REST API 엔드포인트 restApiEndpoint?: string; // REST API 엔드포인트
restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data)
// 다중 REST API 관련 필드
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
companyCode: string; // 회사 코드 (* = 공통) companyCode: string; // 회사 코드 (* = 공통)
isActive: boolean; isActive: boolean;
createdBy?: string; createdBy?: string;
@ -26,12 +46,14 @@ export interface CreateFlowDefinitionRequest {
name: string; name: string;
description?: string; description?: string;
tableName: string; tableName: string;
dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID dbConnectionId?: number; // 외부 DB 연결 ID
// REST API 관련 필드 // REST API 관련 필드 (단일)
restApiConnectionId?: number; // REST API 연결 ID restApiConnectionId?: number; // REST API 연결 ID
restApiEndpoint?: string; // REST API 엔드포인트 restApiEndpoint?: string; // REST API 엔드포인트
restApiJsonPath?: string; // JSON 응답에서 데이터 경로 restApiJsonPath?: string; // JSON 응답에서 데이터 경로
// 다중 REST API 관련 필드
restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
} }

View File

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

View File

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

View File

@ -64,7 +64,30 @@ export default function FlowManagementPage() {
// REST API 연결 관련 상태 // REST API 연결 관련 상태
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]); const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [restApiEndpoint, setRestApiEndpoint] = useState(""); const [restApiEndpoint, setRestApiEndpoint] = useState("");
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); 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({
@ -207,25 +230,161 @@ export default function FlowManagementPage() {
} }
}, [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);
// REST API인 경우 테이블 이름 검증 스킵 // REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵
const isRestApi = selectedDbSource.startsWith("restapi_"); const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi;
const isMultiMode = isMultiRestApi || isMultiExternalDb;
if (!formData.name || (!isRestApi && !formData.tableName)) { if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode });
toast({ toast({
title: "입력 오류", title: "입력 오류",
description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive", variant: "destructive",
}); });
return; return;
} }
// REST API인 경우 엔드포인트 검증 // 다중 REST API 모드인 경우 검증
if (isRestApi && !restApiEndpoint) { 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({ toast({
title: "입력 오류", title: "입력 오류",
description: "REST API 엔드포인트는 필수입니다.", description: "REST API 엔드포인트는 필수입니다.",
@ -236,11 +395,15 @@ export default function FlowManagementPage() {
try { try {
// 데이터 소스 타입 및 ID 파싱 // 데이터 소스 타입 및 ID 파싱
let dbSourceType: "internal" | "external" | "restapi" = "internal"; let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal";
let dbConnectionId: number | undefined = undefined; let dbConnectionId: number | undefined = undefined;
let restApiConnectionId: number | undefined = undefined; let restApiConnectionId: number | undefined = undefined;
if (selectedDbSource === "internal") { if (isMultiRestApi) {
dbSourceType = "multi_restapi";
} else if (isMultiExternalDb) {
dbSourceType = "multi_external_db";
} else if (selectedDbSource === "internal") {
dbSourceType = "internal"; dbSourceType = "internal";
} else if (selectedDbSource.startsWith("external_db_")) { } else if (selectedDbSource.startsWith("external_db_")) {
dbSourceType = "external"; dbSourceType = "external";
@ -257,11 +420,27 @@ export default function FlowManagementPage() {
dbConnectionId, dbConnectionId,
}; };
// REST API인 경우 추가 정보 // 다중 REST API인 경우
if (dbSourceType === "restapi") { 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.restApiConnectionId = restApiConnectionId;
requestData.restApiEndpoint = restApiEndpoint; requestData.restApiEndpoint = restApiEndpoint;
requestData.restApiJsonPath = restApiJsonPath || "data"; requestData.restApiJsonPath = restApiJsonPath || "response";
// REST API는 가상 테이블명 사용 // REST API는 가상 테이블명 사용
requestData.tableName = `_restapi_${restApiConnectionId}`; requestData.tableName = `_restapi_${restApiConnectionId}`;
} }
@ -277,7 +456,11 @@ export default function FlowManagementPage() {
setFormData({ name: "", description: "", tableName: "" }); setFormData({ name: "", description: "", tableName: "" });
setSelectedDbSource("internal"); setSelectedDbSource("internal");
setRestApiEndpoint(""); setRestApiEndpoint("");
setRestApiJsonPath("data"); setRestApiJsonPath("response");
setSelectedRestApis([]);
setSelectedExternalDbs([]);
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
loadFlows(); loadFlows();
} else { } else {
toast({ toast({
@ -485,13 +668,27 @@ export default function FlowManagementPage() {
<div> <div>
<Label className="text-xs sm:text-sm"> </Label> <Label className="text-xs sm:text-sm"> </Label>
<Select <Select
value={selectedDbSource} value={isMultiRestApi ? "multi_restapi" : isMultiExternalDb ? "multi_external_db" : selectedDbSource}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedDbSource(value); if (value === "multi_restapi") {
// 소스 변경 시 테이블 선택 및 REST API 설정 초기화 setIsMultiRestApi(true);
setIsMultiExternalDb(false);
setSelectedDbSource("internal");
} else if (value === "multi_external_db") {
setIsMultiExternalDb(true);
setIsMultiRestApi(false);
setSelectedDbSource("internal");
} else {
setIsMultiRestApi(false);
setIsMultiExternalDb(false);
setSelectedDbSource(value);
}
// 소스 변경 시 초기화
setFormData({ ...formData, tableName: "" }); setFormData({ ...formData, tableName: "" });
setRestApiEndpoint(""); setRestApiEndpoint("");
setRestApiJsonPath("data"); 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">
@ -504,7 +701,7 @@ export default function FlowManagementPage() {
{/* 외부 DB 연결 */} {/* 외부 DB 연결 */}
{externalConnections.length > 0 && ( {externalConnections.length > 0 && (
<> <>
<SelectItem value="__divider_db__" disabled className="text-xs text-muted-foreground"> <SelectItem value="__divider_db__" disabled className="text-muted-foreground text-xs">
-- -- -- --
</SelectItem> </SelectItem>
{externalConnections.map((conn) => ( {externalConnections.map((conn) => (
@ -518,7 +715,7 @@ export default function FlowManagementPage() {
{/* REST API 연결 */} {/* REST API 연결 */}
{restApiConnections.length > 0 && ( {restApiConnections.length > 0 && (
<> <>
<SelectItem value="__divider_api__" disabled className="text-xs text-muted-foreground"> <SelectItem value="__divider_api__" disabled className="text-muted-foreground text-xs">
-- REST API -- -- REST API --
</SelectItem> </SelectItem>
{restApiConnections.map((conn) => ( {restApiConnections.map((conn) => (
@ -528,6 +725,25 @@ export default function FlowManagementPage() {
))} ))}
</> </>
)} )}
{/* 다중 연결 옵션 */}
{(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">
@ -535,8 +751,160 @@ export default function FlowManagementPage() {
</p> </p>
</div> </div>
{/* REST API인 경우 엔드포인트 설정 */} {/* 다중 REST API 선택 UI */}
{selectedDbSource.startsWith("restapi_") ? ( {isMultiRestApi && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm">REST API </Label>
<Select
value=""
onValueChange={(value) => {
if (value) {
addRestApiConfig(parseInt(value));
}
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="API 추가..." />
</SelectTrigger>
<SelectContent>
{restApiConnections
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id))
.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRestApis.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm">
REST API를
</p>
</div>
) : (
<div className="space-y-2">
{selectedRestApis.map((api) => (
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{api.connectionName}</span>
<span className="text-muted-foreground text-xs">
({api.endpoint || "기본 엔드포인트"})
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeRestApiConfig(api.connectionId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px] sm:text-xs">
REST API들의 .
</p>
</div>
)}
{/* 다중 외부 DB 선택 UI */}
{isMultiExternalDb && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> DB </Label>
<Select
value=""
onValueChange={(value) => {
if (value) {
addExternalDbConfig(parseInt(value));
}
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="DB 추가..." />
</SelectTrigger>
<SelectContent>
{externalConnections
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id))
.map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedExternalDbs.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm">
DB를
</p>
</div>
) : (
<div className="space-y-3">
{selectedExternalDbs.map((db) => (
<div key={db.connectionId} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{db.connectionName} ({db.dbType?.toUpperCase()})
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeExternalDbConfig(db.connectionId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Select
value={db.tableName}
onValueChange={(value) => updateExternalDbConfig(db.connectionId, "tableName", value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{(multiDbTableLists[db.connectionId] || []).map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Input
value={db.alias}
onChange={(e) => updateExternalDbConfig(db.connectionId, "alias", e.target.value)}
placeholder="db1_"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px] sm:text-xs">
DB들의 . DB별 .
</p>
</div>
)}
{/* 단일 REST API인 경우 엔드포인트 설정 */}
{!isMultiRestApi && selectedDbSource.startsWith("restapi_") && (
<> <>
<div> <div>
<Label htmlFor="restApiEndpoint" className="text-xs sm:text-sm"> <Label htmlFor="restApiEndpoint" className="text-xs sm:text-sm">
@ -569,8 +937,10 @@ export default function FlowManagementPage() {
</p> </p>
</div> </div>
</> </>
) : ( )}
/* 테이블 선택 (내부 DB 또는 외부 DB) */
{/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */}
{!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
<div> <div>
<Label htmlFor="tableName" className="text-xs sm:text-sm"> <Label htmlFor="tableName" className="text-xs sm:text-sm">
* *

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
@ -132,7 +134,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenModal = (event: CustomEvent) => { const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, description, size, urlParams, editData, selectedData: eventSelectedData, selectedIds } = event.detail; const {
screenId,
title,
description,
size,
urlParams,
editData,
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
} = event.detail;
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", { console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
screenId, screenId,
@ -170,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); // 신규 등록 모드
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, 연결필터 추가
); );
// 현재 사용자 정보 로드 // 현재 사용자 정보 로드

View File

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

View File

@ -846,13 +846,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
restApiConnectionId: selectedScreen?.restApiConnectionId, restApiConnectionId: selectedScreen?.restApiConnectionId,
restApiEndpoint: selectedScreen?.restApiEndpoint, restApiEndpoint: selectedScreen?.restApiEndpoint,
restApiJsonPath: selectedScreen?.restApiJsonPath, restApiJsonPath: selectedScreen?.restApiJsonPath,
// 전체 selectedScreen 객체도 출력
fullScreen: selectedScreen,
}); });
// REST API 데이터 소스인 경우 // REST API 데이터 소스인 경우
// tableName이 restapi_로 시작하면 REST API로 간주 // 1. dataSourceType이 "restapi"인 경우
// 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우
// 3. restApiConnectionId가 있는 경우
const isRestApi = selectedScreen?.dataSourceType === "restapi" || const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
selectedScreen?.tableName?.startsWith("restapi_") || selectedScreen?.tableName?.startsWith("restapi_") ||
selectedScreen?.tableName?.startsWith("_restapi_"); selectedScreen?.tableName?.startsWith("_restapi_") ||
!!selectedScreen?.restApiConnectionId;
console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi });
if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) { if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) {
try { try {
@ -1258,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;
@ -1266,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],
); );

View File

@ -120,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);
@ -479,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]);
@ -488,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));
@ -497,7 +503,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
} }
}; };
// 일괄삭제 실행 // 휴지통 일괄삭제 실행
const handleBulkDelete = () => { const handleBulkDelete = () => {
if (selectedScreenIds.length === 0) { if (selectedScreenIds.length === 0) {
alert("삭제할 화면을 선택해주세요."); alert("삭제할 화면을 선택해주세요.");
@ -506,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;
@ -633,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>
@ -641,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>
@ -659,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>
@ -757,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>}
@ -863,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>
@ -1225,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 />
@ -1254,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]">

View File

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

View File

@ -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에서 오버라이드됨

View File

@ -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로 (배지 없음)
}); });
}; };

View File

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

View File

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

135
frontend/lib/api/driver.ts Normal file
View File

@ -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 || "회원 탈퇴에 실패했습니다.",
};
}
}

View File

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

View File

@ -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, // 🆕 화면 정보

View File

@ -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;
// 확인이 필요한 액션인지 확인 // 확인이 필요한 액션인지 확인

View File

@ -1,13 +1,18 @@
"use client"; "use client";
import React, { useEffect, useState, useMemo } from "react"; import React, { useEffect, useState, useMemo, useCallback } 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";
export interface CardDisplayComponentProps extends ComponentRendererProps { export interface CardDisplayComponentProps extends ComponentRendererProps {
config?: CardDisplayConfig; config?: CardDisplayConfig;
@ -38,10 +43,26 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
tableColumns = [], tableColumns = [],
...props ...props
}) => { }) => {
// 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음)
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const splitPanelPosition = screenContext?.splitPanelPosition;
// 테이블 데이터 상태 관리 // 테이블 데이터 상태 관리
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 [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 +129,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 +251,135 @@ 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]);
// 로딩 중인 경우 로딩 표시 // 로딩 중인 경우 로딩 표시
if (loading) { if (loading) {
return ( return (
@ -261,26 +413,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 +435,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 +535,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 +607,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 +769,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>

View File

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

View File

@ -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; // 편집 버튼 표시 여부
} }
/** /**

View File

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

View File

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

View File

@ -1075,7 +1075,68 @@ 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;
// 🆕 REST API 데이터 소스 처리 // 🆕 REST API 데이터 소스 처리
const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_");
@ -1122,18 +1183,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable, referenceTable: col.additionalJoinInfo!.referenceTable,
})); }));
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) // console.log("🔍 [TableList] API 호출 시작", {
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { // tableName: tableConfig.selectedTable,
page, // page,
size: pageSize, // pageSize,
sortBy, // sortBy,
sortOrder, // sortOrder,
search: filters, // });
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
}); page,
} size: pageSize,
sortBy,
sortOrder,
search: hasFilters ? filters : undefined,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
});
// 실제 데이터의 item_number만 추출하여 중복 확인 // 실제 데이터의 item_number만 추출하여 중복 확인
const itemNumbers = (response.data || []).map((item: any) => item.item_number); const itemNumbers = (response.data || []).map((item: any) => item.item_number);
@ -1173,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([]);
@ -1193,6 +1262,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchTerm, searchTerm,
searchValues, searchValues,
isDesignMode, isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회
]); ]);
const fetchTableDataDebounced = useCallback( const fetchTableDataDebounced = useCallback(
@ -1495,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 });
}; };
@ -2140,6 +2226,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey, refreshKey,
refreshTrigger, // 강제 새로고침 트리거 refreshTrigger, // 강제 새로고침 트리거
isDesignMode, isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]); ]);

View File

@ -261,6 +261,9 @@ export interface ButtonActionContext {
// 🆕 컴포넌트별 설정 (parentDataMapping 등) // 🆕 컴포넌트별 설정 (parentDataMapping 등)
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정 componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData?: Record<string, any>;
} }
/** /**
@ -561,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> = {};
@ -571,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는 항상 로그인한 사람
@ -1316,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", {
@ -1327,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 || {},
}, },
}); });

View File

@ -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;
@ -53,12 +86,16 @@ export interface CreateFlowDefinitionRequest {
description?: string; description?: string;
tableName: string; tableName: string;
// 데이터 소스 관련 // 데이터 소스 관련
dbSourceType?: "internal" | "external" | "restapi"; dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db";
dbConnectionId?: number; dbConnectionId?: number;
// REST API 관련 // REST API 관련 (단일)
restApiConnectionId?: number; restApiConnectionId?: number;
restApiEndpoint?: string; restApiEndpoint?: string;
restApiJsonPath?: string; restApiJsonPath?: string;
// 다중 REST API 관련
restApiConnections?: RestApiConnectionConfig[];
// 다중 외부 DB 관련
externalDbConnections?: ExternalDbConnectionConfig[];
} }
export interface UpdateFlowDefinitionRequest { export interface UpdateFlowDefinitionRequest {

View File

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

View File

@ -1678,3 +1678,4 @@ const 출고등록_설정: ScreenSplitPanel = {
## 결론 ## 결론
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. 화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.

View File

@ -525,3 +525,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
- ✅ 매핑 엔진 완성 - ✅ 매핑 엔진 완성
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.

View File

@ -512,3 +512,4 @@ function ScreenViewPage() {
**충돌 위험도: 낮음 (🟢)** **충돌 위험도: 낮음 (🟢)**
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.