Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons 2025-11-20 14:02:34 +09:00
commit 461338618e
112 changed files with 23789 additions and 1226 deletions

View File

@ -69,6 +69,8 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -232,6 +234,8 @@ app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@ -0,0 +1,118 @@
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* API
* GET /api/entity-search/:tableName
*/
export async function searchEntity(req: Request, res: Response) {
try {
const { tableName } = req.params;
const {
searchText = "",
searchFields = "",
filterCondition = "{}",
page = "1",
limit = "20",
} = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
return res.status(400).json({
success: false,
message: "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
});
}
// 멀티테넌시
const companyCode = req.user!.companyCode;
// 검색 필드 파싱
const fields = searchFields
? (searchFields as string).split(",").map((f) => f.trim())
: [];
// WHERE 조건 생성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터링
if (companyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 검색 조건
if (searchText && fields.length > 0) {
const searchConditions = fields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
// 검색어 파라미터 추가
fields.forEach(() => {
params.push(`%${searchText}%`);
});
}
// 추가 필터 조건
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
whereConditions.push(`${key} = $${paramIndex}`);
params.push(value);
paramIndex++;
}
// 페이징
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 쿼리 실행
const pool = getPool();
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = `
SELECT * FROM ${tableName} ${whereClause}
ORDER BY id DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit as string));
params.push(offset);
const countResult = await pool.query(
countQuery,
params.slice(0, params.length - 2)
);
const dataResult = await pool.query(dataQuery, params);
logger.info("엔티티 검색 성공", {
tableName,
searchText,
companyCode,
rowCount: dataResult.rowCount,
});
res.json({
success: true,
data: dataResult.rows,
pagination: {
total: parseInt(countResult.rows[0].count),
page: parseInt(page as string),
limit: parseInt(limit as string),
},
});
} catch (error: any) {
logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -0,0 +1,238 @@
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
*
* 형식: ORD + YYMMDD + 4 퀀
* : ORD250114001
*/
async function generateOrderNumber(companyCode: string): Promise<string> {
const pool = getPool();
const today = new Date();
const year = today.getFullYear().toString().slice(2); // 25
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
const day = String(today.getDate()).padStart(2, "0"); // 14
const dateStr = `${year}${month}${day}`; // 250114
// 당일 수주 카운트 조회
const countQuery = `
SELECT COUNT(*) as count
FROM order_mng_master
WHERE objid LIKE $1
AND writer LIKE $2
`;
const pattern = `ORD${dateStr}%`;
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
const count = parseInt(result.rows[0]?.count || "0");
const seq = count + 1;
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
}
/**
* API
* POST /api/orders
*/
export async function createOrder(req: Request, res: Response) {
const pool = getPool();
try {
const {
inputMode, // 입력 방식
customerCode, // 거래처 코드
deliveryDate, // 납품일
items, // 품목 목록
memo, // 메모
} = req.body;
// 멀티테넌시
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 유효성 검사
if (!customerCode) {
return res.status(400).json({
success: false,
message: "거래처 코드는 필수입니다",
});
}
if (!items || items.length === 0) {
return res.status(400).json({
success: false,
message: "품목은 최소 1개 이상 필요합니다",
});
}
// 수주 번호 생성
const orderNo = await generateOrderNumber(companyCode);
// 전체 금액 계산
const totalAmount = items.reduce(
(sum: number, item: any) => sum + (item.amount || 0),
0
);
// 수주 마스터 생성
const masterQuery = `
INSERT INTO order_mng_master (
objid,
partner_objid,
final_delivery_date,
reason,
status,
reg_date,
writer
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
RETURNING *
`;
const masterResult = await pool.query(masterQuery, [
orderNo,
customerCode,
deliveryDate || null,
memo || null,
"진행중",
`${userId}|${companyCode}`,
]);
const masterObjid = masterResult.rows[0].objid;
// 수주 상세 (품목) 생성
for (let i = 0; i < items.length; i++) {
const item = items[i];
const subObjid = `${orderNo}_${i + 1}`;
const subQuery = `
INSERT INTO order_mng_sub (
objid,
order_mng_master_objid,
part_objid,
partner_objid,
partner_price,
partner_qty,
delivery_date,
status,
regdate,
writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
`;
await pool.query(subQuery, [
subObjid,
masterObjid,
item.item_code || item.id, // 품목 코드
customerCode,
item.unit_price || 0,
item.quantity || 0,
item.delivery_date || deliveryDate || null,
"진행중",
`${userId}|${companyCode}`,
]);
}
logger.info("수주 등록 성공", {
companyCode,
orderNo,
masterObjid,
itemCount: items.length,
totalAmount,
});
res.json({
success: true,
data: {
orderNo,
masterObjid,
itemCount: items.length,
totalAmount,
},
message: "수주가 등록되었습니다",
});
} catch (error: any) {
logger.error("수주 등록 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: error.message || "수주 등록 중 오류가 발생했습니다",
});
}
}
/**
* API
* GET /api/orders
*/
export async function getOrders(req: Request, res: Response) {
const pool = getPool();
try {
const { page = "1", limit = "20", searchText = "" } = req.query;
const companyCode = req.user!.companyCode;
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
// WHERE 조건
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 (writer 필드에 company_code 포함)
if (companyCode !== "*") {
whereConditions.push(`writer LIKE $${paramIndex}`);
params.push(`%${companyCode}%`);
paramIndex++;
}
// 검색
if (searchText) {
whereConditions.push(`objid LIKE $${paramIndex}`);
params.push(`%${searchText}%`);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 카운트 쿼리
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0]?.count || "0");
// 데이터 쿼리
const dataQuery = `
SELECT * FROM order_mng_master
${whereClause}
ORDER BY reg_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit as string));
params.push(offset);
const dataResult = await pool.query(dataQuery, params);
res.json({
success: true,
data: dataResult.rows,
pagination: {
total,
page: parseInt(page as string),
limit: parseInt(limit as string),
},
});
} catch (error: any) {
logger.error("수주 목록 조회 오류", { error: error.message });
res.status(500).json({
success: false,
message: error.message,
});
}
}

View File

@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
const result = await screenManagementService.getScreensByCompany(
targetCompanyCode,
parseInt(page as string),
parseInt(size as string)
parseInt(size as string),
searchTerm as string // 검색어 전달
);
res.json({

View File

@ -187,6 +187,16 @@ export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Respon
});
} catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
// 사용 중인 경우 상세 에러 메시지 반환 (400)
if (error.message.includes("삭제할 수 없습니다")) {
return res.status(400).json({
success: false,
message: error.message,
});
}
// 기타 에러 (500)
return res.status(500).json({
success: false,
message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다",

View File

@ -1604,10 +1604,14 @@ export async function toggleLogTable(
}
/**
*
* ( )
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description input_type='category'
* @description category_column_mapping의
*
* :
* - 2 "고객사관리" discount_type, rounding_type
* - 3 "고객등록", "고객조회" ()
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu(
return;
}
// 1. 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
// 2. 형제 메뉴들이 사용하는 테이블 조회
const { getPool } = await import("../database/db");
const pool = getPool();
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
// 3. category_column_mapping 테이블 존재 여부 확인
// 1. category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu(
let columnsResult;
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 필터링
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
--
SELECT objid, parent_obj_id, menu_type
SELECT objid, parent_obj_id, menu_type, menu_name_kor
FROM menu_info
WHERE objid = $1
UNION ALL
--
SELECT m.objid, m.parent_obj_id, m.menu_type
SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor
FROM menu_info m
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
WHERE m.parent_obj_id != 0 -- (parent_obj_id=0)
)
SELECT ARRAY_AGG(objid) as menu_objids
SELECT
ARRAY_AGG(objid) as menu_objids,
ARRAY_AGG(menu_name_kor) as menu_names
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
ancestorMenuNames,
hierarchyDepth: ancestorMenuObjids.length
});
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu(
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
ttc.input_type AS "inputType",
ccm.menu_objid AS "definedAtMenuObjid"
FROM category_column_mapping ccm
INNER JOIN table_type_columns ttc
ON ccm.table_name = ttc.table_name
@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu(
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ccm.table_name = ANY($1)
AND ccm.company_code = $2
AND ccm.menu_objid = ANY($3)
WHERE ccm.company_code = $1
AND ccm.menu_objid = ANY($2)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
});
} else {
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
// 형제 메뉴들이 사용하는 테이블 조회
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
const columnsQuery = `
SELECT

View File

@ -14,7 +14,7 @@ router.get(
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } =
req.query;
// 입력값 검증
@ -37,6 +37,9 @@ router.get(
}
}
// 🆕 enableEntityJoin 파싱
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
// SQL 인젝션 방지를 위한 검증
const tables = [leftTable as string, rightTable as string];
const columns = [leftColumn as string, rightColumn as string];
@ -64,6 +67,31 @@ router.get(
// 회사 코드 추출 (멀티테넌시 필터링)
const userCompany = req.user?.companyCode;
// displayColumns 파싱 (item_info.item_name 등)
let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined;
if (displayColumns) {
try {
parsedDisplayColumns = JSON.parse(displayColumns as string);
} catch (e) {
console.error("displayColumns 파싱 실패:", e);
}
}
// 🆕 deduplication 파싱
let parsedDeduplication: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
} | undefined;
if (deduplication) {
try {
parsedDeduplication = JSON.parse(deduplication as string);
} catch (e) {
console.error("deduplication 파싱 실패:", e);
}
}
console.log(`🔗 조인 데이터 조회:`, {
leftTable,
rightTable,
@ -71,10 +99,13 @@ router.get(
rightColumn,
leftValue,
userCompany,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
dataFilter: parsedDataFilter,
enableEntityJoin: enableEntityJoinFlag,
displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그
deduplication: parsedDeduplication, // 🆕 중복 제거 로그
});
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
// 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달)
const result = await dataService.getJoinedData(
leftTable as string,
rightTable as string,
@ -82,7 +113,10 @@ router.get(
rightColumn as string,
leftValue as string,
userCompany,
parsedDataFilter // 🆕 데이터 필터 전달
parsedDataFilter,
enableEntityJoinFlag,
parsedDisplayColumns, // 🆕 표시 컬럼 전달
parsedDeduplication // 🆕 중복 제거 설정 전달
);
if (!result.success) {
@ -305,10 +339,31 @@ router.get(
});
}
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`);
const { enableEntityJoin, groupByColumns } = req.query;
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
// groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분)
let groupByColumnsArray: string[] = [];
if (groupByColumns) {
try {
if (typeof groupByColumns === "string") {
// JSON 형식이면 파싱, 아니면 쉼표로 분리
groupByColumnsArray = groupByColumns.startsWith("[")
? JSON.parse(groupByColumns)
: groupByColumns.split(",").map(c => c.trim());
}
} catch (error) {
console.warn("groupByColumns 파싱 실패:", error);
}
}
// 레코드 상세 조회
const result = await dataService.getRecordDetail(tableName, id);
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
enableEntityJoin: enableEntityJoinFlag,
groupByColumns: groupByColumnsArray
});
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray);
if (!result.success) {
return res.status(400).json(result);
@ -338,6 +393,86 @@ router.get(
}
);
/**
* UPSERT API
* POST /api/data/upsert-grouped
*
* :
* {
* tableName: string,
* parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" },
* records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ]
* }
*/
router.post(
"/upsert-grouped",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName, parentKeys, records } = req.body;
// 입력값 검증
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
error: "MISSING_PARAMETERS",
});
}
// 테이블명 검증
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
error: "INVALID_TABLE_NAME",
});
}
console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, {
parentKeys,
recordCount: records.length,
userCompany: req.user?.companyCode,
userId: req.user?.userId,
});
// UPSERT 수행
const result = await dataService.upsertGroupedRecords(
tableName,
parentKeys,
records,
req.user?.companyCode,
req.user?.userId
);
if (!result.success) {
return res.status(400).json(result);
}
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
return res.json({
success: true,
message: "데이터가 저장되었습니다.",
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
} catch (error) {
console.error("그룹화된 데이터 UPSERT 오류:", error);
return res.status(500).json({
success: false,
message: "데이터 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
);
/**
* API
* POST /api/data/{tableName}
@ -523,6 +658,46 @@ router.post(
}
);
/**
* API
* POST /api/data/:tableName/delete-group
*/
router.post(
"/:tableName/delete-group",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName } = req.params;
const filterConditions = req.body;
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명입니다.",
});
}
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
const result = await dataService.deleteGroupRecords(tableName, filterConditions);
if (!result.success) {
return res.status(400).json(result);
}
console.log(`✅ 그룹 삭제: ${result.data?.deleted}`);
return res.json(result);
} catch (error: any) {
console.error("그룹 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "그룹 삭제 실패",
error: error.message,
});
}
}
);
router.delete(
"/:tableName/:id",
authenticateToken,

View File

@ -0,0 +1,14 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { searchEntity } from "../controllers/entitySearchController";
const router = Router();
/**
* API
* GET /api/entity-search/:tableName
*/
router.get("/:tableName", authenticateToken, searchEntity);
export default router;

View File

@ -0,0 +1,20 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { createOrder, getOrders } from "../controllers/orderController";
const router = Router();
/**
*
* POST /api/orders
*/
router.post("/", authenticateToken, createOrder);
/**
*
* GET /api/orders
*/
router.get("/", authenticateToken, getOrders);
export default router;

View File

@ -14,7 +14,9 @@
* - (company_code = "*")
*/
import { query, queryOne } from "../database/db";
import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
interface GetTableDataParams {
tableName: string;
@ -53,6 +55,103 @@ const BLOCKED_TABLES = [
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
class DataService {
/**
* ( )
*/
private deduplicateData(
data: any[],
config: {
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): any[] {
if (!data || data.length === 0) return data;
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal > bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal < bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "base_price":
// base_price = true인 행 찾기
selectedRow = rows.find(row => row.base_price === true) || rows[0];
break;
case "current_date":
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
const today = new Date();
today.setHours(0, 0, 0, 0); // 시간 제거
selectedRow = rows.find(row => {
const startDate = row.start_date ? new Date(row.start_date) : null;
const endDate = row.end_date ? new Date(row.end_date) : null;
if (startDate) startDate.setHours(0, 0, 0, 0);
if (endDate) endDate.setHours(0, 0, 0, 0);
const afterStart = !startDate || today >= startDate;
const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
break;
default:
selectedRow = rows[0];
}
result.push(selectedRow);
}
return result;
}
/**
* ( )
*/
@ -374,11 +473,13 @@ class DataService {
}
/**
*
* (Entity Join + )
*/
async getRecordDetail(
tableName: string,
id: string | number
id: string | number,
enableEntityJoin: boolean = false,
groupByColumns: string[] = []
): Promise<ServiceResponse<any>> {
try {
// 테이블 접근 검증
@ -401,6 +502,108 @@ class DataService {
pkColumn = pkResult[0].attname;
}
// 🆕 Entity Join이 활성화된 경우
if (enableEntityJoin) {
const { EntityJoinService } = await import("./entityJoinService");
const entityJoinService = new EntityJoinService();
// Entity Join 구성 감지
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length > 0) {
console.log(`✅ Entity Join 감지: ${joinConfigs.length}`);
// Entity Join 쿼리 생성 (개별 파라미터로 전달)
const { query: joinQuery } = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
["*"],
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
);
const result = await pool.query(joinQuery, [id]);
if (result.rows.length === 0) {
return {
success: false,
message: "레코드를 찾을 수 없습니다.",
error: "RECORD_NOT_FOUND",
};
}
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
}
}
return normalized;
});
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
if (groupByColumns.length > 0) {
const baseRecord = result.rows[0];
// 그룹핑 컬럼들의 값 추출
const groupConditions: string[] = [];
const groupValues: any[] = [];
let paramIndex = 1;
for (const col of groupByColumns) {
const value = normalizedRows[0][col];
if (value !== undefined && value !== null) {
groupConditions.push(`main."${col}" = $${paramIndex}`);
groupValues.push(value);
paramIndex++;
}
}
if (groupConditions.length > 0) {
const groupWhereClause = groupConditions.join(" AND ");
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
// 그룹핑 기준으로 모든 레코드 조회
const { query: groupQuery } = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
["*"],
groupWhereClause
);
const groupResult = await pool.query(groupQuery, groupValues);
const normalizedGroupRows = normalizeDates(groupResult.rows);
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`);
return {
success: true,
data: normalizedGroupRows, // 🔧 배열로 반환!
};
}
}
return {
success: true,
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
};
}
}
// 기본 쿼리 (Entity Join 없음)
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
const result = await query<any>(queryText, [id]);
@ -427,7 +630,7 @@ class DataService {
}
/**
*
* (🆕 Entity )
*/
async getJoinedData(
leftTable: string,
@ -436,7 +639,15 @@ class DataService {
rightColumn: string,
leftValue?: string | number,
userCompany?: string,
dataFilter?: any // 🆕 데이터 필터
dataFilter?: any, // 🆕 데이터 필터
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
deduplication?: { // 🆕 중복 제거 설정
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): Promise<ServiceResponse<any[]>> {
try {
// 왼쪽 테이블 접근 검증
@ -451,6 +662,162 @@ class DataService {
return rightValidation.error!;
}
// 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용
if (enableEntityJoin) {
try {
const { entityJoinService } = await import("./entityJoinService");
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
if (displayColumns && Array.isArray(displayColumns)) {
// 테이블별로 요청된 컬럼들을 그룹핑
const tableColumns: Record<string, Set<string>> = {};
for (const col of displayColumns) {
if (col.name && col.name.includes('.')) {
const [refTable, refColumn] = col.name.split('.');
if (!tableColumns[refTable]) {
tableColumns[refTable] = new Set();
}
tableColumns[refTable].add(refColumn);
}
}
// 각 테이블별로 처리
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
// 이미 조인 설정에 있는지 확인
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
if (existingJoins.length > 0) {
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
for (const refColumn of refColumns) {
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
const existingJoin = existingJoins.find(
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
);
if (!existingJoin) {
// 없으면 새 조인 설정 복제하여 추가
const baseJoin = existingJoins[0];
const newJoin = {
...baseJoin,
displayColumns: [refColumn],
aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size)
// ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴
referenceTable: refTable,
referenceColumn: baseJoin.referenceColumn, // item_number 등
};
joinConfigs.push(newJoin);
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
}
}
} else {
console.warn(`⚠️ 조인 설정 없음: ${refTable}`);
}
}
}
if (joinConfigs.length > 0) {
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
// WHERE 조건 생성
const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
// 좌측 테이블 조인 조건 (leftValue로 필터링)
// rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002')
if (leftValue !== undefined && leftValue !== null) {
whereConditions.push(`main."${rightColumn}" = $${paramIndex}`);
values.push(leftValue);
paramIndex++;
}
// 회사별 필터링
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
if (hasCompanyCode) {
whereConditions.push(`main.company_code = $${paramIndex}`);
values.push(userCompany);
paramIndex++;
}
}
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
console.log(`📊 필터 파라미터:`, filterResult.params);
}
}
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
// Entity 조인 쿼리 빌드
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
const selectColumns = ["*"];
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
rightTable,
joinConfigs,
selectColumns,
whereClause,
"",
undefined,
undefined
);
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
console.log(`🔍 파라미터:`, values);
const result = await pool.query(finalQuery, values);
// 🔧 날짜 타입 타임존 문제 해결
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
}
}
return normalized;
});
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
// 🆕 중복 제거 처리
let finalData = normalizedRows;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
finalData = this.deduplicateData(normalizedRows, deduplication);
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`);
}
return {
success: true,
data: finalData,
};
}
} catch (error) {
console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error);
// Entity 조인 실패 시 기본 조인으로 폴백
}
}
// 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시)
let queryText = `
SELECT DISTINCT r.*
FROM "${rightTable}" r
@ -501,9 +868,17 @@ class DataService {
const result = await query<any>(queryText, values);
// 🆕 중복 제거 처리
let finalData = result;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
finalData = this.deduplicateData(result, deduplication);
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`);
}
return {
success: true,
data: result,
data: finalData,
};
} catch (error) {
console.error(
@ -728,6 +1103,284 @@ class DataService {
};
}
}
/**
* ( )
*/
async deleteGroupRecords(
tableName: string,
filterConditions: Record<string, any>
): Promise<ServiceResponse<{ deleted: number }>> {
try {
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
const whereConditions: string[] = [];
const whereValues: any[] = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(filterConditions)) {
whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value);
paramIndex++;
}
if (whereConditions.length === 0) {
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
}
const whereClause = whereConditions.join(" AND ");
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
const result = await pool.query(deleteQuery, whereValues);
console.log(`✅ 그룹 삭제 성공: ${result.rowCount}`);
return { success: true, data: { deleted: result.rowCount || 0 } };
} catch (error) {
console.error("그룹 삭제 오류:", error);
return {
success: false,
message: "그룹 삭제 실패",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* UPSERT
* - (: customer_id, item_id)
* - DB의 INSERT/UPDATE/DELETE
* -
*/
async upsertGroupedRecords(
tableName: string,
parentKeys: Record<string, any>,
records: Array<Record<string, any>>,
userCompany?: string,
userId?: string
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
try {
// 테이블 접근 권한 검증
const validation = await this.validateTableAccess(tableName);
if (!validation.valid) {
return validation.error!;
}
// Primary Key 감지
const pkColumns = await this.getPrimaryKeyColumns(tableName);
if (!pkColumns || pkColumns.length === 0) {
return {
success: false,
message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`,
error: "PRIMARY_KEY_NOT_FOUND",
};
}
const pkColumn = pkColumns[0]; // 첫 번째 PK 사용
console.log(`🔍 UPSERT 시작: ${tableName}`, {
parentKeys,
newRecordsCount: records.length,
primaryKey: pkColumn,
});
// 1. 기존 DB 레코드 조회 (parentKeys 기준)
const whereConditions: string[] = [];
const whereValues: any[] = [];
let paramIndex = 1;
for (const [key, value] of Object.entries(parentKeys)) {
whereConditions.push(`"${key}" = $${paramIndex}`);
whereValues.push(value);
paramIndex++;
}
const whereClause = whereConditions.join(" AND ");
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
const existingRecords = await pool.query(selectQuery, whereValues);
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교
let inserted = 0;
let updated = 0;
let deleted = 0;
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => {
if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split('T')[0]; // YYYY-MM-DD 만 추출
}
return value;
};
// 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) {
// 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value);
}
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord);
// 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => {
const existingValue = existing[field];
const newValue = normalizedRecord[field];
// null/undefined 처리
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
// Date 타입 처리
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
}
// 문자열 비교
return String(existingValue) === String(newValue);
});
});
if (existingRecord) {
// UPDATE: 기존 레코드가 있으면 업데이트
const updateFields: string[] = [];
const updateValues: any[] = [];
let updateParamIndex = 1;
for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`);
updateValues.push(value);
updateParamIndex++;
}
}
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
const updateQuery = `
UPDATE "${tableName}"
SET ${updateFields.join(", ")}, updated_date = NOW()
WHERE "${pkColumn}" = $${updateParamIndex}
`;
await pool.query(updateQuery, updateValues);
updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
} else {
// INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
const recordWithMeta: Record<string, any> = {
...fullRecord,
id: uuidv4(), // 새 ID 생성
created_date: "NOW()",
updated_date: "NOW()",
};
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
recordWithMeta.company_code = userCompany;
}
// writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId;
}
const insertFields = Object.keys(recordWithMeta).filter(key =>
recordWithMeta[key] !== "NOW()"
);
const insertPlaceholders: string[] = [];
const insertValues: any[] = [];
let insertParamIndex = 1;
for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()");
} else {
insertPlaceholders.push(`$${insertParamIndex}`);
insertValues.push(recordWithMeta[field]);
insertParamIndex++;
}
}
const insertQuery = `
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
VALUES (${insertPlaceholders.join(", ")})
`;
console.log(` INSERT 쿼리:`, { query: insertQuery, values: insertValues });
await pool.query(insertQuery, insertValues);
inserted++;
console.log(` INSERT: 새 레코드`);
}
}
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => {
const existingValue = existingRecord[field];
const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
}
return String(existingValue) === String(newValue);
});
});
if (!stillExists) {
// DELETE: 새 레코드에 없으면 삭제
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
}
}
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
return {
success: true,
data: { inserted, updated, deleted },
};
} catch (error) {
console.error(`UPSERT 오류 (${tableName}):`, error);
return {
success: false,
message: "데이터 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
export const dataService = new DataService();

View File

@ -81,18 +81,18 @@ export class EntityJoinService {
let referenceColumn = column.reference_column;
let displayColumn = column.display_column;
if (column.input_type === 'category') {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || 'table_column_category_values';
referenceColumn = referenceColumn || 'value_code';
displayColumn = displayColumn || 'value_label';
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
if (column.input_type === "category") {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || "table_column_category_values";
referenceColumn = referenceColumn || "value_code";
displayColumn = displayColumn || "value_label";
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
@ -200,6 +200,25 @@ export class EntityJoinService {
}
}
/**
* YYYY-MM-DD SQL
*/
private formatDateColumn(
tableAlias: string,
columnName: string,
dataType?: string
): string {
// date, timestamp 타입이면 TO_CHAR로 변환
if (
dataType &&
(dataType.includes("date") || dataType.includes("timestamp"))
) {
return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`;
}
// 기본은 TEXT 캐스팅
return `${tableAlias}.${columnName}::TEXT`;
}
/**
* Entity SQL
*/
@ -210,13 +229,30 @@ export class EntityJoinService {
whereClause: string = "",
orderBy: string = "",
limit?: number,
offset?: number
offset?: number,
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
): { query: string; aliasMap: Map<string, string> } {
try {
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
const baseColumns = selectColumns
.map((col) => `main.${col}::TEXT AS ${col}`)
.join(", ");
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
// 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해
// jsonb_build_object를 사용하여 명시적으로 변환
let baseColumns: string;
if (selectColumns.length === 1 && selectColumns[0] === "*") {
// main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환
// PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지
baseColumns = `main.*`;
logger.info(
`⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요`
);
} else {
baseColumns = selectColumns
.map((col) => {
const dataType = columnTypes?.get(col);
const formattedCol = this.formatDateColumn("main", col, dataType);
return `${formattedCol} AS ${col}`;
})
.join(", ");
}
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
@ -255,7 +291,9 @@ export class EntityJoinService {
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`);
logger.info(
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`
);
});
const joinColumns = joinConfigs
@ -266,64 +304,55 @@ export class EntityJoinService {
config.displayColumn,
];
const separator = config.separator || " - ";
// 결과 컬럼 배열 (aliasColumn + _label 필드)
const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
const isJoinTableColumn = [
"dept_name",
"dept_code",
"master_user_id",
"location_name",
"parent_dept_code",
"master_sabun",
"location",
"data_type",
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (isJoinTableColumn) {
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
);
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
);
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
// 예: customer_code, item_number 등
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
} else {
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
);
}
} else {
// 여러 컬럼인 경우 CONCAT으로 연결
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
const concatParts = displayColumns
.map((col) => {
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
// 현재는 dept_info 테이블의 컬럼들을 확인
const isJoinTableColumn = [
"dept_name",
"dept_code",
"master_user_id",
"location_name",
"parent_dept_code",
"master_sabun",
"location",
"data_type",
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용
@ -336,8 +365,20 @@ export class EntityJoinService {
.join(` || '${separator}' || `);
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (
isJoinTableColumn &&
!displayColumns.includes(config.referenceColumn)
) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
}
}
// 모든 resultColumns를 반환
return resultColumns.join(", ");
})
@ -356,13 +397,13 @@ export class EntityJoinService {
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
@ -424,7 +465,7 @@ export class EntityJoinService {
}
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === 'table_column_category_values') {
if (config.referenceTable === "table_column_category_values") {
logger.info(
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
@ -578,13 +619,13 @@ export class EntityJoinService {
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");

View File

@ -98,7 +98,8 @@ export class ScreenManagementService {
async getScreensByCompany(
companyCode: string,
page: number = 1,
size: number = 20
size: number = 20,
searchTerm?: string // 검색어 추가
): Promise<PaginatedResponse<ScreenDefinition>> {
const offset = (page - 1) * size;
@ -111,6 +112,16 @@ export class ScreenManagementService {
params.push(companyCode);
}
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
if (searchTerm && searchTerm.trim() !== "") {
whereConditions.push(`(
screen_name ILIKE $${params.length + 1} OR
screen_code ILIKE $${params.length + 1} OR
table_name ILIKE $${params.length + 1}
)`);
params.push(`%${searchTerm.trim()}%`);
}
const whereSQL = whereConditions.join(" AND ");
// 페이징 쿼리 (Raw Query)
@ -1068,43 +1079,131 @@ export class ScreenManagementService {
[tableName]
);
// column_labels 테이블에서 입력타입 정보 조회 (있는 경우)
const webTypeInfo = await query<{
// 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음)
// 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리
console.log(`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`);
const typeInfo = await query<{
column_name: string;
input_type: string | null;
column_label: string | null;
detail_settings: any;
}>(
`SELECT column_name, input_type, column_label, detail_settings
`SELECT column_name, input_type, detail_settings
FROM table_type_columns
WHERE table_name = $1
AND company_code = $2
ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지)
[tableName, companyCode]
);
console.log(`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}`);
const currencyCodeType = typeInfo.find(t => t.column_name === 'currency_code');
if (currencyCodeType) {
console.log(`💰 [getTableColumns] currency_code 발견:`, currencyCodeType);
} else {
console.log(`⚠️ [getTableColumns] currency_code 없음`);
}
// column_labels 테이블에서 라벨 정보 조회 (우선순위 2)
const labelInfo = await query<{
column_name: string;
column_label: string | null;
}>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
// 컬럼 정보 매핑
return columns.map((column: any) => {
const webTypeData = webTypeInfo.find(
(wt) => wt.column_name === column.column_name
);
// 🆕 category_column_mapping에서 코드 카테고리 정보 조회
const categoryInfo = await query<{
physical_column_name: string;
logical_column_name: string;
}>(
`SELECT physical_column_name, logical_column_name
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
return {
// 컬럼 정보 매핑
const columnMap = new Map<string, any>();
// 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성
columns.forEach((column: any) => {
columnMap.set(column.column_name, {
tableName: tableName,
columnName: column.column_name,
columnLabel:
webTypeData?.column_label ||
this.getColumnLabel(column.column_name),
dataType: column.data_type,
webType:
(webTypeData?.input_type as WebType) ||
this.inferWebType(column.data_type),
isNullable: column.is_nullable,
columnDefault: column.column_default || undefined,
characterMaximumLength: column.character_maximum_length || undefined,
numericPrecision: column.numeric_precision || undefined,
numericScale: column.numeric_scale || undefined,
detailSettings: webTypeData?.detail_settings || undefined,
};
});
});
console.log(`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}`);
// table_type_columns에서 input_type 추가 (중복 시 최신 것만)
const addedTypes = new Set<string>();
typeInfo.forEach((type) => {
const colName = type.column_name;
if (!addedTypes.has(colName) && columnMap.has(colName)) {
const col = columnMap.get(colName);
col.inputType = type.input_type;
col.webType = type.input_type; // webType도 동일하게 설정
col.detailSettings = type.detail_settings;
addedTypes.add(colName);
if (colName === 'currency_code') {
console.log(`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`);
}
}
});
console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}`);
// column_labels에서 라벨 추가
labelInfo.forEach((label) => {
const col = columnMap.get(label.column_name);
if (col) {
col.columnLabel = label.column_label || this.getColumnLabel(label.column_name);
}
});
// category_column_mapping에서 코드 카테고리 추가
categoryInfo.forEach((cat) => {
const col = columnMap.get(cat.physical_column_name);
if (col) {
col.codeCategory = cat.logical_column_name;
}
});
// 최종 결과 생성
const result = Array.from(columnMap.values()).map((col) => ({
...col,
// 기본값 설정
columnLabel: col.columnLabel || this.getColumnLabel(col.columnName),
inputType: col.inputType || this.inferWebType(col.dataType),
webType: col.webType || this.inferWebType(col.dataType),
detailSettings: col.detailSettings || undefined,
codeCategory: col.codeCategory || undefined,
}));
// 디버깅: currency_code의 최종 inputType 확인
const currencyCodeResult = result.find(r => r.columnName === 'currency_code');
if (currencyCodeResult) {
console.log(`🎯 [getTableColumns] 최종 currency_code:`, {
inputType: currencyCodeResult.inputType,
webType: currencyCodeResult.webType,
dataType: currencyCodeResult.dataType
});
}
console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`);
return result;
} catch (error) {
console.error("테이블 컬럼 조회 실패:", error);
throw new Error("테이블 컬럼 정보를 조회할 수 없습니다.");
@ -2013,55 +2112,109 @@ export class ScreenManagementService {
}
/**
*
* popup targetScreenId를
* /
* - 컴포넌트: popup/modal/edit/openModalWithData targetScreenId
* - 컨테이너: sections[].screenId ( )
* - ()
*/
async detectLinkedModalScreens(
screenId: number
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId]
);
console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`);
const allLinkedScreenIds = new Set<number>();
const visited = new Set<number>(); // 무한 루프 방지
const queue: number[] = [screenId]; // BFS 큐
const linkedScreenIds = new Set<number>();
// BFS로 연결된 모든 화면 탐색
while (queue.length > 0) {
const currentScreenId = queue.shift()!;
// 이미 방문한 화면은 스킵 (순환 참조 방지)
if (visited.has(currentScreenId)) {
console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`);
continue;
}
visited.add(currentScreenId);
console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`);
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// 버튼 컴포넌트인지 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
// 현재 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[currentScreenId]
);
console.log(` 📦 레이아웃 개수: ${layouts.length}`);
// 각 레이아웃에서 연결된 화면 ID 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
// edit 액션도 수정 폼 모달을 열기 때문에 포함
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId)) {
linkedScreenIds.add(targetScreenId);
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
// 1. 버튼 컴포넌트의 액션 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
if (modalActionTypes.includes(action?.type) && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) {
// 메인 화면이 아닌 경우에만 추가
if (targetScreenId !== screenId) {
allLinkedScreenIds.add(targetScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(targetScreenId)) {
queue.push(targetScreenId);
console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`);
}
}
}
}
// 2. conditional-container 컴포넌트의 sections 확인
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const sectionScreenId = parseInt(section.screenId);
if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) {
// 메인 화면이 아닌 경우에만 추가
if (sectionScreenId !== screenId) {
allLinkedScreenIds.add(sectionScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(sectionScreenId)) {
queue.push(sectionScreenId);
console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`);
}
}
}
}
}
} catch (error) {
console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
} catch (error) {
// JSON 파싱 오류 등은 무시하고 계속 진행
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
}
console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}`);
console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`);
console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`);
// 감지된 화면 ID들의 정보 조회
if (linkedScreenIds.size === 0) {
if (allLinkedScreenIds.size === 0) {
console.log(` 연결된 화면이 없습니다.`);
return [];
}
const screenIds = Array.from(linkedScreenIds);
const screenIds = Array.from(allLinkedScreenIds);
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
const linkedScreens = await query<any>(
@ -2073,6 +2226,11 @@ export class ScreenManagementService {
screenIds
);
console.log(`\n📋 최종 감지된 화면 목록:`);
linkedScreens.forEach((s: any) => {
console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`);
});
return linkedScreens.map((s) => ({
screenId: s.screen_id,
screenName: s.screen_name,
@ -2342,23 +2500,23 @@ export class ScreenManagementService {
for (const layout of layouts) {
try {
const properties = layout.properties;
let needsUpdate = false;
// 버튼 컴포넌트인지 확인
// 1. 버튼 컴포넌트의 targetScreenId 업데이트
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
// targetScreenId가 있는 액션 (popup, modal, edit)
// targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData)
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
if (
(action?.type === "popup" ||
action?.type === "modal" ||
action?.type === "edit") &&
modalActionTypes.includes(action?.type) &&
action?.targetScreenId
) {
const oldScreenId = parseInt(action.targetScreenId);
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
@ -2368,31 +2526,63 @@ export class ScreenManagementService {
// properties 업데이트
properties.componentConfig.action.targetScreenId =
newScreenId.toString();
needsUpdate = true;
// 데이터베이스 업데이트
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
`🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
// 2. conditional-container 컴포넌트의 sections[].screenId 업데이트
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const oldScreenId = parseInt(section.screenId);
console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// section.screenId 업데이트
section.screenId = newScreenId;
needsUpdate = true;
console.log(
`🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
}
// 3. 업데이트가 필요한 경우 DB 저장
if (needsUpdate) {
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`);
}
} catch (error) {
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
// 개별 레이아웃 오류는 무시하고 계속 진행
}
}
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
console.log(`✅ 총 ${updateCount}레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`);
return updateCount;
}
}

View File

@ -445,7 +445,129 @@ class TableCategoryValueService {
}
/**
* ()
*
*
*/
async checkCategoryValueUsage(
valueId: number,
companyCode: string
): Promise<{ isUsed: boolean; usedInTables: any[]; totalCount: number }> {
const pool = getPool();
try {
logger.info("카테고리 값 사용 여부 확인", { valueId, companyCode });
// 1. 카테고리 값 정보 조회
let valueQuery: string;
let valueParams: any[];
if (companyCode === "*") {
valueQuery = `
SELECT table_name, column_name, value_code
FROM table_column_category_values
WHERE value_id = $1
`;
valueParams = [valueId];
} else {
valueQuery = `
SELECT table_name, column_name, value_code
FROM table_column_category_values
WHERE value_id = $1
AND company_code = $2
`;
valueParams = [valueId, companyCode];
}
const valueResult = await pool.query(valueQuery, valueParams);
if (valueResult.rowCount === 0) {
throw new Error("카테고리 값을 찾을 수 없습니다");
}
const { table_name, column_name, value_code } = valueResult.rows[0];
// 2. 실제 데이터 테이블에서 사용 여부 확인
// 테이블이 존재하는지 먼저 확인
const tableExistsQuery = `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
) as exists
`;
const tableExistsResult = await pool.query(tableExistsQuery, [table_name]);
if (!tableExistsResult.rows[0].exists) {
logger.info("테이블이 존재하지 않음", { table_name });
return { isUsed: false, usedInTables: [], totalCount: 0 };
}
// 3. 해당 테이블에서 value_code를 사용하는 데이터 개수 확인
let dataCountQuery: string;
let dataCountParams: any[];
if (companyCode === "*") {
dataCountQuery = `
SELECT COUNT(*) as count
FROM ${table_name}
WHERE ${column_name} = $1
`;
dataCountParams = [value_code];
} else {
dataCountQuery = `
SELECT COUNT(*) as count
FROM ${table_name}
WHERE ${column_name} = $1
AND company_code = $2
`;
dataCountParams = [value_code, companyCode];
}
const dataCountResult = await pool.query(dataCountQuery, dataCountParams);
const totalCount = parseInt(dataCountResult.rows[0].count);
const isUsed = totalCount > 0;
// 4. 사용 중인 메뉴 목록 조회 (해당 테이블을 사용하는 화면/메뉴)
const menuQuery = `
SELECT DISTINCT
mi.objid as menu_objid,
mi.menu_name_kor as menu_name,
mi.menu_url
FROM menu_info mi
INNER JOIN screen_menu_assignments sma ON sma.menu_objid = mi.objid
INNER JOIN screen_definitions sd ON sd.screen_id = sma.screen_id
WHERE sd.table_name = $1
AND mi.company_code = $2
ORDER BY mi.menu_name_kor
`;
const menuResult = await pool.query(menuQuery, [table_name, companyCode]);
const usedInTables = menuResult.rows.map((row) => ({
menuObjid: row.menu_objid,
menuName: row.menu_name,
menuUrl: row.menu_url,
tableName: table_name,
columnName: column_name,
}));
logger.info("카테고리 값 사용 여부 확인 완료", {
valueId,
isUsed,
totalCount,
usedInMenusCount: usedInTables.length,
});
return { isUsed, usedInTables, totalCount };
} catch (error: any) {
logger.error(`카테고리 값 사용 여부 확인 실패: ${error.message}`);
throw error;
}
}
/**
* ( )
*/
async deleteCategoryValue(
valueId: number,
@ -455,7 +577,24 @@ class TableCategoryValueService {
const pool = getPool();
try {
// 하위 값 체크 (멀티테넌시 적용)
// 1. 사용 여부 확인
const usage = await this.checkCategoryValueUsage(valueId, companyCode);
if (usage.isUsed) {
let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n";
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
if (usage.usedInTables.length > 0) {
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
}
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
}
// 2. 하위 값 체크 (멀티테넌시 적용)
let checkQuery: string;
let checkParams: any[];
@ -465,7 +604,6 @@ class TableCategoryValueService {
SELECT COUNT(*) as count
FROM table_column_category_values
WHERE parent_value_id = $1
AND is_active = true
`;
checkParams = [valueId];
} else {
@ -475,7 +613,6 @@ class TableCategoryValueService {
FROM table_column_category_values
WHERE parent_value_id = $1
AND company_code = $2
AND is_active = true
`;
checkParams = [valueId, companyCode];
}
@ -486,27 +623,25 @@ class TableCategoryValueService {
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
}
// 비활성화 (멀티테넌시 적용)
// 3. 물리적 삭제 (멀티테넌시 적용)
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 삭제 가능
deleteQuery = `
UPDATE table_column_category_values
SET is_active = false, updated_at = NOW(), updated_by = $2
DELETE FROM table_column_category_values
WHERE value_id = $1
`;
deleteParams = [valueId, userId];
deleteParams = [valueId];
} else {
// 일반 회사: 자신의 카테고리 값만 삭제 가능
deleteQuery = `
UPDATE table_column_category_values
SET is_active = false, updated_at = NOW(), updated_by = $3
DELETE FROM table_column_category_values
WHERE value_id = $1
AND company_code = $2
`;
deleteParams = [valueId, companyCode, userId];
deleteParams = [valueId, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
@ -515,7 +650,7 @@ class TableCategoryValueService {
throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다");
}
logger.info("카테고리 값 삭제(비활성화) 완료", {
logger.info("카테고리 값 삭제 완료", {
valueId,
companyCode,
});

View File

@ -144,6 +144,19 @@ export class TableManagementService {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
}
return cachedResult;
}
@ -174,6 +187,8 @@ export class TableManagementService {
c.data_type as "dbType",
COALESCE(cl.input_type, 'text') as "webType",
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
ttc.input_type as "ttc_input_type",
cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
@ -782,8 +797,13 @@ export class TableManagementService {
]
);
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
cache.delete(cacheKeyPattern);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} (캐시 무효화 완료)`
);
} catch (error) {
logger.error(

View File

@ -6,9 +6,28 @@
export interface ColumnFilter {
id: string;
columnName: string;
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
operator:
| "equals"
| "not_equals"
| "in"
| "not_in"
| "contains"
| "starts_with"
| "ends_with"
| "is_null"
| "is_not_null"
| "greater_than"
| "less_than"
| "greater_than_or_equal"
| "less_than_or_equal"
| "between"
| "date_range_contains";
value: string | string[];
valueType: "static" | "category" | "code";
valueType: "static" | "category" | "code" | "dynamic";
rangeConfig?: {
startColumn: string;
endColumn: string;
};
}
export interface DataFilterConfig {
@ -123,6 +142,71 @@ export function buildDataFilterWhereClause(
conditions.push(`${columnRef} IS NOT NULL`);
break;
case "greater_than":
conditions.push(`${columnRef} > $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "less_than":
conditions.push(`${columnRef} < $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "greater_than_or_equal":
conditions.push(`${columnRef} >= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "less_than_or_equal":
conditions.push(`${columnRef} <= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "between":
if (Array.isArray(value) && value.length === 2) {
conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
params.push(value[0], value[1]);
paramIndex += 2;
}
break;
case "date_range_contains":
// 날짜 범위 포함: start_date <= value <= end_date
// filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" }
// NULL 처리:
// - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속)
// - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속)
// - 둘 다 있으면: start_date <= value <= end_date
if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) {
const startCol = getColumnRef(filter.rangeConfig.startColumn);
const endCol = getColumnRef(filter.rangeConfig.endColumn);
// value가 "TODAY"면 현재 날짜로 변환
const actualValue = filter.valueType === "dynamic" && value === "TODAY"
? "CURRENT_DATE"
: `$${paramIndex}`;
if (actualValue === "CURRENT_DATE") {
// CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함
// NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE)
conditions.push(
`((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))`
);
} else {
// NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param)
conditions.push(
`((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))`
);
params.push(value);
paramIndex++;
}
}
break;
default:
// 알 수 없는 연산자는 무시
break;

View File

@ -0,0 +1,382 @@
# 기간별 단가 설정 시스템 구현 가이드
## 개요
**선택항목 상세입력(selected-items-detail-input)** 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다.
## 데이터베이스 설계
### 1. 마이그레이션 실행
```bash
# 마이그레이션 파일 위치
db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql
# 실행 (로컬)
npm run migrate:local
# 또는 수동 실행
psql -U your_user -d erp_db -f db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql
```
### 2. 추가된 컬럼들
| 컬럼명 | 타입 | 설명 | 사진 항목 |
|--------|------|------|-----------|
| `start_date` | DATE | 기간 시작일 | ✅ 시작일 DatePicker |
| `end_date` | DATE | 기간 종료일 | ✅ 종료일 DatePicker |
| `discount_type` | VARCHAR(50) | 할인 방식 | ✅ 할인율/할인금액 Select |
| `discount_value` | NUMERIC(15,2) | 할인율 또는 할인금액 | ✅ 숫자 입력 |
| `rounding_type` | VARCHAR(50) | 반올림 방식 | ✅ 반올림/절삭/올림 Select |
| `rounding_unit_value` | VARCHAR(50) | 반올림 단위 | ✅ 1원/10원/100원/1,000원 Select |
| `calculated_price` | NUMERIC(15,2) | 계산된 최종 단가 | ✅ 계산 결과 표시 |
| `is_base_price` | BOOLEAN | 기준단가 여부 | ✅ 기준단가 Checkbox |
## 화면 편집기 설정 방법
### Step 1: 선택항목 상세입력 컴포넌트 추가
1. 화면 편집기에서 "선택항목 상세입력" 컴포넌트를 캔버스에 드래그앤드롭
2. 컴포넌트 ID: `customer-item-price-periods`
### Step 2: 데이터 소스 설정
- **원본 데이터 테이블**: `item_info` (품목 정보)
- **저장 대상 테이블**: `customer_item_mapping`
- **데이터 소스 ID**: URL 파라미터에서 자동 설정 (Button 컴포넌트가 전달)
### Step 3: 표시할 원본 데이터 컬럼 설정
이전 화면(품목 선택 모달)에서 전달받은 품목 정보를 표시:
```
컬럼1: item_code (품목코드)
컬럼2: item_name (품목명)
컬럼3: spec (규격)
```
### Step 4: 필드 그룹 2개 생성
#### 그룹 1: 거래처 품목/품명 관리 (group_customer)
| 필드명 | 라벨 | 타입 | 설명 |
|--------|------|------|------|
| `customer_item_code` | 거래처 품번 | text | 거래처에서 사용하는 품번 |
| `customer_item_name` | 거래처 품명 | text | 거래처에서 사용하는 품명 |
#### 그룹 2: 기간별 단가 설정 (group_period_price)
| 필드명 | 라벨 | 타입 | 자동 채우기 | 설명 |
|--------|------|------|-------------|------|
| `start_date` | 시작일 | date | - | 단가 적용 시작일 |
| `end_date` | 종료일 | date | - | 단가 적용 종료일 (NULL이면 무기한) |
| `current_unit_price` | 단가 | number | `item_info.standard_price` | 기본 단가 (품목에서 자동 채우기) |
| `currency_code` | 통화 | code/category | - | 통화 코드 (KRW, USD 등) |
| `discount_type` | 할인 방식 | code/category | - | 할인율없음/할인율(%)/할인금액 |
| `discount_value` | 할인값 | number | - | 할인율(5) 또는 할인금액 |
| `rounding_type` | 반올림 방식 | code/category | - | 반올림없음/반올림/절삭/올림 |
| `rounding_unit_value` | 반올림 단위 | code/category | - | 1원/10원/100원/1,000원 |
| `calculated_price` | 최종 단가 | number | - | 계산된 최종 단가 (읽기 전용) |
| `is_base_price` | 기준단가 | checkbox | - | 기준단가 여부 |
### Step 5: 그룹별 표시 항목 설정 (DisplayItems)
**그룹 2 (기간별 단가 설정)의 표시 설정:**
```
1. [필드] start_date | 라벨: "" | 형식: date | 빈 값: 기본값 (미설정)
2. [텍스트] " ~ "
3. [필드] end_date | 라벨: "" | 형식: date | 빈 값: 기본값 (무기한)
4. [텍스트] " | "
5. [필드] calculated_price | 라벨: "" | 형식: currency | 빈 값: 기본값 (계산 중)
6. [텍스트] " "
7. [필드] currency_code | 라벨: "" | 형식: text | 빈 값: 기본값 (KRW)
8. [조건] is_base_price가 true이면 → [배지] "기준단가" (variant: default)
```
**렌더링 예시:**
```
2024-01-01 ~ 2024-06-30 | 50,000 KRW [기준단가]
2024-07-01 ~ 무기한 | 55,000 KRW
```
## 데이터 흐름
### 1. 품목 선택 모달 (이전 화면)
```tsx
// TableList 컴포넌트에서 품목 선택
<Button
onClick={() => {
const selectedItems = tableData.filter(item => selectedRowIds.includes(item.id));
// modalDataStore에 데이터 저장
useModalDataStore.getState().setData("item_info", selectedItems);
// 다음 화면으로 이동 (dataSourceId 전달)
router.push("/screen/period-price-settings?dataSourceId=item_info");
}}
>
다음
</Button>
```
### 2. 기간별 단가 설정 화면
```tsx
// 선택항목 상세입력 컴포넌트가 자동으로 처리
// 1. URL 파라미터에서 dataSourceId 읽기
// 2. modalDataStore에서 item_info 데이터 가져오기
// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력
// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장
```
### 3. 저장 데이터 구조
**하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:**
```sql
-- customer_item_mapping 테이블에 3개의 행으로 저장
INSERT INTO customer_item_mapping (
customer_id, item_id,
customer_item_code, customer_item_name,
start_date, end_date,
current_unit_price, currency_code,
discount_type, discount_value,
rounding_type, rounding_unit_value,
calculated_price, is_base_price
) VALUES
-- 첫 번째 기간 (기준단가)
('CUST001', 'ITEM001',
'CUST-A-001', '실리콘 고무 시트',
'2024-01-01', '2024-06-30',
50000, 'KRW',
'할인율없음', 0,
'반올림', '100원',
50000, true),
-- 두 번째 기간
('CUST001', 'ITEM001',
'CUST-A-001', '실리콘 고무 시트',
'2024-07-01', '2024-12-31',
50000, 'KRW',
'할인율(%)', 5,
'절삭', '1원',
47500, false),
-- 세 번째 기간 (무기한)
('CUST001', 'ITEM001',
'CUST-A-001', '실리콘 고무 시트',
'2025-01-01', NULL,
50000, 'KRW',
'할인금액', 3000,
'올림', '1000원',
47000, false);
```
## 계산 로직 (선택사항)
단가 계산을 자동화하려면 프론트엔드에서 `calculated_price`를 자동 계산:
```typescript
const calculatePrice = (
basePrice: number,
discountType: string,
discountValue: number,
roundingType: string,
roundingUnit: string
): number => {
let price = basePrice;
// 1단계: 할인 적용
if (discountType === "할인율(%)") {
price = price * (1 - discountValue / 100);
} else if (discountType === "할인금액") {
price = price - discountValue;
}
// 2단계: 반올림 적용
const unitMap: Record<string, number> = {
"1원": 1,
"10원": 10,
"100원": 100,
"1,000원": 1000,
};
const unit = unitMap[roundingUnit] || 1;
if (roundingType === "반올림") {
price = Math.round(price / unit) * unit;
} else if (roundingType === "절삭") {
price = Math.floor(price / unit) * unit;
} else if (roundingType === "올림") {
price = Math.ceil(price / unit) * unit;
}
return price;
};
// 필드 변경 시 자동 계산
useEffect(() => {
const calculatedPrice = calculatePrice(
basePrice,
discountType,
discountValue,
roundingType,
roundingUnit
);
// calculated_price 필드 업데이트
handleFieldChange(itemId, groupId, entryId, "calculated_price", calculatedPrice);
}, [basePrice, discountType, discountValue, roundingType, roundingUnit]);
```
## 백엔드 API 구현 (필요시)
### 기간별 단가 조회
```typescript
// GET /api/customer-item/price-periods?customer_id=CUST001&item_id=ITEM001
router.get("/price-periods", async (req, res) => {
const { customer_id, item_id } = req.query;
const companyCode = req.user!.companyCode;
const query = `
SELECT * FROM customer_item_mapping
WHERE customer_id = $1
AND item_id = $2
AND company_code = $3
ORDER BY start_date ASC
`;
const result = await pool.query(query, [customer_id, item_id, companyCode]);
return res.json({ success: true, data: result.rows });
});
```
### 기간별 단가 저장
```typescript
// POST /api/customer-item/price-periods
router.post("/price-periods", async (req, res) => {
const { items } = req.body; // 선택항목 상세입력 컴포넌트에서 전달
const companyCode = req.user!.companyCode;
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const item of items) {
// item.fieldGroups.group_period_price 배열의 각 항목을 INSERT
const periodPrices = item.fieldGroups.group_period_price || [];
for (const periodPrice of periodPrices) {
const query = `
INSERT INTO customer_item_mapping (
company_code, customer_id, item_id,
customer_item_code, customer_item_name,
start_date, end_date,
current_unit_price, currency_code,
discount_type, discount_value,
rounding_type, rounding_unit_value,
calculated_price, is_base_price
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`;
await client.query(query, [
companyCode,
item.originalData.customer_id,
item.originalData.item_id,
periodPrice.customer_item_code,
periodPrice.customer_item_name,
periodPrice.start_date,
periodPrice.end_date || null,
periodPrice.current_unit_price,
periodPrice.currency_code,
periodPrice.discount_type,
periodPrice.discount_value,
periodPrice.rounding_type,
periodPrice.rounding_unit_value,
periodPrice.calculated_price,
periodPrice.is_base_price
]);
}
}
await client.query("COMMIT");
return res.json({ success: true, message: "기간별 단가가 저장되었습니다." });
} catch (error) {
await client.query("ROLLBACK");
console.error("기간별 단가 저장 실패:", error);
return res.status(500).json({ success: false, error: "저장 실패" });
} finally {
client.release();
}
});
```
## 사용 시나리오 예시
### 시나리오 1: 거래처별 단가 관리
1. 거래처 선택 모달 → 거래처 선택 → 다음
2. 품목 선택 모달 → 품목 여러 개 선택 → 다음
3. **기간별 단가 설정 화면**
- 품목1 (실리콘 고무 시트)
- **그룹1 추가**: 거래처 품번: CUST-A-001, 품명: 실리콘 시트
- **그룹2 추가**: 2024-01-01 ~ 2024-06-30, 50,000원 (기준단가)
- **그룹2 추가**: 2024-07-01 ~ 무기한, 할인율 5% → 47,500원
- 품목2 (스테인리스 판)
- **그룹1 추가**: 거래처 품번: CUST-A-002, 품명: SUS304 판
- **그룹2 추가**: 2024-01-01 ~ 무기한, 150,000원 (기준단가)
4. 저장 버튼 클릭 → customer_item_mapping 테이블에 4개 행 저장
### 시나리오 2: 단순 단가 입력
필드 그룹을 사용하지 않고 단일 입력도 가능:
```
그룹 없이 필드 정의:
- customer_item_code
- customer_item_name
- current_unit_price
- currency_code
→ 각 품목당 1개의 행만 저장
```
## 장점
### 1. 범용성
- 기간별 단가뿐만 아니라 **모든 숫자 계산 시나리오**에 적용 가능
- 견적서, 발주서, 판매 단가, 구매 단가 등
### 2. 유연성
- 필드 그룹으로 자유롭게 섹션 구성
- 표시 항목 설정으로 UI 커스터마이징
### 3. 데이터 무결성
- 1:N 관계로 여러 기간별 데이터 관리
- 기간 중복 체크는 백엔드에서 처리
### 4. 사용자 경험
- 품목별로 여러 개의 기간별 단가를 손쉽게 입력
- 입력 완료 후 작은 카드로 요약 표시
## 다음 단계
1. **마이그레이션 실행** (999_add_period_price_columns_to_customer_item_mapping.sql)
2. **화면 편집기에서 설정** (위 Step 1~5 참고)
3. **백엔드 API 구현** (저장/조회 엔드포인트)
4. **계산 로직 추가** (선택사항: 자동 계산)
5. **테스트** (품목 선택 → 기간별 단가 입력 → 저장 → 조회)
## 참고 자료
- 선택항목 상세입력 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/`
- 타입 정의: `frontend/lib/registry/components/selected-items-detail-input/types.ts`
- 설정 패널: `SelectedItemsDetailInputConfigPanel.tsx`

View File

@ -0,0 +1,185 @@
# Modal Repeater Table 디버깅 가이드
## 📊 콘솔 로그 확인 순서
새로고침 후 수주 등록 모달을 열고, 아래 순서대로 콘솔 로그를 확인하세요:
### 1⃣ 컴포넌트 마운트 (초기 로드)
```
🎬 ModalRepeaterTableComponent 마운트: {
config: {...},
propColumns: [...],
columns: [...],
columnsLength: N, // ⚠️ 0이면 문제!
value: [],
valueLength: 0,
sourceTable: "item_info",
sourceColumns: [...],
uniqueField: "item_number"
}
```
**✅ 정상:**
- `columnsLength: 8` (품번, 품명, 규격, 재질, 수량, 단가, 금액, 납기일)
- `columns` 배열에 각 컬럼의 `field`, `label`, `type` 정보가 있어야 함
**❌ 문제:**
- `columnsLength: 0` → **이것이 문제의 원인!**
- 빈 배열이면 테이블에 컬럼이 표시되지 않음
---
### 2⃣ 항목 검색 모달 열림
```
🚪 모달 열림 - uniqueField: "item_number", multiSelect: true
```
---
### 3⃣ 품목 체크 (선택)
```
🖱️ 행 클릭: {
item: { item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... },
uniqueField: "item_number",
itemValue: "SLI-2025-0003",
currentSelected: 0,
selectedValues: []
}
✅ 매칭 발견: { selectedValue: "SLI-2025-0003", itemValue: "SLI-2025-0003", uniqueField: "item_number" }
```
---
### 4⃣ 추가 버튼 클릭
```
✅ ItemSelectionModal 추가 버튼 클릭: {
selectedCount: 1,
selectedItems: [{ item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }],
uniqueField: "item_number"
}
```
---
### 5⃣ 데이터 추가 처리
```
handleAddItems 호출: {
selectedItems: [{ item_number: "SLI-2025-0003", ... }],
currentValue: [],
columns: [...], // ⚠️ 여기도 확인!
calculationRules: [...]
}
📝 기본값 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, ... }]
🔢 계산 필드 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
✅ 최종 데이터 (onChange 호출): [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
```
---
### 6⃣ Renderer 업데이트
```
🔄 ModalRepeaterTableRenderer onChange 호출: {
previousValue: [],
newValue: [{ item_number: "SLI-2025-0003", ... }]
}
```
---
### 7⃣ value 변경 감지
```
📦 ModalRepeaterTableComponent value 변경: {
valueLength: 1,
value: [{ item_number: "SLI-2025-0003", ... }],
columns: [...] // ⚠️ 여기도 확인!
}
```
---
### 8⃣ 테이블 리렌더링
```
📊 RepeaterTable 데이터 업데이트: {
rowCount: 1,
data: [{ item_number: "SLI-2025-0003", ... }],
columns: ["item_number", "item_name", "specification", "material", "quantity", "selling_price", "amount", "delivery_date"]
}
```
---
## 🔍 문제 진단
### Case 1: columns가 비어있음 (columnsLength: 0)
**원인:**
- 화면관리 시스템에서 modal-repeater-table 컴포넌트의 `columns` 설정을 하지 않음
- DB에 컬럼 설정이 저장되지 않음
**해결:**
1. 화면 관리 페이지로 이동
2. 해당 화면 편집
3. modal-repeater-table 컴포넌트 선택
4. 우측 설정 패널에서 "컬럼 설정" 탭 열기
5. 다음 컬럼들을 추가:
- 품번 (item_number, text, 편집불가)
- 품명 (item_name, text, 편집불가)
- 규격 (specification, text, 편집불가)
- 재질 (material, text, 편집불가)
- 수량 (quantity, number, 편집가능, 기본값: 1)
- 단가 (selling_price, number, 편집가능)
- 금액 (amount, number, 편집불가, 계산필드)
- 납기일 (delivery_date, date, 편집가능)
6. 저장
---
### Case 2: 로그가 8번까지 나오는데 화면에 안 보임
**원인:**
- React 리렌더링 문제
- 화면관리 시스템의 상태 동기화 문제
**해결:**
1. 브라우저 개발자 도구 → Elements 탭
2. `#component-comp_5jdmuzai .border.rounded-md table tbody` 찾기
3. 실제 DOM에 `<tr>` 요소가 추가되었는지 확인
4. 추가되었다면 CSS 문제 (display: none 등)
5. 추가 안 되었다면 컴포넌트 렌더링 문제
---
### Case 3: 로그가 5번까지만 나오고 멈춤
**원인:**
- `onChange` 콜백이 제대로 전달되지 않음
- Renderer의 `updateComponent`가 작동하지 않음
**해결:**
- 이미 수정한 `ModalRepeaterTableRenderer.tsx` 코드 확인
- `handleChange` 함수가 호출되는지 확인
---
## 📝 다음 단계
위 로그를 **모두** 복사해서 공유해주세요. 특히:
1. **🎬 마운트 로그의 `columnsLength` 값**
2. **로그가 어디까지 출력되는지**
3. **Elements 탭에서 `tbody` 내부 HTML 구조**
이 정보로 정확한 문제를 진단할 수 있습니다!

View File

@ -65,6 +65,9 @@ function ScreenViewPage() {
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@ -402,19 +405,39 @@ function ScreenViewPage() {
(c) => (c as any).componentId === "table-search-widget"
);
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
// 디버그: 모든 컴포넌트 타입 확인
console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({
id: c.id,
type: c.type,
componentType: (c as any).componentType,
componentId: (c as any).componentId,
})));
// 🆕 조건부 컨테이너들을 찾기
const conditionalContainers = regularComponents.filter(
(c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container"
);
console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({
id: c.id,
y: c.position.y,
size: c.size,
})));
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
const isConditionalContainer = (component as any).componentId === "conditional-container";
if (isTableSearchWidget) {
// TableSearchWidget 자체는 조정하지 않음
if (isTableSearchWidget || isConditionalContainer) {
// 자기 자신은 조정하지 않음
return component;
}
let totalHeightAdjustment = 0;
// TableSearchWidget 높이 조정
for (const widget of tableSearchWidgets) {
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
const isBelow = component.position.y > widget.position.y;
const heightDiff = getHeightDiff(screenId, widget.id);
@ -423,6 +446,31 @@ function ScreenViewPage() {
}
}
// 🆕 조건부 컨테이너 높이 조정
for (const container of conditionalContainers) {
const isBelow = component.position.y > container.position.y;
const actualHeight = conditionalContainerHeights[container.id];
const originalHeight = container.size?.height || 200;
const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0;
console.log(`🔍 높이 조정 체크:`, {
componentId: component.id,
componentY: component.position.y,
containerY: container.position.y,
isBelow,
actualHeight,
originalHeight,
heightDiff,
containerId: container.id,
containerSize: container.size,
});
if (isBelow && heightDiff > 0) {
totalHeightAdjustment += heightDiff;
console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`);
}
}
if (totalHeightAdjustment > 0) {
return {
...component,
@ -491,6 +539,12 @@ function ScreenViewPage() {
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
onHeightChange={(componentId, newHeight) => {
setConditionalContainerHeights((prev) => ({
...prev,
[componentId]: newHeight,
}));
}}
>
{/* 자식 컴포넌트들 */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&

View File

@ -0,0 +1,141 @@
"use client";
import React, { useState } from "react";
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export default function TestAutocompleteMapping() {
const [selectedValue, setSelectedValue] = useState("");
const [customerName, setCustomerName] = useState("");
const [address, setAddress] = useState("");
const [phone, setPhone] = useState("");
return (
<div className="container mx-auto py-8 space-y-6">
<Card>
<CardHeader>
<CardTitle>AutocompleteSearchInput </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 검색 컴포넌트 */}
<div className="space-y-2">
<Label> </Label>
<AutocompleteSearchInputComponent
config={{
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
placeholder: "거래처명 또는 코드로 검색",
enableFieldMapping: true,
fieldMappings: [
{
sourceField: "customer_name",
targetField: "customer_name_input",
label: "거래처명",
},
{
sourceField: "address",
targetField: "address_input",
label: "주소",
},
{
sourceField: "phone",
targetField: "phone_input",
label: "전화번호",
},
],
}}
value={selectedValue}
onChange={(value, fullData) => {
setSelectedValue(value);
console.log("선택된 항목:", fullData);
}}
/>
</div>
{/* 구분선 */}
<div className="border-t pt-6">
<h3 className="text-sm font-semibold mb-4">
</h3>
<div className="space-y-4">
{/* 거래처명 */}
<div className="space-y-2">
<Label htmlFor="customer_name_input"></Label>
<Input
id="customer_name_input"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="자동으로 채워집니다"
/>
</div>
{/* 주소 */}
<div className="space-y-2">
<Label htmlFor="address_input"></Label>
<Input
id="address_input"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="자동으로 채워집니다"
/>
</div>
{/* 전화번호 */}
<div className="space-y-2">
<Label htmlFor="phone_input"></Label>
<Input
id="phone_input"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="자동으로 채워집니다"
/>
</div>
</div>
</div>
{/* 상태 표시 */}
<div className="border-t pt-6">
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="p-4 bg-muted rounded-lg">
<pre className="text-xs">
{JSON.stringify(
{
selectedValue,
customerName,
address,
phone,
},
null,
2
)}
</pre>
</div>
</div>
</CardContent>
</Card>
{/* 사용 안내 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<ol className="list-decimal list-inside space-y-2">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ol>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import React, { useState } from "react";
import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input";
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
export default function TestEntitySearchPage() {
const [customerCode, setCustomerCode] = useState<string>("");
const [customerData, setCustomerData] = useState<any>(null);
const [itemCode, setItemCode] = useState<string>("");
const [itemData, setItemData] = useState<any>(null);
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">EntitySearchInput </h1>
<p className="text-muted-foreground mt-2">
</p>
</div>
{/* 거래처 검색 테스트 - 자동완성 방식 */}
<Card>
<CardHeader>
<CardTitle> ( ) NEW</CardTitle>
<CardDescription>
-
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<AutocompleteSearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code", "business_number"]}
placeholder="거래처명 입력하여 검색"
showAdditionalInfo
additionalFields={["customer_code", "address", "contact_phone"]}
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerData(fullData);
}}
/>
</div>
{customerData && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h3 className="font-semibold mb-2"> :</h3>
<pre className="text-xs">
{JSON.stringify(customerData, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
{/* 거래처 검색 테스트 - 모달 방식 */}
<Card>
<CardHeader>
<CardTitle> ( )</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<EntitySearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code", "business_number"]}
mode="combo"
placeholder="거래처를 검색하세요"
modalTitle="거래처 검색 및 선택"
modalColumns={["customer_code", "customer_name", "address", "contact_phone"]}
showAdditionalInfo
additionalFields={["address", "contact_phone", "business_number"]}
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerData(fullData);
}}
/>
</div>
</CardContent>
</Card>
{/* 품목 검색 테스트 */}
<Card>
<CardHeader>
<CardTitle> (Modal )</CardTitle>
<CardDescription>
item_info
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<EntitySearchInputComponent
tableName="item_info"
displayField="item_name"
valueField="id"
searchFields={["item_name", "id", "item_number"]}
mode="modal"
placeholder="품목 선택"
modalTitle="품목 검색"
modalColumns={["id", "item_name", "item_number", "unit", "selling_price"]}
showAdditionalInfo
additionalFields={["item_number", "unit", "selling_price"]}
value={itemCode}
onChange={(code, fullData) => {
setItemCode(code || "");
setItemData(fullData);
}}
/>
</div>
{itemData && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h3 className="font-semibold mb-2"> :</h3>
<pre className="text-xs">
{JSON.stringify(itemData, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import React, { useState } from "react";
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function TestOrderRegistrationPage() {
const [modalOpen, setModalOpen] = useState(false);
const handleSuccess = () => {
console.log("수주 등록 성공!");
};
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground mt-2">
EntitySearchInput + ModalRepeaterTable을
</p>
</div>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setModalOpen(true)}>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span>EntitySearchInput: 거래처 ( )</span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span>ModalRepeaterTable: 품목 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 계산: 수량 × = </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 편집: 수량, , , </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 방지: 이미 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 삭제: 추가된 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 표시: 모든 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 전환: 거래처 / / </span>
</div>
</CardContent>
</Card>
{/* 수주 등록 모달 */}
<OrderRegistrationModal
open={modalOpen}
onOpenChange={setModalOpen}
onSuccess={handleSuccess}
/>
</div>
);
}

View File

@ -15,6 +15,8 @@ import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
interface ScreenModalState {
isOpen: boolean;
@ -54,11 +56,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>({});
// 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록)
const continuousModeRef = useRef(false);
const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음)
// localStorage에서 연속 모드 상태 복원
useEffect(() => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
@ -119,7 +121,19 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, description, size } = event.detail;
const { screenId, title, description, size, urlParams } = event.detail;
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
Object.entries(urlParams).forEach(([key, value]) => {
currentUrl.searchParams.set(key, String(value));
});
// pushState로 URL 변경 (페이지 새로고침 없이)
window.history.pushState({}, "", currentUrl.toString());
console.log("✅ URL 파라미터 추가:", urlParams);
}
setModalState({
isOpen: true,
screenId,
@ -130,6 +144,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
const handleCloseModal = () => {
// 🆕 URL 파라미터 제거
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
// dataSourceId 파라미터 제거
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
console.log("🧹 URL 파라미터 제거");
}
setModalState({
isOpen: false,
screenId: null,
@ -150,14 +173,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// console.log("💾 저장 성공 이벤트 수신");
// console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode);
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
// console.log("✅ 연속 모드 활성화 - 폼만 초기화");
// 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨)
setFormData({});
toast.success("저장되었습니다. 계속 입력하세요.");
} else {
// 일반 모드: 모달 닫기
@ -198,13 +221,132 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
console.log("API 응답:", { screenInfo, layoutData });
// 🆕 URL 파라미터 확인 (수정 모드)
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
const editId = urlParams.get("editId");
const tableName = urlParams.get("tableName") || screenInfo.tableName;
const groupByColumnsParam = urlParams.get("groupByColumns");
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam });
// 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) {
try {
console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam });
const { dataApi } = await import("@/lib/api/data");
// groupByColumns 파싱
let groupByColumns: string[] = [];
if (groupByColumnsParam) {
try {
groupByColumns = JSON.parse(groupByColumnsParam);
console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns);
} catch (e) {
console.warn("groupByColumns 파싱 실패:", e);
}
} else {
console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!");
}
console.log("🚀 [ScreenModal] API 호출 직전:", {
tableName,
editId,
enableEntityJoin: true,
groupByColumns,
groupByColumnsLength: groupByColumns.length,
});
// 🆕 apiClient를 named import로 가져오기
const { apiClient } = await import("@/lib/api/client");
const params: any = {
enableEntityJoin: true,
};
if (groupByColumns.length > 0) {
params.groupByColumns = JSON.stringify(groupByColumns);
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
}
console.log("📡 [ScreenModal] 실제 API 요청:", {
url: `/data/${tableName}/${editId}`,
params,
});
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
const response = apiResponse.data;
console.log("📩 [ScreenModal] API 응답 받음:", {
success: response.success,
hasData: !!response.data,
dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음",
dataLength: Array.isArray(response.data) ? response.data.length : 1,
});
if (response.success && response.data) {
// 배열인 경우 (그룹핑) vs 단일 객체
const isArray = Array.isArray(response.data);
if (isArray) {
console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`);
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
} else {
console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")");
console.log("📊 모든 필드 키:", Object.keys(response.data));
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
}
// 🔧 날짜 필드 정규화 (타임존 제거)
const normalizeDates = (data: any): any => {
if (Array.isArray(data)) {
return data.map(normalizeDates);
}
if (typeof data !== 'object' || data === null) {
return data;
}
const normalized: any = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
const before = value;
const after = value.split('T')[0];
console.log(`🔧 [날짜 정규화] ${key}: ${before}${after}`);
normalized[key] = after;
} else {
normalized[key] = value;
}
}
return normalized;
};
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
const normalizedData = normalizeDates(response.data);
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
setFormData(normalizedData);
// setFormData 직후 확인
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
} else {
console.error("❌ 수정 데이터 로드 실패:", response.error);
toast.error("데이터를 불러올 수 없습니다.");
}
} catch (error) {
console.error("❌ 수정 데이터 조회 오류:", error);
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
}
}
}
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면 관리에서 설정한 해상도 사용 (우선순위)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) {
// 화면 관리에서 설정한 해상도 사용
@ -220,7 +362,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
dimensions = calculateScreenDimensions(components);
console.log("⚠️ 자동 계산된 크기 사용:", dimensions);
}
setScreenDimensions(dimensions);
setScreenData({
@ -245,6 +387,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
const handleClose = () => {
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete("mode");
currentUrl.searchParams.delete("editId");
currentUrl.searchParams.delete("tableName");
currentUrl.searchParams.delete("groupByColumns");
window.history.pushState({}, "", currentUrl.toString());
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
}
setModalState({
isOpen: false,
screenId: null,
@ -280,17 +433,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
const modalStyle = getModalStyle();
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
// modalId 생성 및 업데이트
useEffect(() => {
// 모달이 열려있고 screenId가 있을 때만 업데이트
if (!modalState.isOpen) return;
let newModalId: string | undefined;
// 1순위: screenId (가장 안정적)
if (modalState.screenId) {
newModalId = `screen-modal-${modalState.screenId}`;
@ -328,11 +481,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// result: newModalId,
// });
}
if (newModalId) {
setPersistedModalId(newModalId);
}
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
}, [
modalState.isOpen,
modalState.screenId,
modalState.title,
screenData?.screenInfo?.tableName,
screenData?.screenInfo?.screenName,
]);
return (
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
@ -373,55 +532,59 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<div
className="relative bg-white mx-auto"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
transformOrigin: "center center",
}}
>
{screenData.components.map((component) => {
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
<TableOptionsProvider>
<div
className="relative mx-auto bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
transformOrigin: "center center",
}}
>
{screenData.components.map((component) => {
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent =
offsetX === 0 && offsetY === 0
? component
: {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
// console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
// console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
};
// console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
);
})}
</div>
}));
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
);
})}
</div>
</TableOptionsProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>
@ -443,10 +606,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// console.log("🔄 연속 모드 변경:", isChecked);
}}
/>
<Label
htmlFor="continuous-mode"
className="text-sm font-normal cursor-pointer select-none"
>
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>

View File

@ -0,0 +1,49 @@
"use client";
import React from "react";
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
/**
*
*
* , .
* AutocompleteSearchInput과 customer_mng .
*/
interface OrderCustomerSearchProps {
/** 현재 선택된 거래처 코드 */
value: string;
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
onChange: (customerCode: string | null, fullData?: any) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
export function OrderCustomerSearch({
value,
onChange,
disabled = false,
}: OrderCustomerSearchProps) {
return (
<AutocompleteSearchInputComponent
// 고정 설정 (수주 등록 전용)
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={[
"customer_name",
"customer_code",
"business_number",
]}
placeholder="거래처명 입력하여 검색"
showAdditionalInfo
additionalFields={["customer_code", "address", "contact_phone"]}
// 외부에서 제어 가능한 prop
value={value}
onChange={onChange}
disabled={disabled}
/>
);
}

View File

@ -0,0 +1,128 @@
"use client";
import React from "react";
import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table";
import type {
RepeaterColumnConfig,
CalculationRule,
} from "@/lib/registry/components/modal-repeater-table";
/**
*
*
* , .
* ModalRepeaterTable과 item_info ,
* .
*/
interface OrderItemRepeaterTableProps {
/** 현재 선택된 품목 목록 */
value: any[];
/** 품목 목록 변경 시 콜백 */
onChange: (items: any[]) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
// 수주 등록 전용 컬럼 설정 (고정)
const ORDER_COLUMNS: RepeaterColumnConfig[] = [
{
field: "item_number",
label: "품번",
editable: false,
width: "120px",
},
{
field: "item_name",
label: "품명",
editable: false,
width: "180px",
},
{
field: "specification",
label: "규격",
editable: false,
width: "150px",
},
{
field: "material",
label: "재질",
editable: false,
width: "120px",
},
{
field: "quantity",
label: "수량",
type: "number",
editable: true,
required: true,
defaultValue: 1,
width: "100px",
},
{
field: "selling_price",
label: "단가",
type: "number",
editable: true,
required: true,
width: "120px",
},
{
field: "amount",
label: "금액",
type: "number",
editable: false,
calculated: true,
width: "120px",
},
{
field: "delivery_date",
label: "납기일",
type: "date",
editable: true,
width: "130px",
},
];
// 수주 등록 전용 계산 공식 (고정)
const ORDER_CALCULATION_RULES: CalculationRule[] = [
{
result: "amount",
formula: "quantity * selling_price",
dependencies: ["quantity", "selling_price"],
},
];
export function OrderItemRepeaterTable({
value,
onChange,
disabled = false,
}: OrderItemRepeaterTableProps) {
return (
<ModalRepeaterTableComponent
// 고정 설정 (수주 등록 전용)
sourceTable="item_info"
sourceColumns={[
"item_number",
"item_name",
"specification",
"material",
"unit",
"selling_price",
]}
sourceSearchFields={["item_name", "item_number", "specification"]}
modalTitle="품목 검색 및 선택"
modalButtonText="품목 검색"
multiSelect={true}
columns={ORDER_COLUMNS}
calculationRules={ORDER_CALCULATION_RULES}
uniqueField="item_number"
// 외부에서 제어 가능한 prop
value={value}
onChange={onChange}
disabled={disabled}
/>
);
}

View File

@ -0,0 +1,530 @@
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OrderCustomerSearch } from "./OrderCustomerSearch";
import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
interface OrderRegistrationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function OrderRegistrationModal({
open,
onOpenChange,
onSuccess,
}: OrderRegistrationModalProps) {
// 입력 방식
const [inputMode, setInputMode] = useState<string>("customer_first");
// 판매 유형 (국내/해외)
const [salesType, setSalesType] = useState<string>("domestic");
// 단가 기준 (기준단가/거래처별단가)
const [priceType, setPriceType] = useState<string>("standard");
// 폼 데이터
const [formData, setFormData] = useState<any>({
customerCode: "",
customerName: "",
contactPerson: "",
deliveryDestination: "",
deliveryAddress: "",
deliveryDate: "",
memo: "",
// 무역 정보 (해외 판매 시)
incoterms: "",
paymentTerms: "",
currency: "KRW",
portOfLoading: "",
portOfDischarge: "",
hsCode: "",
});
// 선택된 품목 목록
const [selectedItems, setSelectedItems] = useState<any[]>([]);
// 저장 중
const [isSaving, setIsSaving] = useState(false);
// 저장 처리
const handleSave = async () => {
try {
// 유효성 검사
if (!formData.customerCode) {
toast.error("거래처를 선택해주세요");
return;
}
if (selectedItems.length === 0) {
toast.error("품목을 추가해주세요");
return;
}
setIsSaving(true);
// 수주 등록 API 호출
const orderData: any = {
inputMode,
salesType,
priceType,
customerCode: formData.customerCode,
contactPerson: formData.contactPerson,
deliveryDestination: formData.deliveryDestination,
deliveryAddress: formData.deliveryAddress,
deliveryDate: formData.deliveryDate,
items: selectedItems,
memo: formData.memo,
};
// 해외 판매 시 무역 정보 추가
if (salesType === "export") {
orderData.tradeInfo = {
incoterms: formData.incoterms,
paymentTerms: formData.paymentTerms,
currency: formData.currency,
portOfLoading: formData.portOfLoading,
portOfDischarge: formData.portOfDischarge,
hsCode: formData.hsCode,
};
}
const response = await apiClient.post("/orders", orderData);
if (response.data.success) {
toast.success("수주가 등록되었습니다");
onOpenChange(false);
onSuccess?.();
// 폼 초기화
resetForm();
} else {
toast.error(response.data.message || "수주 등록에 실패했습니다");
}
} catch (error: any) {
console.error("수주 등록 오류:", error);
toast.error(
error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
);
} finally {
setIsSaving(false);
}
};
// 취소 처리
const handleCancel = () => {
onOpenChange(false);
resetForm();
};
// 폼 초기화
const resetForm = () => {
setInputMode("customer_first");
setSalesType("domestic");
setPriceType("standard");
setFormData({
customerCode: "",
customerName: "",
contactPerson: "",
deliveryDestination: "",
deliveryAddress: "",
deliveryDate: "",
memo: "",
incoterms: "",
paymentTerms: "",
currency: "KRW",
portOfLoading: "",
portOfDischarge: "",
hsCode: "",
});
setSelectedItems([]);
};
// 전체 금액 계산
const totalAmount = selectedItems.reduce(
(sum, item) => sum + (item.amount || 0),
0
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 상단 셀렉트 박스 3개 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 입력 방식 */}
<div className="space-y-2">
<Label htmlFor="inputMode" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-amber-500">📝</span>
</Label>
<Select value={inputMode} onValueChange={setInputMode}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="입력 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="customer_first"> </SelectItem>
<SelectItem value="quotation"> </SelectItem>
<SelectItem value="unit_price"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 판매 유형 */}
<div className="space-y-2">
<Label htmlFor="salesType" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-blue-500">🌏</span>
</Label>
<Select value={salesType} onValueChange={setSalesType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="판매 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="domestic"> </SelectItem>
<SelectItem value="export"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 단가 기준 */}
<div className="space-y-2">
<Label htmlFor="priceType" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-green-500">💰</span>
</Label>
<Select value={priceType} onValueChange={setPriceType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="단가 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard"> </SelectItem>
<SelectItem value="customer"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 거래처 정보 (항상 표시) */}
{inputMode === "customer_first" && (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<span>🏢</span>
<span> </span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 거래처 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<OrderCustomerSearch
value={formData.customerCode}
onChange={(code, fullData) => {
setFormData({
...formData,
customerCode: code || "",
customerName: fullData?.customer_name || "",
});
}}
/>
</div>
{/* 담당자 */}
<div className="space-y-2">
<Label htmlFor="contactPerson" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="contactPerson"
placeholder="담당자"
value={formData.contactPerson}
onChange={(e) =>
setFormData({ ...formData, contactPerson: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* 납품처 */}
<div className="space-y-2">
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="deliveryDestination"
placeholder="납품처"
value={formData.deliveryDestination}
onChange={(e) =>
setFormData({ ...formData, deliveryDestination: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* 납품장소 */}
<div className="space-y-2">
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="deliveryAddress"
placeholder="납품장소"
value={formData.deliveryAddress}
onChange={(e) =>
setFormData({ ...formData, deliveryAddress: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
)}
{inputMode === "quotation" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<input
type="text"
placeholder="견대 번호를 입력하세요"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{inputMode === "unit_price" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<input
type="text"
placeholder="단가 정보 입력"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{/* 추가된 품목 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<OrderItemRepeaterTable
value={selectedItems}
onChange={setSelectedItems}
/>
</div>
{/* 전체 금액 표시 */}
{selectedItems.length > 0 && (
<div className="flex justify-end">
<div className="text-sm sm:text-base font-semibold">
: {totalAmount.toLocaleString()}
</div>
</div>
)}
{/* 무역 정보 (해외 판매 시에만 표시) */}
{salesType === "export" && (
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
<span>🌏</span>
<span> </span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 인코텀즈 */}
<div className="space-y-2">
<Label htmlFor="incoterms" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.incoterms}
onValueChange={(value) =>
setFormData({ ...formData, incoterms: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXW">EXW</SelectItem>
<SelectItem value="FOB">FOB</SelectItem>
<SelectItem value="CIF">CIF</SelectItem>
<SelectItem value="DDP">DDP</SelectItem>
<SelectItem value="DAP">DAP</SelectItem>
</SelectContent>
</Select>
</div>
{/* 결제 조건 */}
<div className="space-y-2">
<Label htmlFor="paymentTerms" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.paymentTerms}
onValueChange={(value) =>
setFormData({ ...formData, paymentTerms: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="advance"></SelectItem>
<SelectItem value="cod"></SelectItem>
<SelectItem value="lc">(L/C)</SelectItem>
<SelectItem value="net30">NET 30</SelectItem>
<SelectItem value="net60">NET 60</SelectItem>
</SelectContent>
</Select>
</div>
{/* 통화 */}
<div className="space-y-2">
<Label htmlFor="currency" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.currency}
onValueChange={(value) =>
setFormData({ ...formData, currency: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="통화 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="KRW">KRW ()</SelectItem>
<SelectItem value="USD">USD ()</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="JPY">JPY ()</SelectItem>
<SelectItem value="CNY">CNY ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 선적항 */}
<div className="space-y-2">
<Label htmlFor="portOfLoading" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="portOfLoading"
placeholder="선적항"
value={formData.portOfLoading}
onChange={(e) =>
setFormData({ ...formData, portOfLoading: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* 도착항 */}
<div className="space-y-2">
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="portOfDischarge"
placeholder="도착항"
value={formData.portOfDischarge}
onChange={(e) =>
setFormData({ ...formData, portOfDischarge: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* HS Code */}
<div className="space-y-2">
<Label htmlFor="hsCode" className="text-xs sm:text-sm">
HS Code
</Label>
<input
type="text"
id="hsCode"
placeholder="HS Code"
value={formData.hsCode}
onChange={(e) =>
setFormData({ ...formData, hsCode: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
)}
{/* 메모 */}
<div className="space-y-2">
<Label htmlFor="memo" className="text-xs sm:text-sm">
</Label>
<textarea
id="memo"
placeholder="메모를 입력하세요"
value={formData.memo}
onChange={(e) =>
setFormData({ ...formData, memo: e.target.value })
}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,374 @@
# 수주 등록 컴포넌트
## 개요
수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
## 컴포넌트 구조
```
frontend/components/order/
├── OrderRegistrationModal.tsx # 수주 등록 메인 모달
├── OrderCustomerSearch.tsx # 거래처 검색 (전용)
├── OrderItemRepeaterTable.tsx # 품목 반복 테이블 (전용)
└── README.md # 문서 (현재 파일)
```
## 1. OrderRegistrationModal
수주 등록 메인 모달 컴포넌트입니다.
### Props
```typescript
interface OrderRegistrationModalProps {
/** 모달 열림/닫힘 상태 */
open: boolean;
/** 모달 상태 변경 핸들러 */
onOpenChange: (open: boolean) => void;
/** 수주 등록 성공 시 콜백 */
onSuccess?: () => void;
}
```
### 사용 예시
```tsx
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
function MyComponent() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>수주 등록</Button>
<OrderRegistrationModal
open={isOpen}
onOpenChange={setIsOpen}
onSuccess={() => {
console.log("수주 등록 완료!");
// 목록 새로고침 등
}}
/>
</>
);
}
```
### 기능
- **입력 방식 선택**: 거래처 우선, 견적 방식, 단가 방식
- **거래처 검색**: 자동완성 드롭다운으로 거래처 검색 및 선택
- **품목 관리**: 모달에서 품목 검색 및 추가, 수량/단가 입력, 금액 자동 계산
- **전체 금액 표시**: 추가된 품목들의 총 금액 계산
- **유효성 검사**: 거래처 및 품목 필수 입력 체크
---
## 2. OrderCustomerSearch
수주 등록 전용 거래처 검색 컴포넌트입니다.
### 특징
- `customer_mng` 테이블만 조회 (고정)
- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
- 추가 정보 표시 (주소, 연락처)
### Props
```typescript
interface OrderCustomerSearchProps {
/** 현재 선택된 거래처 코드 */
value: string;
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
onChange: (customerCode: string | null, fullData?: any) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
```
### 사용 예시
```tsx
import { OrderCustomerSearch } from "@/components/order/OrderCustomerSearch";
function MyForm() {
const [customerCode, setCustomerCode] = useState("");
const [customerName, setCustomerName] = useState("");
return (
<OrderCustomerSearch
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerName(fullData?.customer_name || "");
}}
/>
);
}
```
### 고정 설정
| 설정 | 값 | 설명 |
|------|-----|------|
| `tableName` | `customer_mng` | 거래처 테이블 |
| `displayField` | `customer_name` | 표시 필드 |
| `valueField` | `customer_code` | 값 필드 |
| `searchFields` | `["customer_name", "customer_code", "business_number"]` | 검색 대상 필드 |
| `additionalFields` | `["customer_code", "address", "contact_phone"]` | 추가 표시 필드 |
---
## 3. OrderItemRepeaterTable
수주 등록 전용 품목 반복 테이블 컴포넌트입니다.
### 특징
- `item_info` 테이블만 조회 (고정)
- 수주에 필요한 컬럼만 표시 (품번, 품명, 수량, 단가, 금액 등)
- 금액 자동 계산 (`수량 * 단가`)
### Props
```typescript
interface OrderItemRepeaterTableProps {
/** 현재 선택된 품목 목록 */
value: any[];
/** 품목 목록 변경 시 콜백 */
onChange: (items: any[]) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
```
### 사용 예시
```tsx
import { OrderItemRepeaterTable } from "@/components/order/OrderItemRepeaterTable";
function MyForm() {
const [items, setItems] = useState([]);
return (
<OrderItemRepeaterTable
value={items}
onChange={setItems}
/>
);
}
```
### 고정 컬럼 설정
| 필드 | 라벨 | 타입 | 편집 | 필수 | 계산 | 설명 |
|------|------|------|------|------|------|------|
| `id` | 품번 | text | ❌ | - | - | 품목 ID |
| `item_name` | 품명 | text | ❌ | - | - | 품목명 |
| `item_number` | 품목번호 | text | ❌ | - | - | 품목 번호 |
| `quantity` | 수량 | number | ✅ | ✅ | - | 주문 수량 (기본값: 1) |
| `selling_price` | 단가 | number | ✅ | ✅ | - | 판매 단가 |
| `amount` | 금액 | number | ❌ | - | ✅ | 자동 계산 (수량 * 단가) |
| `delivery_date` | 납품일 | date | ✅ | - | - | 납품 예정일 |
| `note` | 비고 | text | ✅ | - | - | 추가 메모 |
### 계산 규칙
```javascript
amount = quantity * selling_price
```
---
## 범용 컴포넌트 vs 전용 컴포넌트
### 왜 전용 컴포넌트를 만들었나?
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|------|--------------|--------------|
| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
| **위치** | `lib/registry/components/` | `components/order/` |
### 범용 컴포넌트 (화면 편집기용)
```tsx
// ❌ 수주 등록에서 사용 금지
<AutocompleteSearchInputComponent
tableName="???" // ConfigPanel에서 변경 가능
displayField="???" // 다른 테이블로 바꿀 수 있음
valueField="???" // 필드가 맞지 않으면 에러
/>
```
**문제점:**
- 사용자가 `tableName``item_info`로 변경하면 거래처가 아닌 품목이 조회됨
- `valueField`를 변경하면 `formData.customerCode`에 잘못된 값 저장
- 수주 로직이 깨짐
### 전용 컴포넌트 (수주 등록용)
```tsx
// ✅ 수주 등록에서 사용
<OrderCustomerSearch
value={customerCode} // 외부에서 제어 가능
onChange={handleChange} // 값 변경만 처리
// 나머지 설정은 내부에서 고정
/>
```
**장점:**
- 설정이 하드코딩되어 있어 변경 불가
- 수주 등록 로직에 최적화
- 안전하고 예측 가능
---
## API 엔드포인트
### 거래처 검색
```
GET /api/entity-search/customer_mng
Query Parameters:
- searchText: 검색어
- searchFields: customer_name,customer_code,business_number
- page: 페이지 번호
- limit: 페이지 크기
```
### 품목 검색
```
GET /api/entity-search/item_info
Query Parameters:
- searchText: 검색어
- searchFields: item_name,id,item_number
- page: 페이지 번호
- limit: 페이지 크기
```
### 수주 등록
```
POST /api/orders
Body:
{
inputMode: "customer_first" | "quotation" | "unit_price",
customerCode: string,
deliveryDate?: string,
items: Array<{
id: string,
item_name: string,
quantity: number,
selling_price: number,
amount: number,
delivery_date?: string,
note?: string
}>,
memo?: string
}
Response:
{
success: boolean,
data?: {
orderNumber: string,
orderId: number
},
message?: string
}
```
---
## 멀티테넌시 (Multi-Tenancy)
모든 API 호출은 자동으로 `company_code` 필터링이 적용됩니다.
- 거래처 검색: 현재 로그인한 사용자의 회사에 속한 거래처만 조회
- 품목 검색: 현재 로그인한 사용자의 회사에 속한 품목만 조회
- 수주 등록: 자동으로 현재 사용자의 `company_code` 추가
---
## 트러블슈팅
### 1. 거래처가 검색되지 않음
**원인**: `customer_mng` 테이블에 데이터가 없거나 `company_code`가 다름
**해결**:
```sql
-- 거래처 데이터 확인
SELECT * FROM customer_mng WHERE company_code = 'YOUR_COMPANY_CODE';
```
### 2. 품목이 검색되지 않음
**원인**: `item_info` 테이블에 데이터가 없거나 `company_code`가 다름
**해결**:
```sql
-- 품목 데이터 확인
SELECT * FROM item_info WHERE company_code = 'YOUR_COMPANY_CODE';
```
### 3. 수주 등록 실패
**원인**: 필수 필드 누락 또는 백엔드 API 오류
**해결**:
1. 브라우저 개발자 도구 콘솔 확인
2. 네트워크 탭에서 API 응답 확인
3. 백엔드 로그 확인
---
## 개발 참고 사항
### 새로운 전용 컴포넌트 추가 시
1. **범용 컴포넌트 활용**: 기존 범용 컴포넌트를 래핑
2. **설정 고정**: 비즈니스 로직에 필요한 설정을 하드코딩
3. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
4. **문서 작성**: README에 사용법 및 고정 설정 명시
### 예시: 견적 등록 전용 컴포넌트
```tsx
// QuotationCustomerSearch.tsx
export function QuotationCustomerSearch({ value, onChange }: Props) {
return (
<AutocompleteSearchInputComponent
tableName="customer_mng" // 고정
displayField="customer_name" // 고정
valueField="customer_code" // 고정
value={value}
onChange={onChange}
/>
);
}
```
---
## 관련 파일
- 범용 컴포넌트:
- `lib/registry/components/autocomplete-search-input/`
- `lib/registry/components/entity-search-input/`
- `lib/registry/components/modal-repeater-table/`
- 백엔드 API:
- `backend-node/src/controllers/entitySearchController.ts`
- `backend-node/src/controllers/orderController.ts`
- 계획서:
- `수주등록_화면_개발_계획서.md`

View File

@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/dialog";
import { CalendarIcon, File, Upload, X } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";

View File

@ -40,6 +40,8 @@ interface InteractiveScreenViewerProps {
tableName?: string;
};
onSave?: () => Promise<void>;
onRefresh?: () => void;
onFlowRefresh?: () => void;
}
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -50,6 +52,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
hideLabel = false,
screenInfo,
onSave,
onRefresh,
onFlowRefresh,
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName, user } = useAuth();
@ -312,6 +316,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(selectedRows, selectedData) => {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
@ -324,9 +329,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
}}
onRefresh={() => {
// 테이블 컴포넌트는 자체적으로 loadData 호출
}}
onRefresh={onRefresh || (() => {
// 부모로부터 전달받은 onRefresh 또는 기본 동작
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
})}
onFlowRefresh={onFlowRefresh}
onClose={() => {
// buttonActions.ts가 이미 처리함
}}

View File

@ -60,6 +60,9 @@ interface RealtimePreviewProps {
sortBy?: string;
sortOrder?: "asc" | "desc";
columnOrder?: string[];
// 🆕 조건부 컨테이너 높이 변화 콜백
onHeightChange?: (componentId: string, newHeight: number) => void;
}
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
@ -123,6 +126,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onFlowRefresh,
formData,
onFormDataChange,
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
}) => {
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
@ -218,6 +222,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
};
const getHeight = () => {
// 🆕 조건부 컨테이너는 높이를 자동으로 설정 (내용물에 따라 자동 조정)
const isConditionalContainer = (component as any).componentType === "conditional-container";
if (isConditionalContainer && !isDesignMode) {
return "auto"; // 런타임에서는 내용물 높이에 맞춤
}
// 플로우 위젯의 경우 측정된 높이 사용
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
if (isFlowWidget && actualHeight) {
@ -262,19 +272,15 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
right: undefined,
};
// 디버깅: 크기 정보 로그
if (component.id && isSelected) {
console.log("📐 RealtimePreview baseStyle:", {
componentId: component.id,
componentType: (component as any).componentType || component.type,
sizeWidth: size?.width,
sizeHeight: size?.height,
styleWidth: componentStyle?.width,
styleHeight: componentStyle?.height,
baseStyleWidth: baseStyle.width,
baseStyleHeight: baseStyle.height,
});
}
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
// if (component.id && isSelected) {
// console.log("📐 RealtimePreview baseStyle:", {
// componentId: component.id,
// componentType: (component as any).componentType || component.type,
// sizeWidth: size?.width,
// sizeHeight: size?.height,
// });
// }
// 🔍 DOM 렌더링 후 실제 크기 측정
const innerDivRef = React.useRef<HTMLDivElement>(null);
@ -329,7 +335,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
(contentRef as any).current = node;
}
}}
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
className={`${
(component.type === "component" && (component as any).componentType === "flow-widget") ||
((component as any).componentType === "conditional-container" && !isDesignMode)
? "h-auto"
: "h-full"
} overflow-visible`}
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
@ -365,6 +376,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
sortBy={sortBy}
sortOrder={sortOrder}
columnOrder={columnOrder}
onHeightChange={onHeightChange}
/>
</div>

View File

@ -1,12 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Checkbox } from "@/components/ui/checkbox";
import { useAuth } from "@/hooks/useAuth";
import {
DropdownMenu,
DropdownMenuContent,
@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & {
};
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
const { user } = useAuth();
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
const [activeTab, setActiveTab] = useState("active");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true); // 초기 로딩
const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지)
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("all");
const [companies, setCompanies] = useState<any[]>([]);
const [loadingCompanies, setLoadingCompanies] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 검색어 디바운스를 위한 타이머 ref
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
// 첫 로딩 여부를 추적 (한 번만 true)
const isFirstLoad = useRef(true);
// 삭제 관련 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
@ -119,14 +134,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
// 화면 목록 로드 (실제 API)
// 최고 관리자인 경우 회사 목록 로드
useEffect(() => {
if (isSuperAdmin) {
loadCompanies();
}
}, [isSuperAdmin]);
const loadCompanies = async () => {
try {
setLoadingCompanies(true);
const { apiClient } = await import("@/lib/api/client"); // named export
const response = await apiClient.get("/admin/companies");
const data = response.data.data || response.data || [];
setCompanies(data.map((c: any) => ({
companyCode: c.company_code || c.companyCode,
companyName: c.company_name || c.companyName,
})));
} catch (error) {
console.error("회사 목록 조회 실패:", error);
} finally {
setLoadingCompanies(false);
}
};
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
useEffect(() => {
// 이전 타이머 취소
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
// 새 타이머 설정
debounceTimer.current = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 150);
// 클린업
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [searchTerm]);
// 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용
useEffect(() => {
let abort = false;
const load = async () => {
try {
setLoading(true);
// 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true
if (isFirstLoad.current) {
setLoading(true);
isFirstLoad.current = false; // 첫 로딩 완료 표시
} else {
setIsSearching(true);
}
if (activeTab === "active") {
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
// 최고 관리자이고 특정 회사를 선택한 경우
if (isSuperAdmin && selectedCompanyCode !== "all") {
params.companyCode = selectedCompanyCode;
}
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
const resp = await screenApi.getScreens(params);
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
if (abort) return;
setScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
@ -137,7 +213,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
}
} catch (e) {
// console.error("화면 목록 조회 실패", e);
console.error("화면 목록 조회 실패", e);
if (activeTab === "active") {
setScreens([]);
} else {
@ -145,28 +221,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
}
setTotalPages(1);
} finally {
if (!abort) setLoading(false);
if (!abort) {
setLoading(false);
setIsSearching(false);
}
}
};
load();
return () => {
abort = true;
};
}, [currentPage, searchTerm, activeTab]);
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
const filteredScreens = screens; // 서버 필터 기준 사용
// 화면 목록 다시 로드
const reloadScreens = async () => {
try {
setLoading(true);
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
setIsSearching(true);
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
// 최고 관리자이고 특정 회사를 선택한 경우
if (isSuperAdmin && selectedCompanyCode !== "all") {
params.companyCode = selectedCompanyCode;
}
const resp = await screenApi.getScreens(params);
setScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
} catch (e) {
// console.error("화면 목록 조회 실패", e);
console.error("화면 목록 조회 실패", e);
} finally {
setLoading(false);
setIsSearching(false);
}
};
@ -405,18 +491,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
disabled={activeTab === "trash"}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 최고 관리자 전용: 회사 필터 */}
{isSuperAdmin && (
<div className="w-full sm:w-[200px]">
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode} disabled={activeTab === "trash"}>
<SelectTrigger className="h-10 text-sm">
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.companyCode} value={company.companyCode}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 검색 입력 */}
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
key="screen-search-input" // 리렌더링 시에도 동일한 Input 유지
placeholder="화면명, 코드, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
disabled={activeTab === "trash"}
/>
{/* 검색 중 인디케이터 */}
{isSearching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
</div>
</div>
</div>
<Button
onClick={() => setIsCreateOpen(true)}
disabled={activeTab === "trash"}

View File

@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
// 🆕 제목 블록 타입
interface TitleBlock {
id: string;
type: "text" | "field";
value: string; // text: 텍스트 내용, field: 컬럼명
tableName?: string; // field일 때 테이블명
label?: string; // field일 때 표시용 라벨
}
interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
// 🆕 제목 블록 빌더 상태
const [titleBlocks, setTitleBlocks] = useState<TitleBlock[]>([]);
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]); // 시스템의 모든 테이블 목록
const [tableColumnsMap, setTableColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
const [blockTableSearches, setBlockTableSearches] = useState<Record<string, string>>({}); // 블록별 테이블 검색어
const [blockColumnSearches, setBlockColumnSearches] = useState<Record<string, string>>({}); // 블록별 컬럼 검색어
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
// 🎯 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => {
const found = allComponents.some((comp: any) => {
@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
editModalDescription: String(latestAction.editModalDescription || ""),
targetUrl: String(latestAction.targetUrl || ""),
});
// 🆕 제목 블록 초기화
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
setTitleBlocks(latestAction.modalTitleBlocks);
} else {
// 기본값: 빈 배열
setTitleBlocks([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
// 🆕 제목 블록 핸들러
const addTextBlock = () => {
const newBlock: TitleBlock = {
id: `text-${Date.now()}`,
type: "text",
value: "",
};
const updatedBlocks = [...titleBlocks, newBlock];
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const addFieldBlock = () => {
const newBlock: TitleBlock = {
id: `field-${Date.now()}`,
type: "field",
value: "",
tableName: "",
label: "",
};
const updatedBlocks = [...titleBlocks, newBlock];
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const updateBlock = (id: string, updates: Partial<TitleBlock>) => {
const updatedBlocks = titleBlocks.map((block) =>
block.id === id ? { ...block, ...updates } : block
);
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const removeBlock = (id: string) => {
const updatedBlocks = titleBlocks.filter((block) => block.id !== id);
setTitleBlocks(updatedBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
};
const moveBlockUp = (id: string) => {
const index = titleBlocks.findIndex((b) => b.id === id);
if (index <= 0) return;
const newBlocks = [...titleBlocks];
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
setTitleBlocks(newBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
};
const moveBlockDown = (id: string) => {
const index = titleBlocks.findIndex((b) => b.id === id);
if (index < 0 || index >= titleBlocks.length - 1) return;
const newBlocks = [...titleBlocks];
[newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]];
setTitleBlocks(newBlocks);
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
};
// 🆕 제목 미리보기 생성
const generateTitlePreview = (): string => {
if (titleBlocks.length === 0) return "(제목 없음)";
return titleBlocks
.map((block) => {
if (block.type === "text") {
return block.value || "(텍스트)";
} else {
return block.label || block.value || "(필드)";
}
})
.join("");
};
// 🆕 시스템의 모든 테이블 목록 로드
useEffect(() => {
const fetchAllTables = async () => {
try {
const response = await apiClient.get("/table-management/tables");
if (response.data.success && response.data.data) {
const tables = response.data.data.map((table: any) => ({
name: table.tableName,
label: table.displayName || table.tableName,
}));
setAvailableTables(tables);
console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
fetchAllTables();
}, []);
// 🆕 특정 테이블의 컬럼 로드
const loadTableColumns = async (tableName: string) => {
if (!tableName || tableColumnsMap[tableName]) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data);
if (response.data.success) {
// data가 배열인지 확인
let columnData = response.data.data;
// data.columns 형태일 수도 있음
if (!Array.isArray(columnData) && columnData?.columns) {
columnData = columnData.columns;
}
// data.data 형태일 수도 있음
if (!Array.isArray(columnData) && columnData?.data) {
columnData = columnData.data;
}
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => {
const name = col.name || col.columnName;
const label = col.displayName || col.label || col.columnLabel || name;
console.log(` - 컬럼: ${name} → "${label}"`);
return { name, label };
});
setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns }));
console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개");
} else {
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
}
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
}
};
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => {
const fetchScreens = async () => {
@ -274,6 +434,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="openModalWithData"> + 🆕</SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
@ -409,6 +570,400 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
{component.componentConfig?.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
<h4 className="text-sm font-medium text-foreground"> + </h4>
<p className="text-xs text-muted-foreground">
TableList에서
</p>
<div>
<Label htmlFor="data-source-id">
ID <span className="text-primary">()</span>
</Label>
<Input
id="data-source-id"
placeholder="비워두면 자동으로 감지됩니다"
value={component.componentConfig?.action?.dataSourceId || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
}}
/>
<p className="mt-1 text-xs text-primary font-medium">
TableList를
</p>
<p className="mt-1 text-xs text-muted-foreground">
감지: 현재 TableList <br/>
전달: 이전 <br/>
tableName으로 <br/>
설정: 필요시 (: item_info)
</p>
</div>
{/* 🆕 블록 기반 제목 빌더 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> </Label>
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={addTextBlock}
className="h-6 text-xs"
>
<Type className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={addFieldBlock}
className="h-6 text-xs"
>
<Database className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 블록 목록 */}
<div className="space-y-2">
{titleBlocks.length === 0 ? (
<div className="text-center py-4 text-xs text-muted-foreground border-2 border-dashed rounded">
</div>
) : (
titleBlocks.map((block, index) => (
<Card key={block.id} className="p-2">
<div className="flex items-start gap-2">
{/* 순서 변경 버튼 */}
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlockUp(block.id)}
disabled={index === 0}
className="h-5 w-5 p-0"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => moveBlockDown(block.id)}
disabled={index === titleBlocks.length - 1}
className="h-5 w-5 p-0"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 블록 타입 표시 */}
<div className="flex-shrink-0 mt-1">
{block.type === "text" ? (
<Type className="h-4 w-4 text-blue-500" />
) : (
<Database className="h-4 w-4 text-green-500" />
)}
</div>
{/* 블록 설정 */}
<div className="flex-1 space-y-2">
{block.type === "text" ? (
// 텍스트 블록
<Input
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
value={block.value}
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
className="h-7 text-xs"
/>
) : (
// 필드 블록
<>
{/* 테이블 선택 - Combobox */}
<Popover
open={blockTablePopoverOpen[block.id] || false}
onOpenChange={(open) => {
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open }));
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{block.tableName
? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName)
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="테이블 검색 (라벨 또는 이름)..."
className="h-7 text-xs"
value={blockTableSearches[block.id] || ""}
onValueChange={(value) => {
setBlockTableSearches((prev) => ({ ...prev, [block.id]: value }));
}}
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableTables
.filter((table) => {
const search = (blockTableSearches[block.id] || "").toLowerCase();
if (!search) return true;
return (
table.label.toLowerCase().includes(search) ||
table.name.toLowerCase().includes(search)
);
})
.map((table) => (
<CommandItem
key={table.name}
value={table.name}
onSelect={() => {
updateBlock(block.id, { tableName: table.name, value: "" });
loadTableColumns(table.name);
setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" }));
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
block.tableName === table.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{table.label}</span>
<span className="ml-2 text-[10px] text-muted-foreground">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{block.tableName && (
<>
{/* 컬럼 선택 - Combobox (라벨명 표시) */}
<Popover
open={blockColumnPopoverOpen[block.id] || false}
onOpenChange={(open) => {
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open }));
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{block.value
? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value)
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼 검색 (라벨 또는 이름)..."
className="h-7 text-xs"
value={blockColumnSearches[block.id] || ""}
onValueChange={(value) => {
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value }));
}}
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{(tableColumnsMap[block.tableName] || [])
.filter((col) => {
const search = (blockColumnSearches[block.id] || "").toLowerCase();
if (!search) return true;
return (
col.label.toLowerCase().includes(search) ||
col.name.toLowerCase().includes(search)
);
})
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
updateBlock(block.id, {
value: col.name,
label: col.label,
});
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" }));
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
block.value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{col.label}</span>
<span className="ml-2 text-[10px] text-muted-foreground">({col.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Input
placeholder="표시 라벨 (예: 품목명)"
value={block.label || ""}
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
className="h-7 text-xs"
/>
</>
)}
</>
)}
</div>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeBlock(block.id)}
className="h-7 w-7 p-0 text-red-500"
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
))
)}
</div>
{/* 미리보기 */}
{titleBlocks.length > 0 && (
<div className="mt-2 p-2 bg-muted rounded text-xs">
<span className="text-muted-foreground">: </span>
<span className="font-medium">{generateTitlePreview()}</span>
</div>
)}
<p className="text-[10px] text-muted-foreground">
텍스트: 고정 (: "품목 상세정보 - ")<br/>
필드: 이전 (: 품목명, )<br/>
: <br/>
"표시 라벨"
</p>
</div>
<div>
<Label htmlFor="modal-size-with-data"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "lg"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large) - </SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-with-data"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
style={{ fontSize: "12px" }}
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-data-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
SelectedItemsDetailInput
</p>
</div>
</div>
)}
{/* 수정 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-success/10 p-4">

View File

@ -186,75 +186,93 @@ export function DataFilterConfigPanel({
</Button>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
{filter.operator !== "date_range_contains" && (
<div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => handleFilterChange(filter.id, "operator", value)}
onValueChange={(value: any) => {
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
if (value === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
handleFilterChange(filter.id, "operator", value);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
@ -262,6 +280,11 @@ export function DataFilterConfigPanel({
<SelectContent>
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="greater_than"> (&gt;)</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem>
<SelectItem value="greater_than_or_equal"> ()</SelectItem>
<SelectItem value="less_than_or_equal"> ()</SelectItem>
<SelectItem value="between"> (BETWEEN)</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
@ -269,34 +292,138 @@ export function DataFilterConfigPanel({
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
{isCategoryOrCodeColumn(filter.columnName) && (
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
{filter.operator === "date_range_contains" && (
<>
<div className="col-span-2">
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
💡 :
<br /> NULL
<br /> NULL
<br />
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.startColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: value,
endColumn: filter.rangeConfig?.endColumn || "",
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.valueType}
onValueChange={(value: any) =>
handleFilterChange(filter.id, "valueType", value)
}
onValueChange={(value: any) => {
// dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "dynamic" && filter.operator === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "TODAY" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
{filter.operator === "date_range_contains" && (
<SelectItem value="dynamic"> ( )</SelectItem>
)}
{isCategoryOrCodeColumn(filter.columnName) && (
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
)}
{/* 값 입력 (NULL 체크 제외) */}
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
{filter.operator !== "is_null" &&
filter.operator !== "is_not_null" &&
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
@ -328,11 +455,22 @@ export function DataFilterConfigPanel({
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : filter.operator === "between" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
}}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder="필터 값 입력"
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
@ -341,10 +479,23 @@ export function DataFilterConfigPanel({
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
: filter.operator === "date_range_contains"
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
{/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700">
.
</p>
</div>
)}
</div>
))}
</div>

View File

@ -63,8 +63,9 @@ export function ComponentsPanel({
),
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
};
}, [allComponents]);
@ -92,6 +93,8 @@ export function ComponentsPanel({
return <Palette className="h-6 w-6" />;
case "action":
return <Zap className="h-6 w-6" />;
case "data":
return <Database className="h-6 w-6" />;
case "layout":
return <Layers className="h-6 w-6" />;
case "utility":
@ -185,7 +188,7 @@ export function ComponentsPanel({
{/* 카테고리 탭 */}
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-6 gap-1 p-1">
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
<TabsTrigger
value="tables"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
@ -198,6 +201,14 @@ export function ComponentsPanel({
<Edit3 className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="data"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="데이터"
>
<Grid className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="action"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
@ -260,6 +271,13 @@ export function ComponentsPanel({
: renderEmptyState()}
</TabsContent>
{/* 데이터 컴포넌트 */}
<TabsContent value="data" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("data").length > 0
? getFilteredComponents("data").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 액션 컴포넌트 */}
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("action").length > 0

View File

@ -36,6 +36,9 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
// 동적 컴포넌트 설정 패널
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
interface DetailSettingsPanelProps {
selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void;
@ -859,6 +862,57 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
onUpdateProperty(selectedComponent.id, path, value);
};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
console.log("✅ ConfigPanel 표시:", {
componentId,
definitionName: definition.name,
hasConfigPanel: !!definition.configPanel,
currentConfig,
});
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
};
return <ConfigPanelWrapper key={selectedComponent.id} />;
} else {
console.warn("⚠️ ConfigPanel 없음:", {
componentId,
definitionName: definition?.name,
hasDefinition: !!definition,
});
}
}
// 기존 하드코딩된 설정 패널들 (레거시)
switch (componentType) {
case "button":
case "button-primary":
@ -904,8 +958,10 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> "{componentType}" .</p>
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500">
"{componentId || componentType}" .
</p>
</div>
);
}

View File

@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
import {
@ -48,6 +49,9 @@ import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
@ -263,12 +267,74 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
const renderComponentConfigPanel = () => {
if (!selectedComponent) return null;
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
const componentType =
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id ||
selectedComponent.type;
const handleUpdateProperty = (path: string, value: any) => {
onUpdateProperty(selectedComponent.id, path, value);
};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId =
selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id;
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
console.log("✅ ConfigPanel 표시:", {
componentId,
definitionName: definition.name,
hasConfigPanel: !!definition.configPanel,
currentConfig,
});
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
};
return <ConfigPanelWrapper key={selectedComponent.id} />;
} else {
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
componentId,
definitionName: definition?.name,
hasDefinition: !!definition,
});
// ConfigPanel이 없으면 아래 switch case로 넘어감
}
}
// 기존 하드코딩된 설정 패널들 (레거시)
switch (componentType) {
case "button":
case "button-primary":
@ -311,8 +377,291 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
case "badge-status":
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
case "section-card":
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Card </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 헤더 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={selectedComponent.componentConfig?.showHeader !== false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
}}
/>
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
</Label>
</div>
{/* 제목 */}
{selectedComponent.componentConfig?.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.componentConfig?.title || ""}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.title", e.target.value);
}}
placeholder="섹션 제목 입력"
className="h-9 text-xs"
/>
</div>
)}
{/* 설명 */}
{selectedComponent.componentConfig?.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Textarea
value={selectedComponent.componentConfig?.description || ""}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
}}
placeholder="섹션 설명 입력"
className="text-xs resize-none"
rows={2}
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.padding || "md"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (24px)</SelectItem>
<SelectItem value="lg"> (32px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.componentConfig?.backgroundColor || "default"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ()</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="transparent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.borderStyle || "solid"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.borderStyle", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={selectedComponent.componentConfig?.collapsible || false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
}}
/>
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
/
</Label>
</div>
{selectedComponent.componentConfig?.collapsible && (
<div className="flex items-center space-x-2 ml-6">
<Checkbox
id="defaultOpen"
checked={selectedComponent.componentConfig?.defaultOpen !== false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
}}
/>
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
</Label>
</div>
)}
</div>
</div>
);
case "section-paper":
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Paper </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.componentConfig?.backgroundColor || "default"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ( )</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="accent"> ( )</SelectItem>
<SelectItem value="primary"> </SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 커스텀 색상 */}
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.componentConfig?.customColor || "#f0f0f0"}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", e.target.value);
}}
className="h-9"
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.padding || "md"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (16px)</SelectItem>
<SelectItem value="lg"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 둥근 모서리 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.componentConfig?.roundedCorners || "md"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.roundedCorners", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (2px)</SelectItem>
<SelectItem value="md"> (6px)</SelectItem>
<SelectItem value="lg"> (8px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그림자 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.componentConfig?.shadow || "none"}
onValueChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.shadow", value);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showBorder"
checked={selectedComponent.componentConfig?.showBorder || false}
onCheckedChange={(checked) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
}}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);
default:
return null;
// ConfigPanel이 없는 경우 경고 표시
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
<h3 className="mb-2 text-base font-medium"> </h3>
<p className="text-sm text-muted-foreground">
"{componentId || componentType}" .
</p>
</div>
);
}
};
@ -573,11 +922,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
const renderDetailTab = () => {
console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type);
// 1. DataTable 컴포넌트
if (selectedComponent.type === "datatable") {
console.log("✅ [renderDetailTab] DataTable 컴포넌트");
return (
<DataTableConfigPanel
component={selectedComponent as DataTableComponent}
@ -634,7 +980,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 5. 새로운 컴포넌트 시스템 (type: "component")
if (selectedComponent.type === "component") {
console.log("✅ [renderDetailTab] Component 타입");
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
const webType = selectedComponent.componentConfig?.webType;
@ -694,7 +1039,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
onChange={(newConfig) => {
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => {
handleUpdate(`componentConfig.${key}`, value);

View File

@ -77,3 +77,4 @@ export const numberingRuleTemplate = {

View File

@ -79,14 +79,14 @@ export const CategoryValueAddDialog: React.FC<
const valueCode = generateCode(valueLabel);
onAdd({
tableName: "",
columnName: "",
tableName: "", // CategoryValueManager에서 오버라이드됨
columnName: "", // CategoryValueManager에서 오버라이드됨
valueCode,
valueLabel: valueLabel.trim(),
description: description.trim(),
color: color,
description: description.trim() || undefined, // 빈 문자열 대신 undefined
color: color === "none" ? undefined : color, // "none"은 undefined로
isDefault: false,
});
} as TableCategoryValue);
// 초기화
setValueLabel("");

View File

@ -184,11 +184,18 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
title: "성공",
description: "카테고리 값이 삭제되었습니다",
});
} else {
// 백엔드에서 반환한 상세 에러 메시지 표시
toast({
title: "삭제 불가",
description: response.error || response.message || "카테고리 값 삭제에 실패했습니다",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "오류",
description: "카테고리 값 삭제에 실패했습니다",
description: "카테고리 값 삭제 중 오류가 발생했습니다",
variant: "destructive",
});
}

View File

@ -377,8 +377,8 @@ const ResizableDialogContent = React.forwardRef<
>
<div
ref={contentRef}
className="h-full w-full"
style={{ display: 'block', overflow: 'hidden' }}
className="h-full w-full relative"
style={{ display: 'block', overflow: 'hidden', pointerEvents: 'auto', zIndex: 1 }}
>
{children}
</div>
@ -387,45 +387,56 @@ const ResizableDialogContent = React.forwardRef<
{/* 오른쪽 */}
<div
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("e")}
/>
{/* 아래 */}
<div
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("s")}
/>
{/* 오른쪽 아래 */}
<div
className="absolute right-0 bottom-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("se")}
/>
{/* 왼쪽 */}
<div
className="absolute left-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("w")}
/>
{/* 위 */}
<div
className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("n")}
/>
{/* 왼쪽 아래 */}
<div
className="absolute left-0 bottom-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("sw")}
/>
{/* 오른쪽 위 */}
<div
className="absolute right-0 top-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("ne")}
/>
{/* 왼쪽 위 */}
<div
className="absolute left-0 top-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
style={{ pointerEvents: 'auto', zIndex: 10 }}
onMouseDown={startResize("nw")}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
style={{ zIndex: 20 }}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
import { cn } from "@/lib/utils";

View File

@ -42,10 +42,49 @@ export const dataApi = {
*
* @param tableName
* @param id ID
* @param enableEntityJoin Entity (기본값: false)
* @param groupByColumns ()
*/
getRecordDetail: async (tableName: string, id: string | number): Promise<any> => {
const response = await apiClient.get(`/data/${tableName}/${id}`);
return response.data?.data || response.data;
getRecordDetail: async (
tableName: string,
id: string | number,
enableEntityJoin: boolean = false,
groupByColumns: string[] = []
): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
const params: any = {};
if (enableEntityJoin) {
params.enableEntityJoin = true;
}
if (groupByColumns.length > 0) {
params.groupByColumns = JSON.stringify(groupByColumns);
}
console.log("🌐 [dataApi.getRecordDetail] API 호출:", {
tableName,
id,
enableEntityJoin,
groupByColumns,
params,
url: `/data/${tableName}/${id}`,
});
const response = await apiClient.get(`/data/${tableName}/${id}`, { params });
console.log("📥 [dataApi.getRecordDetail] API 응답:", {
success: response.data?.success,
dataType: Array.isArray(response.data?.data) ? "배열" : "객체",
dataCount: Array.isArray(response.data?.data) ? response.data.data.length : 1,
});
return response.data; // { success: true, data: ... } 형식 그대로 반환
} catch (error: any) {
console.error("❌ [dataApi.getRecordDetail] API 오류:", error);
return {
success: false,
error: error.response?.data?.message || error.message || "레코드 조회 실패",
};
}
},
/**
@ -55,6 +94,9 @@ export const dataApi = {
* @param leftColumn
* @param rightColumn ()
* @param leftValue ()
* @param dataFilter
* @param enableEntityJoin Entity
* @param displayColumns (tableName.columnName )
*/
getJoinedData: async (
leftTable: string,
@ -62,7 +104,15 @@ export const dataApi = {
leftColumn: string,
rightColumn: string,
leftValue?: any,
dataFilter?: any, // 🆕 데이터 필터
dataFilter?: any,
enableEntityJoin?: boolean,
displayColumns?: Array<{ name: string; label?: string }>,
deduplication?: { // 🆕 중복 제거 설정
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
},
): Promise<any[]> => {
const response = await apiClient.get(`/data/join`, {
params: {
@ -71,7 +121,10 @@ export const dataApi = {
leftColumn,
rightColumn,
leftValue,
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined,
enableEntityJoin: enableEntityJoin ?? true,
displayColumns: displayColumns ? JSON.stringify(displayColumns) : undefined, // 🆕 표시 컬럼 전달
deduplication: deduplication ? JSON.stringify(deduplication) : undefined, // 🆕 중복 제거 설정 전달
},
});
const raw = response.data || {};
@ -115,4 +168,98 @@ export const dataApi = {
const response = await apiClient.delete(`/data/${tableName}/${id}`);
return response.data; // success, message 포함된 전체 응답 반환
},
/**
* ( )
* @param tableName
* @param filterConditions (: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" })
*/
deleteGroupRecords: async (
tableName: string,
filterConditions: Record<string, any>
): Promise<{ success: boolean; deleted?: number; message?: string; error?: string }> => {
try {
console.log(`🗑️ [dataApi] 그룹 삭제 요청:`, { tableName, filterConditions });
const response = await apiClient.post(`/data/${tableName}/delete-group`, filterConditions);
console.log(`✅ [dataApi] 그룹 삭제 성공:`, response.data);
return response.data;
} catch (error: any) {
console.error(`❌ [dataApi] 그룹 삭제 실패:`, error);
return {
success: false,
error: error.response?.data?.message || error.message || "그룹 삭제 실패",
};
}
},
/**
*
* @param tableName
* @param id ID
* @param enableEntityJoin Entity (기본값: false)
*/
getRecordDetail: async (
tableName: string,
id: string | number,
enableEntityJoin: boolean = false
): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
const params: any = {};
if (enableEntityJoin) {
params.enableEntityJoin = "true";
}
const response = await apiClient.get(`/data/${tableName}/${id}`, { params });
return response.data; // { success: true, data: ... } 형식 그대로 반환
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || error.message || "레코드 조회 실패",
};
}
},
/**
* UPSERT
* @param tableName
* @param parentKeys (: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" })
* @param records
*/
upsertGroupedRecords: async (
tableName: string,
parentKeys: Record<string, any>,
records: Array<Record<string, any>>
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
try {
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
tableName,
tableNameType: typeof tableName,
tableNameValue: JSON.stringify(tableName),
parentKeys,
recordsCount: records.length,
});
const requestBody = {
tableName,
parentKeys,
records,
};
console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2));
const response = await apiClient.post('/data/upsert-grouped', requestBody);
return response.data;
} catch (error: any) {
console.error("❌ [dataApi.upsertGroupedRecords] 에러:", {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
message: error.message,
});
return {
success: false,
error: error.response?.data?.message || error.message || "데이터 저장 실패",
};
}
},
};

View File

@ -71,25 +71,6 @@ export const entityJoinApi = {
dataFilter?: any; // 🆕 데이터 필터
} = {},
): Promise<EntityJoinResponse> => {
const searchParams = new URLSearchParams();
if (params.page) searchParams.append("page", params.page.toString());
if (params.size) searchParams.append("size", params.size.toString());
if (params.sortBy) searchParams.append("sortBy", params.sortBy);
if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder);
if (params.enableEntityJoin !== undefined) {
searchParams.append("enableEntityJoin", params.enableEntityJoin.toString());
}
// 검색 조건 추가
if (params.search) {
Object.entries(params.search).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
searchParams.append(key, String(value));
}
});
}
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
const autoFilter = {
enabled: true,
@ -99,7 +80,11 @@ export const entityJoinApi = {
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
params: {
...params,
page: params.page,
size: params.size,
sortBy: params.sortBy,
sortOrder: params.sortOrder,
enableEntityJoin: params.enableEntityJoin,
search: params.search ? JSON.stringify(params.search) : undefined,
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정

View File

@ -109,7 +109,10 @@ export async function deleteCategoryValue(valueId: number) {
return response.data;
} catch (error: any) {
console.error("카테고리 값 삭제 실패:", error);
return { success: false, error: error.message };
// 백엔드에서 반환한 에러 메시지 전달
const errorMessage = error.response?.data?.message || error.message;
return { success: false, error: errorMessage, message: errorMessage };
}
}

View File

@ -262,7 +262,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id;
const currentValue = formData?.[fieldName] || "";
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue;
if (componentType === "modal-repeater-table") {
currentValue = formData?.[fieldName] || [];
} else {
currentValue = formData?.[fieldName] || "";
}
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => {
@ -274,13 +281,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
if (onFormDataChange) {
// modal-repeater-table은 배열 데이터를 다룸
if (componentType === "modal-repeater-table") {
onFormDataChange(fieldName, actualValue);
}
// RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달
// 단순 input 컴포넌트는 (fieldName, value) 형태로 전달받음
if (componentType === "repeater-field-group" || componentType === "repeater") {
// fieldName과 함께 전달
else if (componentType === "repeater-field-group" || componentType === "repeater") {
onFormDataChange(fieldName, actualValue);
} else {
// 이미 fieldName이 포함된 경우는 그대로 전달
onFormDataChange(fieldName, actualValue);
}
}
@ -308,6 +316,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
style: finalStyle, // size를 포함한 최종 style
config: component.componentConfig,
componentConfig: component.componentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}),
value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달
autoGeneration: component.autoGeneration || component.componentConfig?.autoGeneration,

View File

@ -0,0 +1,225 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { X, Loader2, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { EntitySearchResult } from "../entity-search-input/types";
import { cn } from "@/lib/utils";
import { AutocompleteSearchInputConfig, FieldMapping } from "./types";
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
config?: AutocompleteSearchInputConfig;
filterCondition?: Record<string, any>;
disabled?: boolean;
value?: any;
onChange?: (value: any, fullData?: any) => void;
className?: string;
}
export function AutocompleteSearchInputComponent({
config,
tableName: propTableName,
displayField: propDisplayField,
valueField: propValueField,
searchFields: propSearchFields,
filterCondition = {},
placeholder: propPlaceholder,
disabled = false,
value,
onChange,
showAdditionalInfo: propShowAdditionalInfo,
additionalFields: propAdditionalFields,
className,
}: AutocompleteSearchInputProps) {
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
const displayField = config?.displayField || propDisplayField || "";
const valueField = config?.valueField || propValueField || "";
const searchFields = config?.searchFields || propSearchFields || [displayField];
const placeholder = config?.placeholder || propPlaceholder || "검색...";
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
const additionalFields = config?.additionalFields || propAdditionalFields || [];
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { searchText, setSearchText, results, loading, clearSearch } = useEntitySearch({
tableName,
searchFields,
filterCondition,
});
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setInputValue(selectedData[displayField] || "");
} else if (!value) {
setInputValue("");
setSelectedData(null);
}
}, [value, displayField]);
// 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
setSearchText(newValue);
setIsOpen(true);
};
// 필드 자동 매핑 처리
const applyFieldMappings = (item: EntitySearchResult) => {
if (!config?.enableFieldMapping || !config?.fieldMappings) {
return;
}
config.fieldMappings.forEach((mapping: FieldMapping) => {
if (!mapping.sourceField || !mapping.targetField) {
return;
}
const value = item[mapping.sourceField];
// DOM에서 타겟 필드 찾기 (id로 검색)
const targetElement = document.getElementById(mapping.targetField);
if (targetElement) {
// input, textarea 등의 값 설정
if (
targetElement instanceof HTMLInputElement ||
targetElement instanceof HTMLTextAreaElement
) {
targetElement.value = value?.toString() || "";
// React의 change 이벤트 트리거
const event = new Event("input", { bubbles: true });
targetElement.dispatchEvent(event);
}
}
});
};
const handleSelect = (item: EntitySearchResult) => {
setSelectedData(item);
setInputValue(item[displayField] || "");
onChange?.(item[valueField], item);
// 필드 자동 매핑 실행
applyFieldMappings(item);
setIsOpen(false);
};
const handleClear = () => {
setInputValue("");
setSelectedData(null);
onChange?.(null, null);
setIsOpen(false);
};
const handleInputFocus = () => {
// 포커스 시 항상 검색 실행 (빈 값이면 전체 목록)
if (!selectedData) {
setSearchText(inputValue || "");
setIsOpen(true);
}
};
return (
<div className={cn("relative", className)} ref={containerRef}>
{/* 입력 필드 */}
<div className="relative">
<Input
value={inputValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
/>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{inputValue && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{/* 드롭다운 결과 */}
{isOpen && (results.length > 0 || loading) && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{loading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
...
</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="py-1">
{results.map((item, index) => (
<button
key={index}
type="button"
onClick={() => handleSelect(item)}
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
>
<div className="font-medium">{item[displayField]}</div>
{additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
{additionalFields.map((field) => (
<div key={field}>
{field}: {item[field] || "-"}
</div>
))}
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,801 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { AutocompleteSearchInputConfig, FieldMapping, ValueFieldStorage } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
interface AutocompleteSearchInputConfigPanelProps {
config: AutocompleteSearchInputConfig;
onConfigChange: (config: AutocompleteSearchInputConfig) => void;
}
export function AutocompleteSearchInputConfigPanel({
config,
onConfigChange,
}: AutocompleteSearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
const [openStorageTableCombo, setOpenStorageTableCombo] = useState(false);
const [openStorageColumnCombo, setOpenStorageColumnCombo] = useState(false);
const [storageTableColumns, setStorageTableColumns] = useState<any[]>([]);
const [isLoadingStorageColumns, setIsLoadingStorageColumns] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.tableName) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
// 저장 대상 테이블의 컬럼 목록 로드
useEffect(() => {
const loadStorageColumns = async () => {
const storageTable = localConfig.valueFieldStorage?.targetTable;
if (!storageTable) {
setStorageTableColumns([]);
return;
}
setIsLoadingStorageColumns(true);
try {
const response = await tableManagementApi.getColumnList(storageTable);
if (response.success && response.data) {
setStorageTableColumns(response.data.columns);
}
} catch (error) {
console.error("저장 테이블 컬럼 로드 실패:", error);
setStorageTableColumns([]);
} finally {
setIsLoadingStorageColumns(false);
}
};
loadStorageColumns();
}, [localConfig.valueFieldStorage?.targetTable]);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const addSearchField = () => {
const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.searchFields || [])];
fields[index] = value;
updateConfig({ searchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.searchFields || [])];
fields.splice(index, 1);
updateConfig({ searchFields: fields });
};
const addAdditionalField = () => {
const fields = localConfig.additionalFields || [];
updateConfig({ additionalFields: [...fields, ""] });
};
const updateAdditionalField = (index: number, value: string) => {
const fields = [...(localConfig.additionalFields || [])];
fields[index] = value;
updateConfig({ additionalFields: fields });
};
const removeAdditionalField = (index: number) => {
const fields = [...(localConfig.additionalFields || [])];
fields.splice(index, 1);
updateConfig({ additionalFields: fields });
};
// 필드 매핑 관리 함수
const addFieldMapping = () => {
const mappings = localConfig.fieldMappings || [];
updateConfig({
fieldMappings: [
...mappings,
{ sourceField: "", targetField: "", label: "" },
],
});
};
const updateFieldMapping = (index: number, updates: Partial<FieldMapping>) => {
const mappings = [...(localConfig.fieldMappings || [])];
mappings[index] = { ...mappings[index], ...updates };
updateConfig({ fieldMappings: mappings });
};
const removeFieldMapping = (index: number) => {
const mappings = [...(localConfig.fieldMappings || [])];
mappings.splice(index, 1);
updateConfig({ fieldMappings: mappings });
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.tableName
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({ tableName: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.displayField
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ displayField: column.columnName });
setOpenDisplayFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
(: 거래처명)
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openValueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.valueField
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ valueField: column.columnName });
setOpenValueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
(: customer_code)
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={localConfig.placeholder || ""}
onChange={(e) => updateConfig({ placeholder: e.target.value })}
placeholder="검색..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 값 필드 저장 위치 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> ()</h3>
<p className="text-xs text-muted-foreground">
"값 필드" / .
<br />
.
</p>
</div>
{/* 저장 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Popover open={openStorageTableCombo} onOpenChange={setOpenStorageTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openStorageTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.valueFieldStorage?.targetTable
? allTables.find((t) => t.tableName === localConfig.valueFieldStorage?.targetTable)?.displayName ||
localConfig.valueFieldStorage.targetTable
: "기본값 (화면 연결 테이블)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{/* 기본값 옵션 */}
<CommandItem
value=""
onSelect={() => {
updateConfig({
valueFieldStorage: {
...localConfig.valueFieldStorage,
targetTable: undefined,
targetColumn: undefined,
},
});
setOpenStorageTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", !localConfig.valueFieldStorage?.targetTable ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium"></span>
<span className="text-[10px] text-gray-500"> </span>
</div>
</CommandItem>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({
valueFieldStorage: {
...localConfig.valueFieldStorage,
targetTable: table.tableName,
targetColumn: undefined, // 테이블 변경 시 컬럼 초기화
},
});
setOpenStorageTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueFieldStorage?.targetTable === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
(기본값: 화면 )
</p>
</div>
{/* 저장 컬럼 선택 */}
{localConfig.valueFieldStorage?.targetTable && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Popover open={openStorageColumnCombo} onOpenChange={setOpenStorageColumnCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openStorageColumnCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingStorageColumns}
>
{localConfig.valueFieldStorage?.targetColumn
? storageTableColumns.find((c) => c.columnName === localConfig.valueFieldStorage?.targetColumn)
?.displayName || localConfig.valueFieldStorage.targetColumn
: isLoadingStorageColumns
? "로딩 중..."
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{storageTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({
valueFieldStorage: {
...localConfig.valueFieldStorage,
targetColumn: column.columnName,
},
});
setOpenStorageColumnCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueFieldStorage?.targetColumn === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
{/* 설명 박스 */}
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium mb-2 text-blue-800 dark:text-blue-200">
</p>
<div className="text-[10px] text-blue-700 dark:text-blue-300 space-y-1">
{localConfig.valueFieldStorage?.targetTable ? (
<>
<p>
(<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">{localConfig.valueField}</code>)
</p>
<p>
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{localConfig.valueFieldStorage.targetTable}
</code>{" "}
{" "}
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{localConfig.valueFieldStorage.targetColumn || "(컬럼 미지정)"}
</code>{" "}
.
</p>
</>
) : (
<p>기본값: 화면의 .</p>
)}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.searchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showAdditionalInfo || false}
onCheckedChange={(checked) =>
updateConfig({ showAdditionalInfo: checked })
}
/>
</div>
</div>
{localConfig.showAdditionalInfo && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addAdditionalField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.additionalFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateAdditionalField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeAdditionalField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{/* 필드 자동 매핑 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.enableFieldMapping || false}
onCheckedChange={(checked) =>
updateConfig({ enableFieldMapping: checked })
}
/>
</div>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{localConfig.enableFieldMapping && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addFieldMapping}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{(localConfig.fieldMappings || []).map((mapping, index) => (
<div key={index} className="border rounded-lg p-3 space-y-3 bg-background">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
#{index + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeFieldMapping(index)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 표시명 */}
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={mapping.label || ""}
onChange={(e) =>
updateFieldMapping(index, { label: e.target.value })
}
placeholder="예: 거래처명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground">
()
</p>
</div>
{/* 소스 필드 (테이블의 컬럼) */}
<div className="space-y-1.5">
<Label className="text-xs">
( ) *
</Label>
<Select
value={mapping.sourceField}
onValueChange={(value) =>
updateFieldMapping(index, { sourceField: value })
}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex flex-col">
<span className="font-medium">
{col.displayName || col.columnName}
</span>
{col.displayName && (
<span className="text-[10px] text-gray-500">
{col.columnName}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 타겟 필드 (화면의 input ID) */}
<div className="space-y-1.5">
<Label className="text-xs">
( ID) *
</Label>
<Input
value={mapping.targetField}
onChange={(e) =>
updateFieldMapping(index, { targetField: e.target.value })
}
placeholder="예: customer_name_input"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground">
ID (: input의 id )
</p>
</div>
{/* 예시 설명 */}
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
<p className="text-[10px] text-blue-700 dark:text-blue-300">
{mapping.sourceField && mapping.targetField ? (
<>
<span className="font-semibold">{mapping.label || "이 필드"}</span>: {" "}
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{mapping.sourceField}
</code>{" "}
{" "}
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{mapping.targetField}
</code>{" "}
</>
) : (
"소스 필드와 타겟 필드를 모두 선택하세요"
)}
</p>
</div>
</div>
))}
</div>
{/* 사용 안내 */}
{localConfig.fieldMappings && localConfig.fieldMappings.length > 0 && (
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded border border-amber-200 dark:border-amber-800">
<p className="text-xs font-medium mb-2 text-amber-800 dark:text-amber-200">
</p>
<ul className="text-[10px] text-amber-700 dark:text-amber-300 space-y-1 list-disc list-inside">
<li> </li>
<li> </li>
<li> ID는 ID와 </li>
</ul>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AutocompleteSearchInputDefinition } from "./index";
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
/**
* AutocompleteSearchInput
*
*/
export class AutocompleteSearchInputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = AutocompleteSearchInputDefinition;
render(): React.ReactElement {
return <AutocompleteSearchInputComponent {...this.props} renderer={this} />;
}
/**
*
*/
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
AutocompleteSearchInputRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
AutocompleteSearchInputRenderer.enableHotReload();
}

View File

@ -0,0 +1,114 @@
# AutocompleteSearchInput 컴포넌트
자동완성 드롭다운 방식의 엔티티 검색 입력 컴포넌트입니다.
## 특징
- 타이핑하면 즉시 드롭다운 표시
- 빈 값일 때 전체 목록 조회
- 추가 정보 표시 가능
- X 버튼으로 선택 초기화
- 외부 클릭 시 자동 닫힘
- **필드 자동 매핑**: 선택한 항목의 값을 화면의 다른 입력 필드에 자동으로 채움
## 사용 예시
### 기본 사용
```tsx
<AutocompleteSearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code"]}
placeholder="거래처명 입력"
showAdditionalInfo
additionalFields={["customer_code", "address"]}
value={selectedCode}
onChange={(code, fullData) => {
console.log("선택됨:", code, fullData);
}}
/>
```
### 필드 자동 매핑 사용
```tsx
<AutocompleteSearchInputComponent
config={{
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
placeholder: "거래처 검색",
enableFieldMapping: true,
fieldMappings: [
{
sourceField: "customer_name", // 테이블의 컬럼명
targetField: "customer_name_input", // 화면 input의 id
label: "거래처명"
},
{
sourceField: "address",
targetField: "address_input",
label: "주소"
},
{
sourceField: "phone",
targetField: "phone_input",
label: "전화번호"
}
]
}}
onChange={(code, fullData) => {
console.log("선택됨:", code, fullData);
// 필드 매핑은 자동으로 실행됩니다
}}
/>
<!-- 화면의 다른 곳에 있는 입력 필드들 -->
<input id="customer_name_input" placeholder="거래처명" />
<input id="address_input" placeholder="주소" />
<input id="phone_input" placeholder="전화번호" />
```
## 설정 옵션
### 기본 설정
- `tableName`: 검색할 테이블명
- `displayField`: 표시할 필드
- `valueField`: 값으로 사용할 필드
- `searchFields`: 검색 대상 필드들
- `placeholder`: 플레이스홀더
- `showAdditionalInfo`: 추가 정보 표시 여부
- `additionalFields`: 추가로 표시할 필드들
### 값 필드 저장 위치 설정 (고급)
- `valueFieldStorage`: 값 필드 저장 위치 지정
- `targetTable`: 저장할 테이블명 (미설정 시 화면 연결 테이블)
- `targetColumn`: 저장할 컬럼명 (미설정 시 바인딩 필드)
### 필드 자동 매핑 설정
- `enableFieldMapping`: 필드 자동 매핑 활성화 여부
- `fieldMappings`: 매핑할 필드 목록
- `sourceField`: 소스 테이블의 컬럼명 (예: customer_name)
- `targetField`: 타겟 필드 ID (예: 화면의 input id 속성)
- `label`: 표시명 (선택사항)
## 필드 자동 매핑 동작 방식
1. 사용자가 검색 컴포넌트에서 항목을 선택합니다
2. 선택된 항목의 데이터에서 `sourceField`에 해당하는 값을 가져옵니다
3. 화면에서 `targetField` ID를 가진 컴포넌트를 찾습니다
4. 해당 컴포넌트에 값을 자동으로 채워넣습니다
5. React의 change 이벤트를 트리거하여 상태 업데이트를 유발합니다
## 주의사항
- 타겟 필드 ID는 화면 디자이너에서 설정한 컴포넌트 ID와 정확히 일치해야 합니다
- 필드 매핑은 input, textarea 타입의 요소에만 동작합니다
- 여러 필드를 한 번에 매핑할 수 있습니다

View File

@ -0,0 +1,43 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
import { AutocompleteSearchInputConfigPanel } from "./AutocompleteSearchInputConfigPanel";
/**
* AutocompleteSearchInput
*
*/
export const AutocompleteSearchInputDefinition = createComponentDefinition({
id: "autocomplete-search-input",
name: "자동완성 검색 입력",
nameEng: "Autocomplete Search Input",
description: "타이핑하면 드롭다운이 나타나는 엔티티 검색 입력 (거래처, 사용자 등)",
category: ComponentCategory.INPUT,
webType: "entity",
component: AutocompleteSearchInputComponent,
defaultConfig: {
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
placeholder: "검색...",
showAdditionalInfo: false,
additionalFields: [],
},
defaultSize: { width: 300, height: 40 },
configPanel: AutocompleteSearchInputConfigPanel,
icon: "Search",
tags: ["검색", "자동완성", "엔티티", "드롭다운", "거래처"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { AutocompleteSearchInputConfig } from "./types";
// 컴포넌트 내보내기
export { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
export { AutocompleteSearchInputRenderer } from "./AutocompleteSearchInputRenderer";

View File

@ -0,0 +1,31 @@
// 값 필드 저장 설정
export interface ValueFieldStorage {
targetTable?: string; // 저장할 테이블명 (기본값: 화면의 연결 테이블)
targetColumn?: string; // 저장할 컬럼명 (기본값: 바인딩 필드)
}
// 필드 매핑 설정
export interface FieldMapping {
sourceField: string; // 소스 테이블의 컬럼명 (예: customer_name)
targetField: string; // 매핑될 타겟 필드 ID (예: 화면의 input ID)
label?: string; // 표시명
targetTable?: string; // 저장할 테이블 (선택사항, 기본값은 화면 연결 테이블)
targetColumn?: string; // 저장할 컬럼명 (선택사항, targetField가 화면 ID가 아닌 경우)
}
export interface AutocompleteSearchInputConfig {
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
placeholder?: string;
showAdditionalInfo?: boolean;
additionalFields?: string[];
// 값 필드 저장 위치 설정
valueFieldStorage?: ValueFieldStorage;
// 필드 자동 매핑 설정
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
}

View File

@ -0,0 +1,300 @@
# AutocompleteSearchInput 컴포넌트 사용 가이드
## 📌 이 컴포넌트는 무엇인가요?
검색 가능한 드롭다운 선택 박스입니다.
거래처, 품목, 직원 등을 검색해서 선택할 때 사용합니다.
---
## ⚙️ 패널 설정 방법
### 1. 기본 검색 설정 (필수)
#### 테이블명
- **의미**: 어디서 검색할 것인가?
- **예시**: `customer_mng` (거래처 테이블)
#### 표시 필드
- **의미**: 사용자에게 무엇을 보여줄 것인가?
- **예시**: `customer_name` → 화면에 "삼성전자" 표시
#### 값 필드
- **의미**: 실제로 어떤 값을 가져올 것인가?
- **예시**: `customer_code` → "CUST-0001" 가져오기
#### 검색 필드 (선택)
- **의미**: 어떤 컬럼으로 검색할 것인가?
- **예시**: `customer_name`, `customer_code` 추가
- **동작**: 이름으로도 검색, 코드로도 검색 가능
---
### 2. 값 필드 저장 위치 (고급, 선택)
#### 저장 테이블
- **기본값**: 화면의 연결 테이블에 자동 저장
- **변경 시**: 다른 테이블에 저장 가능
#### 저장 컬럼
- **기본값**: 컴포넌트의 바인딩 필드
- **변경 시**: 다른 컬럼에 저장 가능
> 💡 **대부분은 기본값을 사용하면 됩니다!**
---
## 📖 사용 예제
### 예제 1: 거래처 선택 (가장 일반적)
#### 패널 설정
```
테이블명: customer_mng
표시 필드: customer_name
값 필드: customer_code
검색 필드: customer_name, customer_code
플레이스홀더: 거래처명 또는 코드 입력
```
#### 동작
```
사용자 입력: "삼성"
드롭다운 표시: "삼성전자", "삼성물산", ...
선택: "삼성전자"
저장 값: "CUST-0001" (customer_code)
```
#### 결과
```
order_mng 테이블
┌───────────┬───────────────┐
│ order_id │ customer_code │
├───────────┼───────────────┤
│ ORD-0001 │ CUST-0001 │ ✅
└───────────┴───────────────┘
```
---
### 예제 2: 거래처명을 직접 저장
#### 패널 설정
```
테이블명: customer_mng
표시 필드: customer_name
값 필드: customer_name ← 이름을 가져옴
플레이스홀더: 거래처명 입력
```
#### 동작
```
사용자 선택: "삼성전자"
저장 값: "삼성전자" (customer_name)
```
#### 결과
```
order_mng 테이블
┌───────────┬───────────────┐
│ order_id │ customer_name │
├───────────┼───────────────┤
│ ORD-0001 │ 삼성전자 │ ✅
└───────────┴───────────────┘
```
---
### 예제 3: 품목 선택 (추가 정보 표시)
#### 패널 설정
```
테이블명: item_mng
표시 필드: item_name
값 필드: item_code
검색 필드: item_name, item_code, category
플레이스홀더: 품목명, 코드, 카테고리로 검색
추가 정보 표시: ON
추가 필드: item_code, unit_price
```
#### 동작
```
드롭다운:
┌────────────────────────────┐
│ 삼성 노트북 │
│ item_code: ITEM-0123 │
│ unit_price: 1,500,000 │
├────────────────────────────┤
│ LG 그램 노트북 │
│ item_code: ITEM-0124 │
│ unit_price: 1,800,000 │
└────────────────────────────┘
```
---
## 🎯 필드 선택 가이드
### 언제 표시 필드 ≠ 값 필드 인가?
**대부분의 경우 (권장)**
```
표시 필드: customer_name (이름 - 사람이 읽기 쉬움)
값 필드: customer_code (코드 - 데이터베이스에 저장)
이유:
✅ 외래키 관계 유지
✅ 데이터 무결성
✅ 이름이 바뀌어도 코드는 그대로
```
### 언제 표시 필드 = 값 필드 인가?
**특수한 경우**
```
표시 필드: customer_name
값 필드: customer_name
사용 케이스:
- 이름 자체를 저장해야 할 때
- 외래키가 필요 없을 때
- 간단한 참조용 데이터
```
---
## 💡 자주 묻는 질문
### Q1. 저장 위치를 설정하지 않으면?
**A**: 자동으로 화면의 연결 테이블에 바인딩 필드로 저장됩니다.
```
화면: 수주 등록 (연결 테이블: order_mng)
컴포넌트 바인딩 필드: customer_code
→ order_mng.customer_code에 자동 저장 ✅
```
---
### Q2. 값 필드와 저장 위치의 차이는?
**A**:
- **값 필드**: 검색 테이블에서 무엇을 가져올지
- **저장 위치**: 가져온 값을 어디에 저장할지
```
값 필드: customer_mng.customer_code (어떤 값?)
저장 위치: order_mng.customer_code (어디에?)
```
---
### Q3. 검색 필드는 왜 여러 개 추가하나요?
**A**: 여러 방법으로 검색할 수 있게 하기 위해서입니다.
```
검색 필드: [customer_name, customer_code]
사용자가 "삼성" 입력 → customer_name에서 검색
사용자가 "CUST" 입력 → customer_code에서 검색
```
---
### Q4. 추가 정보 표시는 언제 사용하나요?
**A**: 선택할 때 참고할 정보를 함께 보여주고 싶을 때 사용합니다.
```
추가 정보 표시: ON
추가 필드: [address, phone]
드롭다운:
┌────────────────────────────┐
│ 삼성전자 │
│ address: 서울시 서초구 │
│ phone: 02-1234-5678 │
└────────────────────────────┘
```
---
## 🚀 빠른 시작
### 1단계: 기본 설정만 입력
```
테이블명: [검색할 테이블]
표시 필드: [사용자에게 보여줄 컬럼]
값 필드: [저장할 컬럼]
```
### 2단계: 화면 디자이너에서 바인딩 필드 설정
```
컴포넌트 ID: customer_search
바인딩 필드: customer_code
```
### 3단계: 완료!
이제 사용자가 선택하면 자동으로 저장됩니다.
---
## ✅ 체크리스트
설정 전:
- [ ] 어느 테이블에서 검색할지 알고 있나요?
- [ ] 사용자에게 무엇을 보여줄지 정했나요?
- [ ] 어떤 값을 저장할지 정했나요?
설정 후:
- [ ] 검색이 정상적으로 되나요?
- [ ] 드롭다운에 원하는 항목이 보이나요?
- [ ] 선택 후 값이 저장되나요?
---
## 📊 설정 패턴 비교
| 패턴 | 표시 필드 | 값 필드 | 사용 케이스 |
|------|----------|---------|------------|
| 1 | customer_name | customer_code | 이름 표시, 코드 저장 (일반적) |
| 2 | customer_name | customer_name | 이름 표시, 이름 저장 (특수) |
| 3 | item_name | item_code | 품목명 표시, 품목코드 저장 |
| 4 | employee_name | employee_id | 직원명 표시, ID 저장 |
---
## 🎨 실전 팁
### 1. 검색 필드는 2-3개가 적당
```
✅ 좋음: [name, code]
✅ 좋음: [name, code, category]
❌ 과함: [name, code, address, phone, email, ...]
```
### 2. 플레이스홀더는 구체적으로
```
❌ "검색..."
✅ "거래처명 또는 코드 입력"
✅ "품목명, 코드, 카테고리로 검색"
```
### 3. 추가 정보는 선택에 도움되는 것만
```
✅ 도움됨: 가격, 주소, 전화번호
❌ 불필요: 등록일, 수정일, ID
```
---
이 가이드로 autocomplete-search-input 컴포넌트를 쉽게 사용할 수 있습니다! 🎉

View File

@ -52,6 +52,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
}
/**
@ -88,6 +91,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
selectedRowsData,
flowSelectedData,
flowSelectedStepId,
allComponents, // 🆕 같은 화면의 모든 컴포넌트
...props
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
@ -389,6 +393,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return;
}
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
const componentConfigs: Record<string, any> = {};
if (allComponents && Array.isArray(allComponents)) {
for (const comp of allComponents) {
if (comp.id && comp.componentConfig) {
componentConfigs[comp.id] = comp.componentConfig;
}
}
}
const context: ButtonActionContext = {
formData: formData || {},
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
@ -409,10 +423,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
sortOrder, // 🆕 정렬 방향
columnOrder, // 🆕 컬럼 순서
tableDisplayData, // 🆕 화면에 표시된 데이터
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents,
// 플로우 선택된 데이터 정보 추가
flowSelectedData,
flowSelectedStepId,
};
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
componentConfigs,
} as ButtonActionContext;
// 확인이 필요한 액션인지 확인
if (confirmationRequiredActions.includes(processedConfig.action.type)) {

View File

@ -1,77 +0,0 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ButtonPrimaryConfig } from "./types";
export interface ButtonPrimaryConfigPanelProps {
config: ButtonPrimaryConfig;
onChange: (config: Partial<ButtonPrimaryConfig>) => void;
}
/**
* ButtonPrimary
* UI
*/
export const ButtonPrimaryConfigPanel: React.FC<ButtonPrimaryConfigPanelProps> = ({ config, onChange }) => {
const handleChange = (key: keyof ButtonPrimaryConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">button-primary </div>
{/* 버튼 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="text"> </Label>
<Input id="text" value={config.text || ""} onChange={(e) => handleChange("text", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="actionType"> </Label>
<Select value={config.actionType || "button"} onValueChange={(value) => handleChange("actionType", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="button">Button</SelectItem>
<SelectItem value="submit">Submit</SelectItem>
<SelectItem value="reset">Reset</SelectItem>
</SelectContent>
</Select>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
);
};

View File

@ -5,7 +5,6 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
import { ButtonPrimaryConfigPanel } from "./ButtonPrimaryConfigPanel";
import { ButtonPrimaryConfig } from "./types";
/**
@ -31,7 +30,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
},
},
defaultSize: { width: 120, height: 40 },
configPanel: ButtonPrimaryConfigPanel,
configPanel: undefined, // 상세 설정 패널(ButtonConfigPanel)이 대신 사용됨
icon: "MousePointer",
tags: ["버튼", "액션", "클릭"],
version: "1.0.0",

View File

@ -0,0 +1,216 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConditionalContainerProps, ConditionalSection } from "./types";
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
import { cn } from "@/lib/utils";
console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
/**
*
* UI를
*/
export function ConditionalContainerComponent({
config,
controlField: propControlField,
controlLabel: propControlLabel,
sections: propSections,
defaultValue: propDefaultValue,
showBorder: propShowBorder,
spacing: propSpacing,
value,
onChange,
formData,
onFormDataChange,
isDesignMode = false,
onUpdateComponent,
onDeleteComponent,
onSelectComponent,
selectedComponentId,
onHeightChange,
componentId,
style,
className,
}: ConditionalContainerProps) {
console.log("🎯 ConditionalContainerComponent 렌더링!", {
isDesignMode,
hasOnHeightChange: !!onHeightChange,
componentId,
});
// config prop 우선, 없으면 개별 prop 사용
const controlField = config?.controlField || propControlField || "condition";
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
const sections = config?.sections || propSections || [];
const defaultValue = config?.defaultValue || propDefaultValue || sections[0]?.condition;
const showBorder = config?.showBorder ?? propShowBorder ?? true;
const spacing = config?.spacing || propSpacing || "normal";
// 현재 선택된 값
const [selectedValue, setSelectedValue] = useState<string>(
value || formData?.[controlField] || defaultValue || ""
);
// formData 변경 시 동기화
useEffect(() => {
if (formData?.[controlField]) {
setSelectedValue(formData[controlField]);
}
}, [formData, controlField]);
// 값 변경 핸들러
const handleValueChange = (newValue: string) => {
setSelectedValue(newValue);
if (onChange) {
onChange(newValue);
}
if (onFormDataChange) {
onFormDataChange(controlField, newValue);
}
};
// 컨테이너 높이 측정용 ref
const containerRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number>(0);
// 🔍 디버그: props 확인
useEffect(() => {
console.log("🔍 ConditionalContainer props:", {
isDesignMode,
hasOnHeightChange: !!onHeightChange,
componentId,
selectedValue,
});
}, [isDesignMode, onHeightChange, componentId, selectedValue]);
// 높이 변화 감지 및 콜백 호출
useEffect(() => {
console.log("🔍 ResizeObserver 등록 조건:", {
hasContainer: !!containerRef.current,
isDesignMode,
hasOnHeightChange: !!onHeightChange,
});
if (!containerRef.current || isDesignMode || !onHeightChange) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
// 높이가 실제로 변경되었을 때만 콜백 호출
if (Math.abs(newHeight - previousHeightRef.current) > 5) {
console.log(`📏 조건부 컨테이너 높이 변화: ${previousHeightRef.current}px → ${newHeight}px`);
previousHeightRef.current = newHeight;
onHeightChange(newHeight);
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [isDesignMode, onHeightChange, selectedValue]); // selectedValue 변경 시에도 감지
// 간격 스타일
const spacingClass = {
tight: "space-y-2",
normal: "space-y-4",
loose: "space-y-8",
}[spacing];
return (
<div
ref={containerRef}
className={cn("w-full flex flex-col", spacingClass, className)}
style={style}
>
{/* 제어 셀렉트박스 */}
<div className="space-y-2 flex-shrink-0">
<Label htmlFor={controlField} className="text-xs sm:text-sm">
{controlLabel}
</Label>
<Select value={selectedValue} onValueChange={handleValueChange}>
<SelectTrigger
id={controlField}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{sections.map((section) => (
<SelectItem key={section.id} value={section.condition}>
{section.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조건별 섹션들 */}
<div className="flex-1 min-h-0">
{isDesignMode ? (
// 디자인 모드: 모든 섹션 표시
<div className={spacingClass}>
{sections.map((section) => (
<ConditionalSectionViewer
key={section.id}
sectionId={section.id}
condition={section.condition}
label={section.label}
screenId={section.screenId}
screenName={section.screenName}
isActive={selectedValue === section.condition}
isDesignMode={isDesignMode}
showBorder={showBorder}
formData={formData}
onFormDataChange={onFormDataChange}
/>
))}
</div>
) : (
// 실행 모드: 활성 섹션만 표시
sections.map((section) =>
selectedValue === section.condition ? (
<ConditionalSectionViewer
key={section.id}
sectionId={section.id}
condition={section.condition}
label={section.label}
screenId={section.screenId}
screenName={section.screenName}
isActive={true}
isDesignMode={false}
showBorder={showBorder}
formData={formData}
onFormDataChange={onFormDataChange}
/>
) : null
)
)}
{/* 섹션이 없는 경우 안내 */}
{sections.length === 0 && isDesignMode && (
<div className="flex items-center justify-center min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg bg-muted/20">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,343 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
import { ConditionalContainerConfig, ConditionalSection } from "./types";
import { screenApi } from "@/lib/api/screen";
interface ConditionalContainerConfigPanelProps {
config: ConditionalContainerConfig;
onConfigChange: (config: ConditionalContainerConfig) => void;
}
export function ConditionalContainerConfigPanel({
config,
onConfigChange,
}: ConditionalContainerConfigPanelProps) {
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
controlField: config.controlField || "condition",
controlLabel: config.controlLabel || "조건 선택",
sections: config.sections || [],
defaultValue: config.defaultValue || "",
showBorder: config.showBorder ?? true,
spacing: config.spacing || "normal",
});
// 화면 목록 상태
const [screens, setScreens] = useState<any[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
// 화면 목록 로드
useEffect(() => {
const loadScreens = async () => {
setScreensLoading(true);
try {
const response = await screenApi.getScreens({ page: 1, size: 1000 });
if (response.data) {
setScreens(response.data);
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setScreensLoading(false);
}
};
loadScreens();
}, []);
// 설정 업데이트 헬퍼
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
// 새 섹션 추가
const addSection = () => {
const newSection: ConditionalSection = {
id: `section_${Date.now()}`,
condition: `condition_${localConfig.sections.length + 1}`,
label: `조건 ${localConfig.sections.length + 1}`,
screenId: null,
screenName: undefined,
};
updateConfig({
sections: [...localConfig.sections, newSection],
});
};
// 섹션 삭제
const removeSection = (sectionId: string) => {
updateConfig({
sections: localConfig.sections.filter((s) => s.id !== sectionId),
});
};
// 섹션 업데이트
const updateSection = (
sectionId: string,
updates: Partial<ConditionalSection>
) => {
updateConfig({
sections: localConfig.sections.map((s) =>
s.id === sectionId ? { ...s, ...updates } : s
),
});
};
return (
<div className="space-y-6 p-4">
<div>
<h3 className="text-sm font-semibold mb-4"> </h3>
{/* 제어 필드 설정 */}
<div className="space-y-4 mb-6">
<div className="space-y-2">
<Label htmlFor="controlField" className="text-xs">
</Label>
<Input
id="controlField"
value={localConfig.controlField}
onChange={(e) => updateConfig({ controlField: e.target.value })}
placeholder="예: inputMode"
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
formData에
</p>
</div>
<div className="space-y-2">
<Label htmlFor="controlLabel" className="text-xs">
</Label>
<Input
id="controlLabel"
value={localConfig.controlLabel}
onChange={(e) => updateConfig({ controlLabel: e.target.value })}
placeholder="예: 입력 방식"
className="h-8 text-xs"
/>
</div>
</div>
{/* 조건별 섹션 설정 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
onClick={addSection}
size="sm"
variant="outline"
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{localConfig.sections.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed rounded-lg">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-3">
{localConfig.sections.map((section, index) => (
<div
key={section.id}
className="p-3 border rounded-lg space-y-3 bg-muted/20"
>
{/* 섹션 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">
{index + 1}
</span>
</div>
<Button
onClick={() => removeSection(section.id)}
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 조건 값 */}
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">
()
</Label>
<Input
value={section.condition}
onChange={(e) =>
updateSection(section.id, { condition: e.target.value })
}
placeholder="예: customer_first"
className="h-7 text-xs"
/>
</div>
{/* 조건 라벨 */}
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">
</Label>
<Input
value={section.label}
onChange={(e) =>
updateSection(section.id, { label: e.target.value })
}
placeholder="예: 거래처 우선"
className="h-7 text-xs"
/>
</div>
{/* 화면 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
</Label>
{screensLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground h-7 px-3 border rounded">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={section.screenId?.toString() || "none"}
onValueChange={(value) => {
if (value === "none") {
updateSection(section.id, {
screenId: null,
screenName: undefined,
});
} else {
const screenId = parseInt(value);
const selectedScreen = screens.find(
(s) => s.screenId === screenId
);
updateSection(section.id, {
screenId,
screenName: selectedScreen?.screenName,
});
}
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="화면 선택..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{screens.map((screen) => (
<SelectItem
key={screen.screenId}
value={screen.screenId.toString()}
>
{screen.screenName}
{screen.description && (
<span className="text-[10px] text-muted-foreground ml-1">
({screen.description})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{section.screenId && (
<div className="text-[10px] text-muted-foreground">
ID: {section.screenId}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 기본값 설정 */}
{localConfig.sections.length > 0 && (
<div className="space-y-2 mt-4">
<Label htmlFor="defaultValue" className="text-xs">
</Label>
<Select
value={localConfig.defaultValue || ""}
onValueChange={(value) => updateConfig({ defaultValue: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
{localConfig.sections.map((section) => (
<SelectItem key={section.id} value={section.condition}>
{section.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 스타일 설정 */}
<div className="space-y-4 mt-6 pt-6 border-t">
<Label className="text-xs font-semibold"> </Label>
{/* 테두리 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="showBorder" className="text-xs">
</Label>
<Switch
id="showBorder"
checked={localConfig.showBorder}
onCheckedChange={(checked) =>
updateConfig({ showBorder: checked })
}
/>
</div>
{/* 간격 설정 */}
<div className="space-y-2">
<Label htmlFor="spacing" className="text-xs">
</Label>
<Select
value={localConfig.spacing || "normal"}
onValueChange={(value: any) => updateConfig({ spacing: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tight"></SelectItem>
<SelectItem value="normal"></SelectItem>
<SelectItem value="loose"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import React from "react";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import ConditionalContainerDefinition from "./index";
import { ConditionalContainerComponent } from "./ConditionalContainerComponent";
import { ConditionalContainerConfigPanel } from "./ConditionalContainerConfigPanel";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent({
...ConditionalContainerDefinition,
component: ConditionalContainerComponent,
renderer: ConditionalContainerComponent,
configPanel: ConditionalContainerConfigPanel,
} as any);
}
export { ConditionalContainerComponent };

View File

@ -0,0 +1,156 @@
"use client";
import React, { useState, useEffect } from "react";
import { ConditionalSectionViewerProps } from "./types";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
/**
*
*
*/
export function ConditionalSectionViewer({
sectionId,
condition,
label,
screenId,
screenName,
isActive,
isDesignMode,
showBorder = true,
formData,
onFormDataChange,
}: ConditionalSectionViewerProps) {
const [isLoading, setIsLoading] = useState(false);
const [components, setComponents] = useState<ComponentData[]>([]);
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null);
// 화면 로드
useEffect(() => {
if (!screenId) {
setComponents([]);
setScreenInfo(null);
setScreenResolution(null);
return;
}
const loadScreen = async () => {
setIsLoading(true);
try {
const [layout, screen] = await Promise.all([screenApi.getLayout(screenId), screenApi.getScreen(screenId)]);
setComponents(layout.components || []);
setScreenInfo({
id: screenId,
tableName: screen.tableName,
});
setScreenResolution(layout.screenResolution || null);
} catch (error) {
console.error("화면 로드 실패:", error);
setComponents([]);
setScreenInfo(null);
setScreenResolution(null);
} finally {
setIsLoading(false);
}
};
loadScreen();
}, [screenId]);
// 디자인 모드가 아니고 비활성 섹션이면 렌더링하지 않음
if (!isDesignMode && !isActive) {
return null;
}
return (
<div
className={cn(
"relative w-full transition-all",
isDesignMode && showBorder && "border-muted-foreground/30 bg-muted/20 rounded-lg border-2 border-dashed",
!isDesignMode && !isActive && "hidden",
)}
style={{
minHeight: isDesignMode ? "200px" : undefined,
}}
data-section-id={sectionId}
>
{/* 섹션 라벨 (디자인 모드에서만 표시) */}
{isDesignMode && (
<div className="bg-background text-muted-foreground absolute -top-3 left-4 z-10 px-2 text-xs font-medium">
{label} {isActive && "(활성)"}
{screenId && ` - 화면 ID: ${screenId}`}
</div>
)}
{/* 화면 미선택 안내 (디자인 모드 + 화면 없을 때) */}
{isDesignMode && !screenId && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-muted-foreground text-center">
<p className="text-sm"> </p>
<p className="mt-1 text-xs">: {condition}</p>
</div>
</div>
)}
{/* 로딩 중 */}
{isLoading && (
<div className="bg-background/50 absolute inset-0 z-20 flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<p className="text-muted-foreground text-xs"> ...</p>
</div>
</div>
)}
{/* 화면 렌더링 */}
{screenId && components.length > 0 && (
<>
{isDesignMode ? (
/* 디자인 모드: 화면 정보만 표시 */
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="text-foreground mb-2 text-sm font-medium">{screenName || `화면 ID: ${screenId}`}</p>
<p className="text-muted-foreground text-xs">
{screenResolution?.width} x {screenResolution?.height}
</p>
<p className="text-muted-foreground mt-1 text-xs"> {components.length}</p>
</div>
</div>
) : (
/* 실행 모드: 실제 화면 렌더링 */
<div className="w-full">
{/* 화면 크기만큼의 절대 위치 캔버스 */}
<div
className="relative mx-auto"
style={{
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
minHeight: "200px",
}}
>
{components.map((component) => (
<RealtimePreview
key={component.id}
component={component}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
formData={formData}
onFormDataChange={onFormDataChange}
/>
))}
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,293 @@
# 조건부 컨테이너 (ConditionalContainer) - 화면 선택 방식
제어 셀렉트박스 값에 따라 다른 **화면**을 표시하는 조건부 컨테이너 컴포넌트입니다.
## 📋 개요
화면 편집기에서 조건별로 표시할 화면을 선택하여 조건부 UI를 구성할 수 있는 컨테이너입니다. 상단의 셀렉트박스 값에 따라 하단에 미리 만들어진 화면을 표시합니다.
## ✨ 주요 기능
- ✅ **조건별 화면 전환**: 셀렉트박스 값에 따라 다른 화면 표시
- ✅ **화면 재사용**: 기존에 만든 화면을 조건별로 할당
- ✅ **간편한 구성**: 복잡한 입력 폼도 화면 선택으로 간단히 구성
- ✅ **자동 동기화**: 화면 수정 시 자동 반영
- ✅ **폼 데이터 연동**: formData와 자동 동기화
- ✅ **커스터마이징**: 테두리, 간격, 기본값 등 설정 가능
## 🎯 사용 사례
### 1. 입력 방식 선택
```
[셀렉트: 입력 방식]
├─ 거래처 우선: "거래처_우선_입력_화면" (화면 ID: 101)
├─ 견적서 기반: "견적서_업로드_화면" (화면 ID: 102)
└─ 단가 직접입력: "단가_직접입력_화면" (화면 ID: 103)
```
### 2. 판매 유형 선택
```
[셀렉트: 판매 유형]
├─ 국내 판매: "국내판매_기본폼" (화면 ID: 201)
└─ 해외 판매: "해외판매_무역정보폼" (화면 ID: 202)
```
### 3. 문서 유형 선택
```
[셀렉트: 문서 유형]
├─ 신규 작성: "신규문서_입력폼" (화면 ID: 301)
├─ 복사 생성: "문서복사_화면" (화면 ID: 302)
└─ 불러오기: "파일업로드_화면" (화면 ID: 303)
```
## 📐 구조
```
┌─────────────────────────────────┐
│ ConditionalContainer │
├─────────────────────────────────┤
│ [제어 셀렉트박스] │ ← controlField, controlLabel
├─────────────────────────────────┤
│ 📄 조건 1: "옵션 A" 선택 시 │ ← sections[0]
│ ┌─────────────────────────────┐│
│ │ [선택된 화면이 표시됨] ││ ← screenId로 지정된 화면
│ │ (화면 ID: 101) ││
│ │ ││
│ └─────────────────────────────┘│
├─────────────────────────────────┤
│ 📄 조건 2: "옵션 B" 선택 시 │ ← sections[1]
│ ┌─────────────────────────────┐│
│ │ [다른 화면이 표시됨] ││ ← screenId로 지정된 다른 화면
│ │ (화면 ID: 102) ││
│ └─────────────────────────────┘│
└─────────────────────────────────┘
```
## 🔧 설정 방법
### 1. 컴포넌트 추가
화면 편집기의 컴포넌트 패널에서 **"조건부 컨테이너"**를 드래그하여 캔버스에 배치합니다.
### 2. 설정 패널에서 구성
#### 제어 필드 설정
- **제어 필드명**: formData에 저장될 필드명 (예: `inputMode`)
- **셀렉트박스 라벨**: 화면에 표시될 라벨 (예: "입력 방식")
#### 조건별 섹션 추가
1. **"섹션 추가"** 버튼 클릭
2. 각 섹션 설정:
- **조건 값**: 고유한 값 (예: `customer_first`)
- **표시 라벨**: 사용자에게 보이는 텍스트 (예: "거래처 우선")
#### 기본값 설정
- 처음 화면 로드 시 선택될 기본 조건 선택
#### 스타일 설정
- **섹션 테두리 표시**: ON/OFF
- **섹션 간격**: 좁게 / 보통 / 넓게
### 3. 조건별 화면 선택
1. **디자인 모드**에서 모든 조건 섹션이 표시됩니다
2. 각 섹션의 **"표시할 화면"** 드롭다운에서 화면을 선택합니다
3. 선택된 화면 ID와 이름이 자동으로 저장됩니다
**장점:**
- ✅ 이미 만든 화면을 재사용
- ✅ 복잡한 입력 폼도 간단히 구성
- ✅ 화면 수정 시 자동 반영
### 4. 실행 모드 동작
- 셀렉트박스에서 조건 선택
- 선택된 조건의 **화면**이 표시됨
- 다른 조건의 화면은 자동으로 숨김
## 💻 기술 사양
### Props
```typescript
interface ConditionalContainerProps {
// 제어 필드
controlField: string; // 예: "inputMode"
controlLabel: string; // 예: "입력 방식"
// 조건별 섹션
sections: ConditionalSection[];
// 기본값
defaultValue?: string;
// 스타일
showBorder?: boolean; // 기본: true
spacing?: "tight" | "normal" | "loose"; // 기본: "normal"
// 폼 연동
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
}
interface ConditionalSection {
id: string; // 고유 ID
condition: string; // 조건 값
label: string; // 표시 라벨
screenId: number | null; // 표시할 화면 ID
screenName?: string; // 화면 이름 (표시용)
}
```
### 기본 설정
```typescript
defaultSize: {
width: 800,
height: 600,
}
defaultConfig: {
controlField: "condition",
controlLabel: "조건 선택",
sections: [
{
id: "section_1",
condition: "option1",
label: "옵션 1",
screenId: null, // 화면 미선택 상태
},
{
id: "section_2",
condition: "option2",
label: "옵션 2",
screenId: null, // 화면 미선택 상태
},
],
defaultValue: "option1",
showBorder: true,
spacing: "normal",
}
```
## 🎨 디자인 모드 vs 실행 모드
### 디자인 모드 (편집기)
- ✅ 모든 조건 섹션 표시
- ✅ 각 섹션에 "조건: XXX" 라벨 표시
- ✅ 화면 선택 안내 메시지 (미선택 시)
- ✅ 선택된 화면 ID 표시
- ✅ 활성 조건 "(활성)" 표시
### 실행 모드 (할당된 화면)
- ✅ 선택된 조건의 화면만 표시
- ✅ 다른 조건의 화면 자동 숨김
- ✅ 깔끔한 UI (라벨, 점선 테두리 제거)
- ✅ 선택된 화면이 완전히 통합되어 표시
## 📊 폼 데이터 연동
### 자동 동기화
```typescript
// formData 읽기
formData[controlField] // 현재 선택된 값
// formData 쓰기
onFormDataChange(controlField, newValue)
```
### 예시
```typescript
// controlField = "salesType"
formData = {
salesType: "export", // ← 자동으로 여기에 저장됨
// ... 다른 필드들
}
// 셀렉트박스 값 변경 시 자동으로 formData 업데이트
```
## 🔍 주의사항
1. **조건 값은 고유해야 함**: 각 섹션의 `condition` 값은 중복되면 안 됩니다
2. **최소 1개 섹션 필요**: 섹션이 없으면 안내 메시지 표시
3. **컴포넌트 ID 충돌 방지**: 각 섹션의 컴포넌트 ID는 전역적으로 고유해야 함
## 📝 예시: 수주 입력 방식 선택
```typescript
{
controlField: "inputMode",
controlLabel: "입력 방식",
sections: [
{
id: "customer_first",
condition: "customer_first",
label: "거래처 우선",
components: [
// 거래처 검색 컴포넌트
// 품목 선택 테이블
// 저장 버튼
]
},
{
id: "quotation",
condition: "quotation",
label: "견적서 기반",
components: [
// 견적서 검색 컴포넌트
// 견적서 내용 표시
// 수주 전환 버튼
]
},
{
id: "unit_price",
condition: "unit_price",
label: "단가 직접입력",
components: [
// 품목 입력 테이블
// 단가 입력 필드들
// 계산 위젯
]
}
],
defaultValue: "customer_first",
showBorder: true,
spacing: "normal"
}
```
## 🚀 로드맵
- [ ] 다중 제어 필드 지원 (AND/OR 조건)
- [ ] 섹션 전환 애니메이션
- [ ] 조건별 검증 규칙
- [ ] 템플릿 저장/불러오기
## 🐛 트러블슈팅
### Q: 섹션이 전환되지 않아요
A: `controlField` 값이 formData에 제대로 저장되고 있는지 확인하세요.
### Q: 컴포넌트가 드롭되지 않아요
A: 디자인 모드인지 확인하고, 드롭존 영역에 정확히 드롭하세요.
### Q: 다른 조건의 UI가 계속 보여요
A: 실행 모드로 전환했는지 확인하세요. 디자인 모드에서는 모든 조건이 표시됩니다.
## 📦 파일 구조
```
conditional-container/
├── types.ts # 타입 정의
├── ConditionalContainerComponent.tsx # 메인 컴포넌트
├── ConditionalSectionDropZone.tsx # 드롭존 컴포넌트
├── ConditionalContainerConfigPanel.tsx # 설정 패널
├── ConditionalContainerRenderer.tsx # 렌더러 및 등록
├── index.ts # 컴포넌트 정의
└── README.md # 이 파일
```
## 🎉 완료!
이제 화면 편집기에서 **조건부 컨테이너**를 사용하여 동적인 UI를 만들 수 있습니다! 🚀

View File

@ -0,0 +1,96 @@
/**
*
* UI를
*/
import { ComponentDefinition, ComponentCategory } from "@/types/component";
export const ConditionalContainerDefinition: Omit<
ComponentDefinition,
"renderer" | "configPanel" | "component"
> = {
id: "conditional-container",
name: "조건부 컨테이너",
category: ComponentCategory.LAYOUT,
webType: "container" as const,
description: "셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너",
icon: "GitBranch",
version: "1.0.0",
author: "WACE",
tags: ["조건부", "분기", "동적", "레이아웃"],
defaultSize: {
width: 1400,
height: 800,
},
defaultConfig: {
controlField: "condition",
controlLabel: "조건 선택",
sections: [
{
id: "section_1",
condition: "option1",
label: "옵션 1",
screenId: null,
},
{
id: "section_2",
condition: "option2",
label: "옵션 2",
screenId: null,
},
],
defaultValue: "option1",
showBorder: true,
spacing: "normal",
},
defaultProps: {
style: {
width: "1400px",
height: "800px",
},
},
configSchema: {
controlField: {
type: "string",
label: "제어 필드명",
defaultValue: "condition",
},
controlLabel: {
type: "string",
label: "셀렉트박스 라벨",
defaultValue: "조건 선택",
},
sections: {
type: "array",
label: "조건별 섹션",
defaultValue: [],
},
defaultValue: {
type: "string",
label: "기본 선택 값",
defaultValue: "",
},
showBorder: {
type: "boolean",
label: "섹션 테두리 표시",
defaultValue: true,
},
spacing: {
type: "select",
label: "섹션 간격",
options: [
{ label: "좁게", value: "tight" },
{ label: "보통", value: "normal" },
{ label: "넓게", value: "loose" },
],
defaultValue: "normal",
},
},
};
export default ConditionalContainerDefinition;

View File

@ -0,0 +1,79 @@
/**
* ConditionalContainer
* UI를
*/
import { ComponentData } from "@/types/screen";
export interface ConditionalSection {
id: string; // 고유 ID
condition: string; // 조건 값 (예: "customer_first", "quotation")
label: string; // 조건 라벨 (예: "거래처 우선", "견적서 기반")
screenId: number | null; // 이 조건일 때 표시할 화면 ID
screenName?: string; // 화면 이름 (표시용)
}
export interface ConditionalContainerConfig {
// 제어 셀렉트박스 설정
controlField: string; // 제어할 필드명 (예: "inputMode")
controlLabel: string; // 셀렉트박스 라벨 (예: "입력 방식")
// 조건별 섹션
sections: ConditionalSection[];
// 기본 선택 값
defaultValue?: string;
// 스타일
showBorder?: boolean; // 섹션별 테두리 표시
spacing?: "tight" | "normal" | "loose"; // 섹션 간격
}
export interface ConditionalContainerProps {
config?: ConditionalContainerConfig;
// 개별 props (config 우선)
controlField?: string;
controlLabel?: string;
sections?: ConditionalSection[];
defaultValue?: string;
showBorder?: boolean;
spacing?: "tight" | "normal" | "loose";
// 폼 데이터 연동
value?: any; // 현재 선택된 값
onChange?: (value: string) => void;
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
// 화면 편집기 관련
isDesignMode?: boolean; // 디자인 모드 여부
onUpdateComponent?: (componentId: string, updates: Partial<ComponentData>) => void;
onDeleteComponent?: (componentId: string) => void;
onSelectComponent?: (componentId: string) => void;
selectedComponentId?: string;
// 높이 변화 알림 (아래 컴포넌트 재배치용)
onHeightChange?: (newHeight: number) => void;
componentId?: string; // 자신의 컴포넌트 ID
// 스타일
style?: React.CSSProperties;
className?: string;
}
// 조건부 섹션 뷰어 Props
export interface ConditionalSectionViewerProps {
sectionId: string;
condition: string;
label: string;
screenId: number | null; // 표시할 화면 ID
screenName?: string; // 화면 이름
isActive: boolean; // 현재 조건이 활성화되어 있는지
isDesignMode: boolean;
showBorder?: boolean;
// 폼 데이터 전달
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
}

View File

@ -0,0 +1,126 @@
"use client";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { EntitySearchModal } from "./EntitySearchModal";
import { EntitySearchInputProps, EntitySearchResult } from "./types";
import { cn } from "@/lib/utils";
export function EntitySearchInputComponent({
tableName,
displayField,
valueField,
searchFields = [displayField],
mode = "combo",
placeholder = "검색...",
disabled = false,
filterCondition = {},
value,
onChange,
modalTitle = "검색",
modalColumns = [],
showAdditionalInfo = false,
additionalFields = [],
className,
}: EntitySearchInputProps) {
const [modalOpen, setModalOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setDisplayValue(selectedData[displayField] || "");
} else {
setDisplayValue("");
setSelectedData(null);
}
}, [value, displayField]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
};
const handleClear = () => {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
};
const handleOpenModal = () => {
if (!disabled) {
setModalOpen(true);
}
};
return (
<div className={cn("space-y-2", className)}>
{/* 입력 필드 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
value={displayValue}
onChange={(e) => setDisplayValue(e.target.value)}
placeholder={placeholder}
disabled={disabled}
readOnly={mode === "modal" || mode === "combo"}
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
/>
{displayValue && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Search className="h-4 w-4" />
</Button>
)}
</div>
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
{/* 검색 모달 */}
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
/>
</div>
);
}

View File

@ -0,0 +1,498 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { EntitySearchInputConfig } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
interface EntitySearchInputConfigPanelProps {
config: EntitySearchInputConfig;
onConfigChange: (config: EntitySearchInputConfig) => void;
}
export function EntitySearchInputConfigPanel({
config,
onConfigChange,
}: EntitySearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.tableName) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const addSearchField = () => {
const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.searchFields || [])];
fields[index] = value;
updateConfig({ searchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.searchFields || [])];
fields.splice(index, 1);
updateConfig({ searchFields: fields });
};
const addModalColumn = () => {
const columns = localConfig.modalColumns || [];
updateConfig({ modalColumns: [...columns, ""] });
};
const updateModalColumn = (index: number, value: string) => {
const columns = [...(localConfig.modalColumns || [])];
columns[index] = value;
updateConfig({ modalColumns: columns });
};
const removeModalColumn = (index: number) => {
const columns = [...(localConfig.modalColumns || [])];
columns.splice(index, 1);
updateConfig({ modalColumns: columns });
};
const addAdditionalField = () => {
const fields = localConfig.additionalFields || [];
updateConfig({ additionalFields: [...fields, ""] });
};
const updateAdditionalField = (index: number, value: string) => {
const fields = [...(localConfig.additionalFields || [])];
fields[index] = value;
updateConfig({ additionalFields: fields });
};
const removeAdditionalField = (index: number) => {
const fields = [...(localConfig.additionalFields || [])];
fields.splice(index, 1);
updateConfig({ additionalFields: fields });
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.tableName
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || table.tableName}-${table.tableName}`}
onSelect={() => {
updateConfig({ tableName: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && table.displayName !== table.tableName && (
<span className="text-[10px] text-gray-500">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.displayField
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => {
updateConfig({ displayField: column.columnName });
setOpenDisplayFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && column.displayName !== column.columnName && (
<span className="text-[10px] text-gray-500">{column.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openValueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.valueField
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => {
updateConfig({ valueField: column.columnName });
setOpenValueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && column.displayName !== column.columnName && (
<span className="text-[10px] text-gray-500">{column.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">UI </Label>
<Select
value={localConfig.mode || "combo"}
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
updateConfig({ mode: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="combo"> ( + )</SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="autocomplete"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={localConfig.placeholder || ""}
onChange={(e) => updateConfig({ placeholder: e.target.value })}
placeholder="검색..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{(localConfig.mode === "modal" || localConfig.mode === "combo") && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalTitle || ""}
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
placeholder="검색 및 선택"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addModalColumn}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.modalColumns || []).map((column, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={column}
onValueChange={(value) => updateModalColumn(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeModalColumn(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.searchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showAdditionalInfo || false}
onCheckedChange={(checked) =>
updateConfig({ showAdditionalInfo: checked })
}
/>
</div>
</div>
{localConfig.showAdditionalInfo && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addAdditionalField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.additionalFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateAdditionalField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeAdditionalField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { EntitySearchInputDefinition } from "./index";
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
/**
* EntitySearchInput
*
*/
export class EntitySearchInputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = EntitySearchInputDefinition;
render(): React.ReactElement {
return <EntitySearchInputComponent {...this.props} renderer={this} />;
}
/**
*
*/
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
EntitySearchInputRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
EntitySearchInputRenderer.enableHotReload();
}

View File

@ -0,0 +1,226 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "./useEntitySearch";
import { EntitySearchResult } from "./types";
interface EntitySearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
modalTitle?: string;
modalColumns?: string[];
onSelect: (value: any, fullData: EntitySearchResult) => void;
}
export function EntitySearchModal({
open,
onOpenChange,
tableName,
displayField,
valueField,
searchFields = [displayField],
filterCondition = {},
modalTitle = "검색",
modalColumns = [],
onSelect,
}: EntitySearchModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const {
results,
loading,
error,
pagination,
search,
clearSearch,
loadMore,
} = useEntitySearch({
tableName,
searchFields,
filterCondition,
});
// 모달 열릴 때 초기 검색
useEffect(() => {
if (open) {
search("", 1); // 빈 검색어로 전체 목록 조회
} else {
clearSearch();
setLocalSearchText("");
}
}, [open]);
const handleSearch = () => {
search(localSearchText, 1);
};
const handleSelect = (item: EntitySearchResult) => {
onSelect(item[valueField], item);
onOpenChange(false);
};
// 표시할 컬럼 결정
const displayColumns = modalColumns.length > 0 ? modalColumns : [displayField];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{/* 검색 입력 */}
<div className="flex gap-2">
<Input
placeholder="검색어를 입력하세요"
value={localSearchText}
onChange={(e) => setLocalSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
disabled={loading}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
{/* 오류 메시지 */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
{/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
{displayColumns.map((col) => (
<th
key={col}
className="px-4 py-2 text-left font-medium text-muted-foreground"
>
{col}
</th>
))}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
</th>
</tr>
</thead>
<tbody>
{loading && results.length === 0 ? (
<tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p>
</td>
</tr>
) : results.length === 0 ? (
<tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
results.map((item, index) => {
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
return (
<tr
key={uniqueKey}
className="border-t hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleSelect(item)}
>
{displayColumns.map((col) => (
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
{item[col] || "-"}
</td>
))}
<td className="px-4 py-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
handleSelect(item);
}}
className="h-7 text-xs"
>
</Button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 정보 */}
{results.length > 0 && (
<div className="flex justify-between items-center text-xs sm:text-sm text-muted-foreground">
<span>
{pagination.total} {results.length}
</span>
{pagination.page * pagination.limit < pagination.total && (
<Button
variant="outline"
size="sm"
onClick={loadMore}
disabled={loading}
className="h-7 text-xs"
>
</Button>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,14 @@
export interface EntitySearchInputConfig {
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
mode?: "autocomplete" | "modal" | "combo";
placeholder?: string;
modalTitle?: string;
modalColumns?: string[];
showAdditionalInfo?: boolean;
additionalFields?: string[];
}

View File

@ -0,0 +1,53 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
import { EntitySearchInputConfigPanel } from "./EntitySearchInputConfigPanel";
/**
* EntitySearchInput
*
*/
export const EntitySearchInputDefinition = createComponentDefinition({
id: "entity-search-input",
name: "엔티티 검색 입력 (모달)",
nameEng: "Entity Search Input",
description: "모달을 통한 엔티티 검색 및 선택 (거래처, 품목 등)",
category: ComponentCategory.INPUT,
webType: "entity",
component: EntitySearchInputComponent,
defaultConfig: {
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
mode: "combo",
placeholder: "검색...",
modalTitle: "검색 및 선택",
modalColumns: ["customer_code", "customer_name", "address"],
showAdditionalInfo: false,
additionalFields: [],
},
defaultSize: { width: 300, height: 40 },
configPanel: EntitySearchInputConfigPanel,
icon: "Search",
tags: ["검색", "모달", "엔티티", "거래처", "품목"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { EntitySearchInputConfig } from "./config";
// 컴포넌트 내보내기
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
export { EntitySearchModal } from "./EntitySearchModal";
export { useEntitySearch } from "./useEntitySearch";
export type {
EntitySearchInputProps,
EntitySearchResult,
EntitySearchResponse,
} from "./types";

View File

@ -0,0 +1,52 @@
/**
* EntitySearchInput
*
*/
export interface EntitySearchInputProps {
// 데이터 소스
tableName: string; // 검색할 테이블명 (예: "customer_mng")
displayField: string; // 표시할 필드 (예: "customer_name")
valueField: string; // 값으로 사용할 필드 (예: "customer_code")
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
// UI 모드
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
placeholder?: string;
disabled?: boolean;
// 필터링
filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시
// 선택된 값
value?: any;
onChange?: (value: any, fullData?: any) => void;
// 모달 설정 (mode가 "modal" 또는 "combo"일 때)
modalTitle?: string;
modalColumns?: string[]; // 모달에 표시할 컬럼들
// 추가 표시 정보
showAdditionalInfo?: boolean; // 선택 후 추가 정보 표시 (예: 주소)
additionalFields?: string[]; // 추가로 표시할 필드들
// 스타일
className?: string;
}
export interface EntitySearchResult {
[key: string]: any;
}
export interface EntitySearchResponse {
success: boolean;
data: EntitySearchResult[];
pagination?: {
total: number;
page: number;
limit: number;
};
error?: string;
}

View File

@ -0,0 +1,118 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { EntitySearchResult, EntitySearchResponse } from "./types";
interface UseEntitySearchProps {
tableName: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
}
export function useEntitySearch({
tableName,
searchFields = [],
filterCondition = {},
}: UseEntitySearchProps) {
const [searchText, setSearchText] = useState("");
const [results, setResults] = useState<EntitySearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 20,
});
// searchFields와 filterCondition을 ref로 관리하여 useCallback 의존성 문제 해결
const searchFieldsRef = useRef(searchFields);
const filterConditionRef = useRef(filterCondition);
useEffect(() => {
searchFieldsRef.current = searchFields;
filterConditionRef.current = filterCondition;
}, [searchFields, filterCondition]);
const search = useCallback(
async (text: string, page: number = 1) => {
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
console.warn("엔티티 검색 건너뜀: tableName이 없음", { tableName });
setError("테이블명이 설정되지 않았습니다. 컴포넌트 설정을 확인해주세요.");
return;
}
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
searchText: text,
searchFields: searchFieldsRef.current.join(","),
filterCondition: JSON.stringify(filterConditionRef.current),
page: page.toString(),
limit: pagination.limit.toString(),
});
const response = await apiClient.get<EntitySearchResponse>(
`/entity-search/${tableName}?${params.toString()}`
);
if (response.data.success) {
setResults(response.data.data);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
setError(response.data.error || "검색에 실패했습니다");
}
} catch (err: any) {
console.error("Entity search error:", err);
const errorMessage = err.response?.data?.message || "검색 중 오류가 발생했습니다";
setError(errorMessage);
} finally {
setLoading(false);
}
},
[tableName, pagination.limit]
);
// 디바운스된 검색
useEffect(() => {
// searchText가 명시적으로 설정되지 않은 경우(null/undefined)만 건너뛰기
if (searchText === null || searchText === undefined) {
return;
}
const timer = setTimeout(() => {
// 빈 문자열("")도 검색 (전체 목록 조회)
search(searchText.trim(), 1);
}, 300); // 300ms 디바운스
return () => clearTimeout(timer);
}, [searchText, search]);
const clearSearch = useCallback(() => {
setSearchText("");
setResults([]);
setError(null);
}, []);
const loadMore = useCallback(() => {
if (pagination.page * pagination.limit < pagination.total) {
search(searchText, pagination.page + 1);
}
}, [search, searchText, pagination]);
return {
searchText,
setSearchText,
results,
loading,
error,
pagination,
search,
clearSearch,
loadMore,
};
}

View File

@ -45,6 +45,20 @@ import "./category-manager/CategoryManagerRenderer";
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
// 🆕 수주 등록 관련 컴포넌트들
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
import "./entity-search-input/EntitySearchInputRenderer";
import "./modal-repeater-table/ModalRepeaterTableRenderer";
import "./order-registration-modal/OrderRegistrationModalRenderer";
// 🆕 조건부 컨테이너 컴포넌트
import "./conditional-container/ConditionalContainerRenderer";
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
// 🆕 섹션 그룹화 레이아웃 컴포넌트
import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식
import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식
/**
*
*/

View File

@ -0,0 +1,333 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { ItemSelectionModalProps } from "./types";
export function ItemSelectionModal({
open,
onOpenChange,
sourceTable,
sourceColumns,
sourceSearchFields = [],
multiSelect = true,
filterCondition = {},
modalTitle,
alreadySelected = [],
uniqueField,
onSelect,
columnLabels = {},
}: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const { results, loading, error, search, clearSearch } = useEntitySearch({
tableName: sourceTable,
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
filterCondition,
});
// 모달 열릴 때 초기 검색
useEffect(() => {
if (open) {
search("", 1); // 빈 검색어로 전체 목록 조회
setSelectedItems([]);
} else {
clearSearch();
setLocalSearchText("");
setSelectedItems([]);
}
}, [open]);
const handleSearch = () => {
search(localSearchText, 1);
};
const handleToggleItem = (item: any) => {
const itemValue = uniqueField ? item[uniqueField] : undefined;
if (!multiSelect) {
setSelectedItems([item]);
return;
}
// uniqueField 값이 undefined인 경우 객체 참조로 비교
if (uniqueField && (itemValue === undefined || itemValue === null)) {
console.warn(`⚠️ uniqueField "${uniqueField}"의 값이 undefined입니다. 객체 참조로 비교합니다.`);
const itemIsSelected = selectedItems.includes(item);
if (itemIsSelected) {
const newSelected = selectedItems.filter((selected) => selected !== item);
setSelectedItems(newSelected);
} else {
setSelectedItems([...selectedItems, item]);
}
return;
}
const itemIsSelected = selectedItems.some((selected) => {
if (!uniqueField) {
return selected === item;
}
const selectedValue = selected[uniqueField];
if (selectedValue === undefined || selectedValue === null) {
return false;
}
return selectedValue === itemValue;
});
if (itemIsSelected) {
const newSelected = selectedItems.filter((selected) => {
if (!uniqueField) {
return selected !== item;
}
const selectedValue = selected[uniqueField];
if (selectedValue === undefined || selectedValue === null) {
return true;
}
return selectedValue !== itemValue;
});
setSelectedItems(newSelected);
} else {
setSelectedItems([...selectedItems, item]);
}
};
const handleConfirm = () => {
console.log("✅ ItemSelectionModal 추가:", selectedItems.length, "개 항목");
onSelect(selectedItems);
onOpenChange(false);
};
// 이미 추가된 항목인지 확인
const isAlreadyAdded = (item: any): boolean => {
if (!uniqueField) return false;
return alreadySelected.some(
(selected) => selected[uniqueField] === item[uniqueField]
);
};
// 이미 추가된 항목 제외한 결과 필터링
const filteredResults = results.filter((item) => !isAlreadyAdded(item));
// 선택된 항목인지 확인
const isSelected = (item: any): boolean => {
if (!uniqueField) {
return selectedItems.includes(item);
}
const itemValue = item[uniqueField];
// uniqueField 값이 undefined인 경우 객체 참조로 비교
if (itemValue === undefined || itemValue === null) {
return selectedItems.includes(item);
}
const result = selectedItems.some((selected) => {
const selectedValue = selected[uniqueField];
// selectedValue도 undefined면 안전하게 처리
if (selectedValue === undefined || selectedValue === null) {
return false;
}
const isMatch = selectedValue === itemValue;
if (isMatch) {
console.log("✅ 매칭 발견:", {
selectedValue,
itemValue,
uniqueField
});
}
return isMatch;
});
return result;
};
// 유효한 컬럼만 필터링
const validColumns = sourceColumns.filter(col => col != null && col !== "");
const totalColumns = validColumns.length + (multiSelect ? 1 : 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{multiSelect && " (다중 선택 가능)"}
</DialogDescription>
</DialogHeader>
{/* 검색 입력 */}
<div className="flex gap-2">
<Input
placeholder="검색어를 입력하세요"
value={localSearchText}
onChange={(e) => setLocalSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
disabled={loading}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
{/* 선택된 항목 수 */}
{selectedItems.length > 0 && (
<div className="text-sm text-primary">
{selectedItems.length}
{uniqueField && (
<span className="ml-2 text-xs text-muted-foreground">
({selectedItems.map(item => item[uniqueField]).join(", ")})
</span>
)}
</div>
)}
{/* 오류 메시지 */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
{/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
{multiSelect && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
</th>
)}
{validColumns.map((col) => (
<th
key={col}
className="px-4 py-2 text-left font-medium text-muted-foreground"
>
{columnLabels[col] || col}
</th>
))}
</tr>
</thead>
<tbody>
{loading && filteredResults.length === 0 ? (
<tr>
<td
colSpan={totalColumns}
className="px-4 py-8 text-center"
>
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p>
</td>
</tr>
) : filteredResults.length === 0 ? (
<tr>
<td
colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground"
>
{results.length > 0
? "모든 항목이 이미 추가되었습니다"
: "검색 결과가 없습니다"}
</td>
</tr>
) : (
filteredResults.map((item, index) => {
const selected = isSelected(item);
const uniqueFieldValue = uniqueField ? item[uniqueField] : undefined;
const itemKey = (uniqueFieldValue !== undefined && uniqueFieldValue !== null)
? uniqueFieldValue
: `item-${index}`;
return (
<tr
key={itemKey}
className={`border-t transition-colors ${
selected
? "bg-primary/10"
: "hover:bg-accent cursor-pointer"
}`}
onClick={() => handleToggleItem(item)}
>
{multiSelect && (
<td
className="px-4 py-2"
onClick={(e) => {
// 체크박스 영역 클릭을 행 클릭으로 전파
e.stopPropagation();
handleToggleItem(item);
}}
>
<div className="pointer-events-none">
<Checkbox checked={selected} />
</div>
</td>
)}
{validColumns.map((col) => (
<td key={col} className="px-4 py-2">
{item[col] || "-"}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleConfirm}
disabled={selectedItems.length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
({selectedItems.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,408 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types";
import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface ModalRepeaterTableComponentProps extends Partial<ModalRepeaterTableProps> {
config?: ModalRepeaterTableProps;
}
/**
*
* @param referenceTable (: "customer_item_mapping")
* @param referenceField (: "basic_price")
* @param joinConditions
* @param sourceItem ( )
* @param currentItem ( )
* @returns undefined
*/
async function fetchReferenceValue(
referenceTable: string,
referenceField: string,
joinConditions: JoinCondition[],
sourceItem: any,
currentItem: any
): Promise<any> {
if (joinConditions.length === 0) {
console.warn("⚠️ 조인 조건이 없습니다. 참조 조회를 건너뜁니다.");
return undefined;
}
try {
// 조인 조건을 WHERE 절로 변환
const whereConditions: Record<string, any> = {};
for (const condition of joinConditions) {
const { sourceTable = "target", sourceField, targetField, operator = "=" } = condition;
// 소스 테이블에 따라 값을 가져오기
let value: any;
if (sourceTable === "source") {
// 소스 테이블 (item_info 등): 모달에서 선택한 원본 데이터
value = sourceItem[sourceField];
console.log(` 📘 소스 테이블에서 값 가져오기: ${sourceField} =`, value);
} else {
// 저장 테이블 (sales_order_mng 등): 반복 테이블에 이미 복사된 값
value = currentItem[sourceField];
console.log(` 📗 저장 테이블(반복테이블)에서 값 가져오기: ${sourceField} =`, value);
}
if (value === undefined || value === null) {
console.warn(`⚠️ 조인 조건의 소스 필드 "${sourceField}" 값이 없습니다. (sourceTable: ${sourceTable})`);
return undefined;
}
// 연산자가 "=" 인 경우만 지원 (확장 가능)
if (operator === "=") {
whereConditions[targetField] = value;
} else {
console.warn(`⚠️ 연산자 "${operator}"는 아직 지원되지 않습니다.`);
}
}
console.log(`🔍 참조 조회 API 호출:`, {
table: referenceTable,
field: referenceField,
where: whereConditions,
});
// API 호출: 테이블 데이터 조회 (POST 방식)
const requestBody = {
search: whereConditions, // ✅ filters → search 변경 (백엔드 파라미터명)
size: 1, // 첫 번째 결과만 가져오기
page: 1,
};
console.log("📤 API 요청 Body:", JSON.stringify(requestBody, null, 2));
const response = await apiClient.post(
`/table-management/tables/${referenceTable}/data`,
requestBody
);
console.log("📥 API 전체 응답:", {
success: response.data.success,
dataLength: response.data.data?.data?.length, // ✅ data.data.data 구조
total: response.data.data?.total, // ✅ data.data.total
firstRow: response.data.data?.data?.[0], // ✅ data.data.data[0]
});
if (response.data.success && response.data.data?.data?.length > 0) {
const firstRow = response.data.data.data[0]; // ✅ data.data.data[0]
const value = firstRow[referenceField];
console.log(`✅ 참조 조회 성공:`, {
table: referenceTable,
field: referenceField,
value,
fullRow: firstRow,
});
return value;
} else {
console.warn(`⚠️ 참조 조회 결과 없음:`, {
table: referenceTable,
where: whereConditions,
responseData: response.data.data,
total: response.data.total,
});
return undefined;
}
} catch (error) {
console.error(`❌ 참조 조회 API 오류:`, error);
return undefined;
}
}
export function ModalRepeaterTableComponent({
config,
sourceTable: propSourceTable,
sourceColumns: propSourceColumns,
sourceSearchFields: propSourceSearchFields,
modalTitle: propModalTitle,
modalButtonText: propModalButtonText,
multiSelect: propMultiSelect,
columns: propColumns,
calculationRules: propCalculationRules,
value: propValue,
onChange: propOnChange,
uniqueField: propUniqueField,
filterCondition: propFilterCondition,
companyCode: propCompanyCode,
className,
}: ModalRepeaterTableComponentProps) {
// config prop 우선, 없으면 개별 prop 사용
const sourceTable = config?.sourceTable || propSourceTable || "";
// sourceColumns에서 빈 문자열 필터링
const rawSourceColumns = config?.sourceColumns || propSourceColumns || [];
const sourceColumns = rawSourceColumns.filter((col) => col && col.trim() !== "");
const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || [];
const modalTitle = config?.modalTitle || propModalTitle || "항목 검색";
const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색";
const multiSelect = config?.multiSelect ?? propMultiSelect ?? true;
const calculationRules = config?.calculationRules || propCalculationRules || [];
const value = config?.value || propValue || [];
const onChange = config?.onChange || propOnChange || (() => {});
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
const rawUniqueField = config?.uniqueField || propUniqueField;
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
? "item_number"
: rawUniqueField;
const filterCondition = config?.filterCondition || propFilterCondition || {};
const companyCode = config?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
const configuredColumns = config?.columns || propColumns || [];
if (configuredColumns.length > 0) {
console.log("✅ 설정된 columns 사용:", configuredColumns);
return configuredColumns;
}
// columns가 비어있으면 sourceColumns로부터 자동 생성
if (sourceColumns.length > 0) {
console.log("🔄 sourceColumns로부터 자동 생성:", sourceColumns);
const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({
field: field,
label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능)
editable: false, // 기본적으로 읽기 전용
type: "text" as const,
width: "150px",
}));
console.log("📋 자동 생성된 columns:", autoColumns);
return autoColumns;
}
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
return [];
}, [config?.columns, propColumns, sourceColumns]);
// 초기 props 로깅
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
console.warn(`⚠️ sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개 (빈 문자열 제거)`);
}
if (rawUniqueField !== uniqueField) {
console.warn(`⚠️ uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
}
console.log("🎬 ModalRepeaterTableComponent 마운트:", {
columnsLength: columns.length,
sourceTable,
sourceColumns,
uniqueField,
});
if (columns.length === 0) {
console.error("❌ columns가 비어있습니다! sourceColumns:", sourceColumns);
} else {
console.log("✅ columns 설정 완료:", columns.map(c => c.label || c.field).join(", "));
}
}, []);
// value 변경 감지
useEffect(() => {
console.log("📦 ModalRepeaterTableComponent value 변경:", {
valueLength: value.length,
});
}, [value]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
onChange(calculated);
}
}
}, []);
const handleAddItems = async (items: any[]) => {
console.log(" handleAddItems 호출:", items.length, "개 항목");
console.log("📋 소스 데이터:", items);
// 매핑 규칙에 따라 데이터 변환 (비동기 처리)
const mappedItems = await Promise.all(items.map(async (sourceItem) => {
const newItem: any = {};
// ⚠️ 중요: reference 매핑은 다른 컬럼에 의존할 수 있으므로
// 1단계: source/manual 매핑을 먼저 처리
// 2단계: reference 매핑을 나중에 처리
const referenceColumns: typeof columns = [];
const otherColumns: typeof columns = [];
for (const col of columns) {
if (col.mapping?.type === "reference") {
referenceColumns.push(col);
} else {
otherColumns.push(col);
}
}
// 1단계: source/manual 컬럼 먼저 처리
for (const col of otherColumns) {
console.log(`🔄 컬럼 "${col.field}" 매핑 처리:`, col.mapping);
// 1. 매핑 규칙이 있는 경우
if (col.mapping) {
if (col.mapping.type === "source") {
// 소스 테이블 컬럼에서 복사
const sourceField = col.mapping.sourceField;
if (sourceField && sourceItem[sourceField] !== undefined) {
newItem[col.field] = sourceItem[sourceField];
console.log(` ✅ 소스 복사: ${sourceField}${col.field}:`, newItem[col.field]);
} else {
console.warn(` ⚠️ 소스 필드 "${sourceField}" 값이 없음`);
}
} else if (col.mapping.type === "manual") {
// 사용자 입력 (빈 값)
newItem[col.field] = undefined;
console.log(` ✏️ 수동 입력 필드`);
}
}
// 2. 매핑 규칙이 없는 경우 - 소스 데이터에서 같은 필드명으로 복사
else if (sourceItem[col.field] !== undefined) {
newItem[col.field] = sourceItem[col.field];
console.log(` 📝 직접 복사: ${col.field}:`, newItem[col.field]);
}
// 3. 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
console.log(` 🎯 기본값 적용: ${col.field}:`, col.defaultValue);
}
}
// 2단계: reference 컬럼 처리 (다른 컬럼들이 모두 설정된 후)
console.log("🔗 2단계: reference 컬럼 처리 시작");
for (const col of referenceColumns) {
console.log(`🔄 컬럼 "${col.field}" 참조 매핑 처리:`, col.mapping);
// 외부 테이블 참조 (API 호출)
console.log(` ⏳ 참조 조회 시작: ${col.mapping?.referenceTable}.${col.mapping?.referenceField}`);
try {
const referenceValue = await fetchReferenceValue(
col.mapping!.referenceTable!,
col.mapping!.referenceField!,
col.mapping!.joinCondition || [],
sourceItem,
newItem
);
if (referenceValue !== null && referenceValue !== undefined) {
newItem[col.field] = referenceValue;
console.log(` ✅ 참조 조회 성공: ${col.field}:`, referenceValue);
} else {
newItem[col.field] = undefined;
console.warn(` ⚠️ 참조 조회 결과 없음`);
}
} catch (error) {
console.error(` ❌ 참조 조회 오류:`, error);
newItem[col.field] = undefined;
}
// 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
console.log(` 🎯 기본값 적용: ${col.field}:`, col.defaultValue);
}
}
console.log("📦 변환된 항목:", newItem);
return newItem;
}));
// 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...value, ...calculatedItems];
console.log("✅ 최종 데이터:", newData.length, "개 항목");
onChange(newData);
};
const handleRowChange = (index: number, newRow: any) => {
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
const newData = [...value];
newData[index] = calculatedRow;
onChange(newData);
};
const handleRowDelete = (index: number) => {
const newData = value.filter((_, i) => i !== index);
onChange(newData);
};
// 컬럼명 -> 라벨명 매핑 생성
const columnLabels = columns.reduce((acc, col) => {
acc[col.field] = col.label;
return acc;
}, {} as Record<string, string>);
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{value.length > 0 && `${value.length}개 항목`}
</div>
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{modalButtonText}
</Button>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={value}
onDataChange={onChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={filterCondition}
modalTitle={modalTitle}
alreadySelected={value}
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
/>
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ModalRepeaterTableDefinition } from "./index";
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
/**
* ModalRepeaterTable
*
*/
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = ModalRepeaterTableDefinition;
render(): React.ReactElement {
// onChange 콜백을 명시적으로 전달
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleChange = (newValue: any[]) => {
console.log("🔄 ModalRepeaterTableRenderer onChange:", newValue.length, "개 항목");
// 컴포넌트 업데이트
this.updateComponent({ value: newValue });
// 원본 onChange 콜백도 호출 (있다면)
if (this.props.onChange) {
this.props.onChange(newValue);
}
};
// renderer prop 제거 (불필요)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onChange, ...restProps } = this.props;
return <ModalRepeaterTableComponent {...restProps} onChange={handleChange} />;
}
/**
* ( - )
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
ModalRepeaterTableRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
ModalRepeaterTableRenderer.enableHotReload();
}

View File

@ -0,0 +1,184 @@
"use client";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
interface RepeaterTableProps {
columns: RepeaterColumnConfig[];
data: any[];
onDataChange: (newData: any[]) => void;
onRowChange: (index: number, newRow: any) => void;
onRowDelete: (index: number) => void;
}
export function RepeaterTable({
columns,
data,
onDataChange,
onRowChange,
onRowDelete,
}: RepeaterTableProps) {
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
field: string;
} | null>(null);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
// console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행");
// }, [data]);
const handleCellEdit = (rowIndex: number, field: string, value: any) => {
const newRow = { ...data[rowIndex], [field]: value };
onRowChange(rowIndex, newRow);
};
const renderCell = (
row: any,
column: RepeaterColumnConfig,
rowIndex: number
) => {
const isEditing =
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field];
// 계산 필드는 편집 불가
if (column.calculated || !column.editable) {
return (
<div className="px-2 py-1">
{column.type === "number"
? typeof value === "number"
? value.toLocaleString()
: value || "0"
: value || "-"}
</div>
);
}
// 편집 가능한 필드
switch (column.type) {
case "number":
return (
<Input
type="number"
value={value || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs"
/>
);
case "date":
return (
<Input
type="date"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
case "select":
return (
<Select
value={value || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default: // text
return (
<Input
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
}
};
return (
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
#
</th>
{columns.map((col) => (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</th>
))}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-muted-foreground"
>
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50">
<td className="px-4 py-2 text-center text-muted-foreground">
{rowIndex + 1}
</td>
{columns.map((col) => (
<td key={col.field} className="px-2 py-1">
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-4 py-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
import { ModalRepeaterTableConfigPanel } from "./ModalRepeaterTableConfigPanel";
/**
* ModalRepeaterTable
* + ()
*/
export const ModalRepeaterTableDefinition = createComponentDefinition({
id: "modal-repeater-table",
name: "모달 반복 테이블",
nameEng: "Modal Repeater Table",
description: "모달에서 항목을 검색하여 동적으로 추가/편집할 수 있는 테이블 (수주 품목 등)",
category: ComponentCategory.DATA,
webType: "table",
component: ModalRepeaterTableComponent,
defaultConfig: {
sourceTable: "item_info",
sourceColumns: ["item_code", "item_name", "size", "unit_price"],
sourceSearchFields: ["item_code", "item_name"],
modalTitle: "항목 검색 및 선택",
modalButtonText: "항목 검색",
multiSelect: true,
columns: [],
uniqueField: "item_code",
},
defaultSize: { width: 800, height: 400 },
configPanel: ModalRepeaterTableConfigPanel,
icon: "Table",
tags: ["테이블", "반복", "동적", "모달", "수주", "품목"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type {
ModalRepeaterTableProps,
RepeaterColumnConfig,
CalculationRule,
ItemSelectionModalProps,
} from "./types";
// 컴포넌트 내보내기
export { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
export { ModalRepeaterTableRenderer } from "./ModalRepeaterTableRenderer";
export { ItemSelectionModal } from "./ItemSelectionModal";
export { RepeaterTable } from "./RepeaterTable";
export { useCalculation } from "./useCalculation";

View File

@ -0,0 +1,110 @@
/**
* ModalRepeaterTable
* , Repeater
*/
export interface ModalRepeaterTableProps {
// 소스 데이터 (모달에서 가져올 데이터)
sourceTable: string; // 검색할 테이블 (예: "item_info")
sourceColumns: string[]; // 모달에 표시할 컬럼들
sourceSearchFields?: string[]; // 검색 가능한 필드들
// 🆕 저장 대상 테이블 설정
targetTable?: string; // 저장할 테이블 (예: "sales_order_mng")
// 모달 설정
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
// Repeater 테이블 설정
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
// 계산 규칙
calculationRules?: CalculationRule[]; // 자동 계산 규칙
// 데이터
value: any[]; // 현재 추가된 항목들
onChange: (newData: any[]) => void; // 데이터 변경 콜백
// 중복 체크
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
// 필터링
filterCondition?: Record<string, any>;
companyCode?: string;
// 스타일
className?: string;
}
export interface RepeaterColumnConfig {
field: string; // 필드명
label: string; // 컬럼 헤더 라벨
type?: "text" | "number" | "date" | "select"; // 입력 타입
editable?: boolean; // 편집 가능 여부
calculated?: boolean; // 계산 필드 여부
width?: string; // 컬럼 너비
required?: boolean; // 필수 입력 여부
defaultValue?: any; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
// 🆕 컬럼 매핑 설정
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
}
/**
*
*
*/
export interface ColumnMapping {
/** 매핑 타입 */
type: "source" | "reference" | "manual";
/** 매핑 타입별 설정 */
// type: "source" - 소스 테이블 (모달에서 선택한 항목)의 컬럼에서 가져오기
sourceField?: string; // 소스 테이블의 컬럼명 (예: "item_name")
// type: "reference" - 외부 테이블 참조 (조인)
referenceTable?: string; // 참조 테이블명 (예: "customer_item_mapping")
referenceField?: string; // 참조 테이블에서 가져올 컬럼 (예: "basic_price")
joinCondition?: JoinCondition[]; // 조인 조건
// type: "manual" - 사용자가 직접 입력
}
/**
*
*/
export interface JoinCondition {
/** 소스 테이블 (어느 테이블의 컬럼인지) */
sourceTable?: string; // "source" (item_info) 또는 "target" (sales_order_mng)
/** 현재 테이블의 컬럼 (소스 테이블 또는 반복 테이블) */
sourceField: string;
/** 참조 테이블의 컬럼 */
targetField: string;
/** 비교 연산자 */
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
}
export interface CalculationRule {
result: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
}
export interface ItemSelectionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceTable: string;
sourceColumns: string[];
sourceSearchFields?: string[];
multiSelect?: boolean;
filterCondition?: Record<string, any>;
modalTitle: string;
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
uniqueField?: string;
onSelect: (items: any[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
}

View File

@ -0,0 +1,67 @@
import { useCallback } from "react";
import { CalculationRule } from "./types";
/**
*
*/
export function useCalculation(calculationRules: CalculationRule[] = []) {
/**
*
*/
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
try {
// formula에서 필드명 자동 추출 (영문자, 숫자, 언더스코어로 구성된 단어)
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
// 추출된 필드명들을 사용 (dependencies가 없으면 자동 추출 사용)
const dependencies = rule.dependencies && rule.dependencies.length > 0
? rule.dependencies
: fieldMatches;
// 필드명을 실제 값으로 대체
for (const dep of dependencies) {
// 결과 필드는 제외
if (dep === rule.result) continue;
const value = parseFloat(row[dep]) || 0;
// 정확한 필드명만 대체 (단어 경계 사용)
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
// 계산 실행 (Function 사용)
const result = new Function(`return ${formula}`)();
updatedRow[rule.result] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
updatedRow[rule.result] = 0;
}
}
return updatedRow;
},
[calculationRules]
);
/**
*
*/
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
[calculateRow]
);
return {
calculateRow,
calculateAll,
};
}

View File

@ -0,0 +1,93 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface OrderRegistrationModalConfig {
buttonText?: string;
buttonVariant?: "default" | "secondary" | "outline" | "ghost";
buttonSize?: "default" | "sm" | "lg";
}
interface OrderRegistrationModalConfigPanelProps {
config: OrderRegistrationModalConfig;
onConfigChange: (config: OrderRegistrationModalConfig) => void;
}
export function OrderRegistrationModalConfigPanel({
config,
onConfigChange,
}: OrderRegistrationModalConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<OrderRegistrationModalConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.buttonText || "수주 등록"}
onChange={(e) => updateConfig({ buttonText: e.target.value })}
placeholder="수주 등록"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.buttonVariant || "default"}
onValueChange={(value: any) => updateConfig({ buttonVariant: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="secondary"></SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="ghost"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.buttonSize || "default"}
onValueChange={(value: any) => updateConfig({ buttonSize: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="default"></SelectItem>
<SelectItem value="lg"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2">💡 :</p>
<ul className="space-y-1 list-disc list-inside">
<li> </li>
<li> , </li>
<li> 방식: 거래처 //</li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import OrderRegistrationModalDefinition from "./index";
import { OrderRegistrationModalConfigPanel } from "./OrderRegistrationModalConfigPanel";
interface OrderRegistrationModalRendererProps {
buttonText?: string;
buttonVariant?: "default" | "secondary" | "outline" | "ghost" | "destructive";
buttonSize?: "default" | "sm" | "lg";
style?: React.CSSProperties;
}
export function OrderRegistrationModalRenderer({
buttonText = "수주 등록",
buttonVariant = "default",
buttonSize = "default",
style,
}: OrderRegistrationModalRendererProps) {
const [isOpen, setIsOpen] = useState(false);
// style에서 width, height 제거 (h-full w-full로 제어)
const { width, height, ...restStyle } = style || {};
return (
<>
<Button
variant={buttonVariant}
size={buttonSize}
onClick={() => setIsOpen(true)}
className="h-full w-full"
style={restStyle}
>
<Plus className="mr-2 h-4 w-4" />
{buttonText}
</Button>
<OrderRegistrationModal open={isOpen} onOpenChange={setIsOpen} />
</>
);
}
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent({
...OrderRegistrationModalDefinition,
component: OrderRegistrationModalRenderer,
renderer: OrderRegistrationModalRenderer,
configPanel: OrderRegistrationModalConfigPanel,
} as any);
}

View File

@ -0,0 +1,68 @@
/**
*
* , ,
*/
import { ComponentDefinition, ComponentCategory } from "@/types/component";
export const OrderRegistrationModalDefinition: Omit<ComponentDefinition, "renderer" | "configPanel" | "component"> = {
id: "order-registration-modal",
name: "수주등록 모달",
category: ComponentCategory.ACTION,
webType: "button" as const,
description: "거래처, 품목을 선택하여 수주를 등록하는 모달",
icon: "FileText",
version: "1.0.0",
author: "WACE",
tags: ["수주", "주문", "영업", "모달"],
defaultSize: {
width: 120,
height: 40,
},
defaultConfig: {
buttonText: "수주 등록",
buttonVariant: "default",
buttonSize: "default",
},
defaultProps: {
style: {
width: "120px",
height: "40px",
},
},
configSchema: {
buttonText: {
type: "string",
label: "버튼 텍스트",
defaultValue: "수주 등록",
},
buttonVariant: {
type: "select",
label: "버튼 스타일",
options: [
{ label: "기본", value: "default" },
{ label: "보조", value: "secondary" },
{ label: "외곽선", value: "outline" },
{ label: "고스트", value: "ghost" },
],
defaultValue: "default",
},
buttonSize: {
type: "select",
label: "버튼 크기",
options: [
{ label: "작게", value: "sm" },
{ label: "기본", value: "default" },
{ label: "크게", value: "lg" },
],
defaultValue: "default",
},
},
};
export default OrderRegistrationModalDefinition;

View File

@ -0,0 +1,178 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export interface SectionCardProps {
component?: {
id: string;
componentConfig?: {
title?: string;
description?: string;
showHeader?: boolean;
headerPosition?: "top" | "left";
padding?: "none" | "sm" | "md" | "lg";
backgroundColor?: "default" | "muted" | "transparent";
borderStyle?: "solid" | "dashed" | "none";
collapsible?: boolean;
defaultOpen?: boolean;
};
style?: React.CSSProperties;
};
children?: React.ReactNode;
className?: string;
onClick?: (e?: React.MouseEvent) => void;
isSelected?: boolean;
isDesignMode?: boolean;
}
/**
* Section Card
*
*/
export function SectionCardComponent({
component,
children,
className,
onClick,
isSelected = false,
isDesignMode = false,
}: SectionCardProps) {
const config = component?.componentConfig || {};
const [isOpen, setIsOpen] = React.useState(config.defaultOpen !== false);
// 🔄 실시간 업데이트를 위해 config에서 직접 읽기
const title = config.title || "";
const description = config.description || "";
const showHeader = config.showHeader !== false; // 기본값: true
const padding = config.padding || "md";
const backgroundColor = config.backgroundColor || "default";
const borderStyle = config.borderStyle || "solid";
const collapsible = config.collapsible || false;
// 🎯 디버깅: config 값 확인
React.useEffect(() => {
console.log("✅ Section Card Config:", {
title,
description,
showHeader,
fullConfig: config,
});
}, [config.title, config.description, config.showHeader]);
// 패딩 매핑
const paddingMap = {
none: "p-0",
sm: "p-3",
md: "p-6",
lg: "p-8",
};
// 배경색 매핑
const backgroundColorMap = {
default: "bg-card",
muted: "bg-muted/30",
transparent: "bg-transparent",
};
// 테두리 스타일 매핑
const borderStyleMap = {
solid: "border-solid",
dashed: "border-dashed",
none: "border-none",
};
const handleToggle = () => {
if (collapsible) {
setIsOpen(!isOpen);
}
};
return (
<Card
className={cn(
"transition-all",
backgroundColorMap[backgroundColor],
borderStyleMap[borderStyle],
borderStyle === "none" && "shadow-none",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
isDesignMode && !children && "min-h-[150px]",
className
)}
style={component?.style}
onClick={onClick}
>
{/* 헤더 */}
{showHeader && (title || description || isDesignMode) && (
<CardHeader
className={cn(
"cursor-pointer",
collapsible && "hover:bg-accent/50 transition-colors"
)}
onClick={handleToggle}
>
<div className="flex items-center justify-between">
<div className="flex-1">
{(title || isDesignMode) && (
<CardTitle className="text-xl font-semibold">
{title || (isDesignMode ? "섹션 제목" : "")}
</CardTitle>
)}
{(description || isDesignMode) && (
<CardDescription className="text-sm text-muted-foreground mt-1.5">
{description || (isDesignMode ? "섹션 설명 (선택사항)" : "")}
</CardDescription>
)}
</div>
{collapsible && (
<div className={cn(
"ml-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)}
</div>
</CardHeader>
)}
{/* 컨텐츠 */}
{(!collapsible || isOpen) && (
<CardContent className={cn(paddingMap[padding])}>
{/* 디자인 모드에서 빈 상태 안내 */}
{isDesignMode && !children && (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<div className="text-center">
<div className="mb-2">🃏 Section Card</div>
<div className="text-xs"> </div>
</div>
</div>
)}
{/* 자식 컴포넌트들 */}
{children}
</CardContent>
)}
</Card>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
interface SectionCardConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export function SectionCardConfigPanel({
config,
onChange,
}: SectionCardConfigPanelProps) {
const handleChange = (key: string, value: any) => {
const newConfig = {
...config,
[key]: value,
};
onChange(newConfig);
// 🎯 실시간 업데이트를 위한 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
detail: { config: newConfig }
}));
}
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Card </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 헤더 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={config.showHeader !== false}
onCheckedChange={(checked) => handleChange("showHeader", checked)}
/>
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
</Label>
</div>
{/* 제목 */}
{config.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || ""}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="섹션 제목 입력"
className="h-9 text-xs"
/>
</div>
)}
{/* 설명 */}
{config.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Textarea
value={config.description || ""}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="섹션 설명 입력"
className="text-xs resize-none"
rows={2}
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.padding || "md"}
onValueChange={(value) => handleChange("padding", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (24px)</SelectItem>
<SelectItem value="lg"> (32px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.backgroundColor || "default"}
onValueChange={(value) => handleChange("backgroundColor", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ()</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="transparent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.borderStyle || "solid"}
onValueChange={(value) => handleChange("borderStyle", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible || false}
onCheckedChange={(checked) => handleChange("collapsible", checked)}
/>
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
/
</Label>
</div>
{config.collapsible && (
<div className="flex items-center space-x-2 ml-6">
<Checkbox
id="defaultOpen"
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => handleChange("defaultOpen", checked)}
/>
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
</Label>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SectionCardDefinition } from "./index";
import { SectionCardComponent } from "./SectionCardComponent";
/**
* Section Card
*
*/
export class SectionCardRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SectionCardDefinition;
render(): React.ReactElement {
return <SectionCardComponent {...this.props} renderer={this} />;
}
}
// 자동 등록 실행
SectionCardRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SectionCardRenderer.enableHotReload();
}

View File

@ -0,0 +1,43 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionCardComponent } from "./SectionCardComponent";
import { SectionCardConfigPanel } from "./SectionCardConfigPanel";
/**
* Section Card
*
*/
export const SectionCardDefinition = createComponentDefinition({
id: "section-card",
name: "Section Card",
nameEng: "Section Card",
description: "제목과 테두리가 있는 명확한 그룹화 컨테이너",
category: ComponentCategory.LAYOUT,
webType: "custom",
component: SectionCardComponent,
defaultConfig: {
title: "섹션 제목",
description: "",
showHeader: true,
padding: "md",
backgroundColor: "default",
borderStyle: "solid",
collapsible: false,
defaultOpen: true,
},
defaultSize: { width: 800, height: 250 },
configPanel: SectionCardConfigPanel,
icon: "LayoutPanelTop",
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
version: "1.0.0",
author: "WACE",
});
// 컴포넌트는 SectionCardRenderer에서 자동 등록됩니다
export { SectionCardComponent } from "./SectionCardComponent";
export { SectionCardConfigPanel } from "./SectionCardConfigPanel";
export { SectionCardRenderer } from "./SectionCardRenderer";

View File

@ -0,0 +1,138 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export interface SectionPaperProps {
component?: {
id: string;
componentConfig?: {
backgroundColor?: "default" | "muted" | "accent" | "primary" | "custom";
customColor?: string;
showBorder?: boolean;
borderStyle?: "none" | "subtle";
padding?: "none" | "sm" | "md" | "lg";
roundedCorners?: "none" | "sm" | "md" | "lg";
shadow?: "none" | "sm" | "md";
};
style?: React.CSSProperties;
};
children?: React.ReactNode;
className?: string;
onClick?: (e?: React.MouseEvent) => void;
isSelected?: boolean;
isDesignMode?: boolean;
}
/**
* Section Paper
* ( )
*/
export function SectionPaperComponent({
component,
children,
className,
onClick,
isSelected = false,
isDesignMode = false,
}: SectionPaperProps) {
const config = component?.componentConfig || {};
// 배경색 매핑
const backgroundColorMap = {
default: "bg-muted/20",
muted: "bg-muted/30",
accent: "bg-accent/20",
primary: "bg-primary/5",
custom: "",
};
// 패딩 매핑
const paddingMap = {
none: "p-0",
sm: "p-3",
md: "p-4",
lg: "p-6",
};
// 둥근 모서리 매핑
const roundedMap = {
none: "rounded-none",
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
};
// 그림자 매핑
const shadowMap = {
none: "",
sm: "shadow-sm",
md: "shadow-md",
};
const backgroundColor = config.backgroundColor || "default";
const padding = config.padding || "md";
const rounded = config.roundedCorners || "md";
const shadow = config.shadow || "none";
const showBorder = config.showBorder || false;
const borderStyle = config.borderStyle || "subtle";
// 커스텀 배경색 처리
const customBgStyle =
backgroundColor === "custom" && config.customColor
? { backgroundColor: config.customColor }
: {};
return (
<div
className={cn(
// 기본 스타일
"relative transition-colors",
// 배경색
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
// 패딩
paddingMap[padding],
// 둥근 모서리
roundedMap[rounded],
// 그림자
shadowMap[shadow],
// 테두리 (선택)
showBorder &&
borderStyle === "subtle" &&
"border border-border/30",
// 디자인 모드에서 선택된 상태
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
// 디자인 모드에서 빈 상태 표시
isDesignMode && !children && "min-h-[100px] border-2 border-dashed border-muted-foreground/30",
className
)}
style={{
...customBgStyle,
...component?.style,
}}
onClick={onClick}
>
{/* 디자인 모드에서 빈 상태 안내 */}
{isDesignMode && !children && (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
<div className="text-center">
<div className="mb-1">📄 Section Paper</div>
<div className="text-xs"> </div>
</div>
</div>
)}
{/* 자식 컴포넌트들 */}
{children}
</div>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
interface SectionPaperConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export function SectionPaperConfigPanel({
config,
onChange,
}: SectionPaperConfigPanelProps) {
const handleChange = (key: string, value: any) => {
const newConfig = {
...config,
[key]: value,
};
onChange(newConfig);
// 🎯 실시간 업데이트를 위한 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
detail: { config: newConfig }
}));
}
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Paper </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.backgroundColor || "default"}
onValueChange={(value) => handleChange("backgroundColor", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ( )</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="accent"> ( )</SelectItem>
<SelectItem value="primary"> </SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 커스텀 색상 */}
{config.backgroundColor === "custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.customColor || "#f0f0f0"}
onChange={(e) => handleChange("customColor", e.target.value)}
className="h-9"
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.padding || "md"}
onValueChange={(value) => handleChange("padding", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (16px)</SelectItem>
<SelectItem value="lg"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 둥근 모서리 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.roundedCorners || "md"}
onValueChange={(value) => handleChange("roundedCorners", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (2px)</SelectItem>
<SelectItem value="md"> (6px)</SelectItem>
<SelectItem value="lg"> (8px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그림자 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.shadow || "none"}
onValueChange={(value) => handleChange("shadow", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showBorder"
checked={config.showBorder || false}
onCheckedChange={(checked) => handleChange("showBorder", checked)}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SectionPaperDefinition } from "./index";
import { SectionPaperComponent } from "./SectionPaperComponent";
/**
* Section Paper
*
*/
export class SectionPaperRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SectionPaperDefinition;
render(): React.ReactElement {
return <SectionPaperComponent {...this.props} renderer={this} />;
}
}
// 자동 등록 실행
SectionPaperRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SectionPaperRenderer.enableHotReload();
}

View File

@ -0,0 +1,40 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionPaperComponent } from "./SectionPaperComponent";
import { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
/**
* Section Paper
* ( )
*/
export const SectionPaperDefinition = createComponentDefinition({
id: "section-paper",
name: "Section Paper",
nameEng: "Section Paper",
description: "배경색 기반의 미니멀한 그룹화 컨테이너 (색종이 컨셉)",
category: ComponentCategory.LAYOUT,
webType: "custom",
component: SectionPaperComponent,
defaultConfig: {
backgroundColor: "default",
padding: "md",
roundedCorners: "md",
shadow: "none",
showBorder: false,
},
defaultSize: { width: 800, height: 200 },
configPanel: SectionPaperConfigPanel,
icon: "Square",
tags: ["섹션", "그룹", "배경", "컨테이너", "색종이", "paper"],
version: "1.0.0",
author: "WACE",
});
// 컴포넌트는 SectionPaperRenderer에서 자동 등록됩니다
export { SectionPaperComponent } from "./SectionPaperComponent";
export { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
export { SectionPaperRenderer } from "./SectionPaperRenderer";

View File

@ -0,0 +1,429 @@
"use client";
import React, { useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, Calculator } from "lucide-react";
import { CalculationNode, CalculationStep, AdditionalFieldDefinition } from "./types";
interface CalculationBuilderProps {
steps: CalculationStep[];
availableFields: AdditionalFieldDefinition[];
onChange: (steps: CalculationStep[]) => void;
}
export const CalculationBuilder: React.FC<CalculationBuilderProps> = ({
steps,
availableFields,
onChange,
}) => {
const [previewValues, setPreviewValues] = useState<Record<string, number>>({});
// 새 단계 추가
const addStep = () => {
const newStep: CalculationStep = {
id: `step_${Date.now()}`,
label: `단계 ${steps.length + 1}`,
expression: {
type: "field",
fieldName: "",
},
};
onChange([...steps, newStep]);
};
// 단계 삭제
const removeStep = (stepId: string) => {
onChange(steps.filter((s) => s.id !== stepId));
};
// 단계 업데이트
const updateStep = (stepId: string, updates: Partial<CalculationStep>) => {
onChange(
steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s))
);
};
// 간단한 표현식 렌더링
const renderSimpleExpression = (step: CalculationStep) => {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
{/* 왼쪽 항 */}
<Select
value={step.expression.type === "field" ? step.expression.fieldName || "" : step.expression.type}
onValueChange={(value) => {
if (value === "previous") {
updateStep(step.id, {
expression: { type: "previous" },
});
} else if (value === "constant") {
updateStep(step.id, {
expression: { type: "constant", value: 0 },
});
} else {
updateStep(step.id, {
expression: { type: "field", fieldName: value },
});
}
}}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="항목 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="previous"> </SelectItem>
<SelectItem value="constant"></SelectItem>
{availableFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
{step.expression.type === "constant" && (
<Input
type="number"
value={step.expression.value || 0}
onChange={(e) => {
updateStep(step.id, {
expression: {
...step.expression,
value: parseFloat(e.target.value) || 0,
},
});
}}
className="h-8 w-24 text-xs"
placeholder="값"
/>
)}
</div>
{/* 연산 추가 버튼 */}
{step.expression.type !== "operation" && (
<Button
variant="outline"
size="sm"
onClick={() => {
const currentExpression = step.expression;
updateStep(step.id, {
expression: {
type: "operation",
operator: "+",
left: currentExpression,
right: { type: "constant", value: 0 },
},
});
}}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
)}
{/* 연산식 */}
{step.expression.type === "operation" && (
<div className="space-y-2 border-l-2 border-primary pl-3 ml-2">
{renderOperationExpression(step)}
</div>
)}
</div>
);
};
// 연산식 렌더링
const renderOperationExpression = (step: CalculationStep) => {
if (step.expression.type !== "operation") return null;
return (
<div className="flex items-center gap-2 flex-wrap">
{/* 왼쪽 항 */}
<div className="text-xs text-muted-foreground">
{renderNodeLabel(step.expression.left)}
</div>
{/* 연산자 */}
<Select
value={step.expression.operator || "+"}
onValueChange={(value) => {
updateStep(step.id, {
expression: {
...step.expression,
operator: value as any,
},
});
}}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="+">+</SelectItem>
<SelectItem value="-">-</SelectItem>
<SelectItem value="*">×</SelectItem>
<SelectItem value="/">÷</SelectItem>
<SelectItem value="%">%</SelectItem>
</SelectContent>
</Select>
{/* 오른쪽 항 */}
<Select
value={
step.expression.right?.type === "field"
? step.expression.right.fieldName || ""
: step.expression.right?.type || ""
}
onValueChange={(value) => {
if (value === "constant") {
updateStep(step.id, {
expression: {
...step.expression,
right: { type: "constant", value: 0 },
},
});
} else {
updateStep(step.id, {
expression: {
...step.expression,
right: { type: "field", fieldName: value },
},
});
}
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="항목 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="constant"></SelectItem>
{availableFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
{step.expression.right?.type === "constant" && (
<Input
type="number"
value={step.expression.right.value || 0}
onChange={(e) => {
updateStep(step.id, {
expression: {
...step.expression,
right: {
...step.expression.right!,
value: parseFloat(e.target.value) || 0,
},
},
});
}}
className="h-7 w-24 text-xs"
placeholder="값"
/>
)}
</div>
);
};
// 노드 라벨 표시
const renderNodeLabel = (node?: CalculationNode): string => {
if (!node) return "";
switch (node.type) {
case "field":
const field = availableFields.find((f) => f.name === node.fieldName);
return field?.label || node.fieldName || "필드";
case "constant":
return String(node.value || 0);
case "previous":
return "이전 결과";
case "operation":
const left = renderNodeLabel(node.left);
const right = renderNodeLabel(node.right);
const op = node.operator === "*" ? "×" : node.operator === "/" ? "÷" : node.operator;
return `(${left} ${op} ${right})`;
default:
return "";
}
};
// 함수 적용 UI
const renderFunctionStep = (step: CalculationStep) => {
if (step.expression.type !== "function") return null;
return (
<div className="flex items-center gap-2">
<Select
value={step.expression.functionName || "round"}
onValueChange={(value) => {
updateStep(step.id, {
expression: {
type: "function",
functionName: value as any,
params: [{ type: "previous" }],
},
});
}}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="함수 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="round"></SelectItem>
<SelectItem value="floor"></SelectItem>
<SelectItem value="ceil"></SelectItem>
<SelectItem value="abs"></SelectItem>
</SelectContent>
</Select>
{(step.expression.functionName === "round" ||
step.expression.functionName === "floor" ||
step.expression.functionName === "ceil") && (
<>
<span className="text-xs text-muted-foreground">:</span>
<Select
value={
step.expression.params?.[1]?.type === "field"
? step.expression.params[1].fieldName || ""
: String(step.expression.params?.[1]?.value || "1")
}
onValueChange={(value) => {
const isField = availableFields.some((f) => f.name === value);
updateStep(step.id, {
expression: {
...step.expression,
params: [
{ type: "previous" },
isField
? { type: "field", fieldName: value }
: { type: "constant", value: parseFloat(value) },
],
},
});
}}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="단위" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="1000">1,000</SelectItem>
{availableFields.map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button variant="outline" size="sm" onClick={addStep} className="h-7 text-xs">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{steps.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-6 text-center">
<Calculator className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{steps.map((step, idx) => (
<Card key={step.id} className="border-primary/30">
<CardHeader className="pb-2 pt-3 px-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xs font-medium">
{step.label || `단계 ${idx + 1}`}
</CardTitle>
<div className="flex items-center gap-1">
<Input
value={step.label}
onChange={(e) => updateStep(step.id, { label: e.target.value })}
placeholder={`단계 ${idx + 1}`}
className="h-6 w-24 text-xs"
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeStep(step.id)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pb-3 px-3">
{step.expression.type === "function"
? renderFunctionStep(step)
: renderSimpleExpression(step)}
{/* 함수 적용 버튼 */}
{step.expression.type !== "function" && (
<Button
variant="outline"
size="sm"
onClick={() => {
updateStep(step.id, {
expression: {
type: "function",
functionName: "round",
params: [{ type: "previous" }, { type: "constant", value: 1 }],
},
});
}}
className="h-7 text-xs mt-2"
>
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* 미리보기 */}
{steps.length > 0 && (
<Card className="bg-muted/30">
<CardContent className="py-3 px-3">
<div className="text-xs">
<span className="font-semibold">:</span>
<div className="mt-1 font-mono text-muted-foreground">
{steps.map((step, idx) => (
<div key={step.id}>
{idx + 1}. {renderNodeLabel(step.expression)}
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
};

View File

@ -0,0 +1,248 @@
# SelectedItemsDetailInput 컴포넌트
선택된 항목들의 상세 정보를 입력하는 컴포넌트입니다.
## 개요
이 컴포넌트는 다음과 같은 흐름에서 사용됩니다:
1. **첫 번째 모달**: TableList에서 여러 항목 선택 (체크박스)
2. **버튼 클릭**: "다음" 버튼 클릭 → 선택된 데이터를 modalDataStore에 저장
3. **두 번째 모달**: SelectedItemsDetailInput이 자동으로 데이터를 읽어와서 표시
4. **추가 입력**: 각 항목별로 추가 정보 입력 (거래처 품번, 단가 등)
5. **저장**: 모든 데이터를 백엔드로 일괄 전송
## 주요 기능
- ✅ 전달받은 원본 데이터 표시 (읽기 전용)
- ✅ 각 항목별 추가 입력 필드 제공
- ✅ Grid/Table 레이아웃 또는 Card 레이아웃 지원
- ✅ 필드별 타입 지정 (text, number, date, select, checkbox, textarea)
- ✅ 필수 입력 검증
- ✅ 항목 삭제 기능 (선택적)
## 사용 방법
### 1단계: 첫 번째 모달 (품목 선택)
```tsx
// TableList 컴포넌트 설정
{
type: "table-list",
config: {
selectedTable: "item_info",
multiSelect: true, // 다중 선택 활성화
columns: [
{ columnName: "item_code", label: "품목코드" },
{ columnName: "item_name", label: "품목명" },
{ columnName: "spec", label: "규격" },
{ columnName: "unit", label: "단위" },
{ columnName: "price", label: "단가" }
]
}
}
// "다음" 버튼 설정
{
type: "button-primary",
config: {
text: "다음 (상세정보 입력)",
action: {
type: "openModalWithData", // 새 액션 타입
targetScreenId: "123", // 두 번째 모달 화면 ID
dataSourceId: "table-list-456" // TableList 컴포넌트 ID
}
}
}
```
### 2단계: 두 번째 모달 (상세 입력)
```tsx
// SelectedItemsDetailInput 컴포넌트 설정
{
type: "selected-items-detail-input",
config: {
dataSourceId: "table-list-456", // 첫 번째 모달의 TableList ID
targetTable: "sales_detail", // 최종 저장 테이블
layout: "grid", // 테이블 형식
// 전달받은 원본 데이터 중 표시할 컬럼
displayColumns: ["item_code", "item_name", "spec", "unit"],
// 추가 입력 필드 정의
additionalFields: [
{
name: "customer_item_code",
label: "거래처 품번",
type: "text",
required: false
},
{
name: "customer_item_name",
label: "거래처 품명",
type: "text",
required: false
},
{
name: "year",
label: "연도",
type: "select",
required: true,
options: [
{ value: "2024", label: "2024년" },
{ value: "2025", label: "2025년" }
]
},
{
name: "currency",
label: "통화단위",
type: "select",
required: true,
options: [
{ value: "KRW", label: "KRW (원)" },
{ value: "USD", label: "USD (달러)" }
]
},
{
name: "unit_price",
label: "단가",
type: "number",
required: true
},
{
name: "quantity",
label: "수량",
type: "number",
required: true
}
],
showIndex: true,
allowRemove: true // 항목 삭제 허용
}
}
```
### 3단계: 저장 버튼
```tsx
{
type: "button-primary",
config: {
text: "저장",
action: {
type: "save",
targetTable: "sales_detail",
// formData에 selected_items 데이터가 자동으로 포함됨
}
}
}
```
## 데이터 구조
### 전달되는 데이터 형식
```typescript
const modalData: ModalDataItem[] = [
{
id: "SALE-003", // 항목 ID
originalData: { // 원본 데이터 (TableList에서 선택한 행)
item_code: "SALE-003",
item_name: "와셔 M8",
spec: "M8",
unit: "EA",
price: 50
},
additionalData: { // 사용자가 입력한 추가 데이터
customer_item_code: "ABC-001",
customer_item_name: "와셔",
year: "2025",
currency: "KRW",
unit_price: 50,
quantity: 100
}
},
// ... 더 많은 항목들
];
```
## 설정 옵션
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `dataSourceId` | string | - | 데이터를 전달하는 컴포넌트 ID (필수) |
| `displayColumns` | string[] | [] | 표시할 원본 데이터 컬럼명 |
| `additionalFields` | AdditionalFieldDefinition[] | [] | 추가 입력 필드 정의 |
| `targetTable` | string | - | 최종 저장 대상 테이블 |
| `layout` | "grid" \| "card" | "grid" | 레이아웃 모드 |
| `showIndex` | boolean | true | 항목 번호 표시 여부 |
| `allowRemove` | boolean | false | 항목 삭제 허용 여부 |
| `emptyMessage` | string | "전달받은 데이터가 없습니다." | 빈 상태 메시지 |
| `disabled` | boolean | false | 비활성화 여부 |
| `readonly` | boolean | false | 읽기 전용 여부 |
## 추가 필드 정의
```typescript
interface AdditionalFieldDefinition {
name: string; // 필드명 (컬럼명)
label: string; // 필드 라벨
type: "text" | "number" | "date" | "select" | "checkbox" | "textarea";
required?: boolean; // 필수 입력 여부
placeholder?: string; // 플레이스홀더
defaultValue?: any; // 기본값
options?: Array<{ label: string; value: string }>; // 선택 옵션 (select 타입일 때)
validation?: { // 검증 규칙
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
};
}
```
## 실전 예시: 수주 등록 화면
### 시나리오
1. 품목 선택 모달에서 여러 품목 선택
2. "다음" 버튼 클릭
3. 각 품목별로 거래처 정보, 단가, 수량 입력
4. "저장" 버튼으로 일괄 저장
### 구현
```tsx
// [모달 1] 품목 선택
<TableList id="item-selection-table" multiSelect={true} />
<Button action="openModalWithData" targetScreenId="detail-input-modal" dataSourceId="item-selection-table" />
// [모달 2] 상세 입력
<SelectedItemsDetailInput
dataSourceId="item-selection-table"
displayColumns={["item_code", "item_name", "spec"]}
additionalFields={[
{ name: "customer_item_code", label: "거래처 품번", type: "text" },
{ name: "unit_price", label: "단가", type: "number", required: true },
{ name: "quantity", label: "수량", type: "number", required: true }
]}
targetTable="sales_detail"
/>
<Button action="save" />
```
## 주의사항
1. **dataSourceId 일치**: 첫 번째 모달의 TableList ID와 두 번째 모달의 dataSourceId가 정확히 일치해야 합니다.
2. **컬럼명 정확성**: displayColumns와 additionalFields의 name은 실제 데이터베이스 컬럼명과 일치해야 합니다.
3. **필수 필드 검증**: required=true인 필드는 반드시 입력해야 저장이 가능합니다.
4. **데이터 정리**: 모달이 닫힐 때 modalDataStore의 데이터가 자동으로 정리됩니다.
## 향후 개선 사항
- [ ] 일괄 수정 기능 (모든 항목에 같은 값 적용)
- [ ] 엑셀 업로드로 일괄 입력
- [ ] 조건부 필드 표시 (특정 조건에서만 필드 활성화)
- [ ] 커스텀 검증 규칙
- [ ] 실시간 계산 필드 (단가 × 수량 = 금액)

View File

@ -0,0 +1,51 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SelectedItemsDetailInputDefinition } from "./index";
import { SelectedItemsDetailInputComponent } from "./SelectedItemsDetailInputComponent";
/**
* SelectedItemsDetailInput
*
*/
export class SelectedItemsDetailInputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SelectedItemsDetailInputDefinition;
render(): React.ReactElement {
return <SelectedItemsDetailInputComponent {...this.props} renderer={this} />;
}
/**
*
*/
// text 타입 특화 속성 처리
protected getSelectedItemsDetailInputProps() {
const baseProps = this.getWebTypeProps();
// text 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 text 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
SelectedItemsDetailInputRenderer.registerSelf();

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { SelectedItemsDetailInputWrapper } from "./SelectedItemsDetailInputComponent";
import { SelectedItemsDetailInputConfigPanel } from "./SelectedItemsDetailInputConfigPanel";
import { SelectedItemsDetailInputConfig } from "./types";
/**
* SelectedItemsDetailInput
*
*/
export const SelectedItemsDetailInputDefinition = createComponentDefinition({
id: "selected-items-detail-input",
name: "선택 항목 상세입력",
nameEng: "SelectedItemsDetailInput Component",
description: "선택된 항목들의 상세 정보를 입력하는 컴포넌트",
category: ComponentCategory.INPUT,
webType: "text",
component: SelectedItemsDetailInputWrapper,
defaultConfig: {
dataSourceId: "",
displayColumns: [],
additionalFields: [],
targetTable: "",
layout: "grid",
showIndex: true,
allowRemove: false,
emptyMessage: "전달받은 데이터가 없습니다.",
disabled: false,
readonly: false,
} as SelectedItemsDetailInputConfig,
defaultSize: { width: 800, height: 400 },
configPanel: SelectedItemsDetailInputConfigPanel,
icon: "Table",
tags: ["선택", "상세입력", "반복", "테이블", "데이터전달"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/selected-items-detail-input",
});
// 컴포넌트는 SelectedItemsDetailInputRenderer에서 자동 등록됩니다
// 타입 내보내기
export type { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";

View File

@ -0,0 +1,324 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
*
*/
export interface AdditionalFieldDefinition {
/** 필드명 (컬럼명) */
name: string;
/** 필드 라벨 */
label: string;
/** 입력 타입 */
type: "text" | "number" | "date" | "select" | "checkbox" | "textarea";
/** 🆕 데이터베이스 inputType (실제 렌더링 시 사용) */
inputType?: string;
/** 🆕 코드 카테고리 (inputType이 code/category일 때) */
codeCategory?: string;
/** 필수 입력 여부 */
required?: boolean;
/** 플레이스홀더 */
placeholder?: string;
/** 기본값 */
defaultValue?: any;
/** 🆕 원본 데이터에서 자동으로 값을 가져올 필드명 */
autoFillFrom?: string;
/** 🆕 자동 채우기할 데이터의 테이블명 (비워두면 주 데이터 소스 사용) */
autoFillFromTable?: string;
/** 선택 옵션 (type이 select일 때) */
options?: Array<{ label: string; value: string }>;
/** 필드 너비 (px 또는 %) */
width?: string;
/** 🆕 필드 그룹 ID (같은 그룹ID를 가진 필드들은 같은 카드에 표시) */
groupId?: string;
/** 검증 규칙 */
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
};
}
/**
*
*/
export interface FieldGroup {
/** 그룹 ID */
id: string;
/** 그룹 제목 */
title: string;
/** 그룹 설명 (선택사항) */
description?: string;
/** 그룹 표시 순서 */
order?: number;
/** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */
displayItems?: DisplayItem[];
}
/**
* 🆕
*/
export type CalculationNodeType = "field" | "constant" | "operation" | "function" | "previous";
export interface CalculationNode {
type: CalculationNodeType;
// field: 필드명
fieldName?: string;
// constant: 상수값
value?: number;
// operation: 연산
operator?: "+" | "-" | "*" | "/" | "%" | "^";
left?: CalculationNode;
right?: CalculationNode;
// function: 함수
functionName?: "round" | "floor" | "ceil" | "abs" | "max" | "min";
params?: CalculationNode[];
}
/**
* 🆕
*/
export interface CalculationStep {
id: string;
label: string;
expression: CalculationNode;
}
/**
* 🆕
*/
export interface AutoCalculationConfig {
/** 계산 대상 필드명 (예: calculated_price) */
targetField: string;
/** 🆕 계산 방식 */
mode: "template" | "custom";
/** 템플릿 모드 (기존 방식) */
inputFields?: {
basePrice: string;
discountType: string;
discountValue: string;
roundingType: string;
roundingUnit: string;
};
calculationType?: "price" | "custom";
valueMapping?: {
discountType?: {
[valueCode: string]: "none" | "rate" | "amount";
};
roundingType?: {
[valueCode: string]: "none" | "round" | "floor" | "ceil";
};
roundingUnit?: {
[valueCode: string]: number;
};
};
/** 커스텀 모드 (계산식 빌더) */
calculationSteps?: CalculationStep[];
}
/**
* 🆕
*/
export interface ParentDataMapping {
/** 소스 테이블명 (필수) */
sourceTable: string;
/** 소스 테이블의 필드명 */
sourceField: string;
/** 저장할 테이블의 필드명 */
targetField: string;
/** 부모 데이터가 없을 때 사용할 기본값 (선택사항) */
defaultValue?: any;
}
/**
* SelectedItemsDetailInput
*/
export interface SelectedItemsDetailInputConfig extends ComponentConfig {
/**
* ID (TableList ID )
* ID를 modalDataStore에서
*/
dataSourceId?: string;
/**
* 🆕 ( )
* : "item_info"
*/
sourceTable?: string;
/**
* (name, label, width)
*
*/
displayColumns?: Array<{ name: string; label: string; width?: string }>;
/**
*
*
*/
additionalFields?: AdditionalFieldDefinition[];
/**
* 🆕
*
*/
fieldGroups?: FieldGroup[];
/**
*
*/
targetTable?: string;
/**
* 🆕
* (: 거래처 )
* : { sourceField: "id", targetField: "customer_id" }
*/
parentDataMapping?: ParentDataMapping[];
/**
* 🆕
*
*/
autoCalculation?: AutoCalculationConfig;
/**
*
* - grid: 테이블 ()
* - card: 카드
*/
layout?: "grid" | "card";
/**
*
*/
showIndex?: boolean;
/**
*
*/
allowRemove?: boolean;
/**
* 🆕
* - inline: 항상 ()
* - modal: 추가 ,
*/
inputMode?: "inline" | "modal";
/**
*
*/
emptyMessage?: string;
// 공통 설정
disabled?: boolean;
readonly?: boolean;
}
/**
* 🆕 (: 그룹1의 )
*/
export interface GroupEntry {
/** 입력 항목 고유 ID */
id: string;
/** 입력된 필드 데이터 */
[key: string]: any;
}
/**
* 🆕
*/
export type DisplayItemType = "icon" | "field" | "text" | "badge";
/**
* 🆕
*/
export type EmptyBehavior = "hide" | "default" | "blank";
/**
* 🆕
*/
export type DisplayFieldFormat = "text" | "date" | "currency" | "number" | "badge";
/**
* 🆕 (, , , )
*/
export interface DisplayItem {
/** 항목 타입 */
type: DisplayItemType;
/** 고유 ID */
id: string;
// === type: "field" 인 경우 ===
/** 필드명 (컬럼명) */
fieldName?: string;
/** 라벨 (예: "거래처:", "단가:") */
label?: string;
/** 표시 형식 */
format?: DisplayFieldFormat;
/** 빈 값일 때 동작 */
emptyBehavior?: EmptyBehavior;
/** 기본값 (빈 값일 때 표시) */
defaultValue?: string;
// === type: "icon" 인 경우 ===
/** 아이콘 이름 (lucide-react 아이콘명) */
icon?: string;
// === type: "text" 인 경우 ===
/** 텍스트 내용 */
value?: string;
// === type: "badge" 인 경우 ===
/** 배지 스타일 */
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
// === 공통 스타일 ===
/** 굵게 표시 */
bold?: boolean;
/** 밑줄 표시 */
underline?: boolean;
/** 기울임 표시 */
italic?: boolean;
/** 텍스트 색상 */
color?: string;
/** 배경 색상 */
backgroundColor?: string;
}
/**
* 🆕 +
*
* : { "group1": [entry1, entry2], "group2": [entry1, entry2, entry3] }
*/
export interface ItemData {
/** 품목 고유 ID */
id: string;
/** 원본 데이터 (품목 정보) */
originalData: Record<string, any>;
/** 필드 그룹별 입력 항목들 { groupId: [entry1, entry2, ...] } */
fieldGroups: Record<string, GroupEntry[]>;
}
/**
* SelectedItemsDetailInput Props
*/
export interface SelectedItemsDetailInputProps {
id?: string;
name?: string;
value?: any;
config?: SelectedItemsDetailInputConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onSave?: (data: any[]) => void;
}

View File

@ -21,6 +21,14 @@ export interface SplitPanelLayoutConfig {
width?: number;
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
format?: {
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
decimalPlaces?: number; // 소수점 자릿수
prefix?: string; // 접두사 (예: "₩", "$")
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -69,12 +77,23 @@ export interface SplitPanelLayoutConfig {
showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼
summaryColumnCount?: number; // 요약에서 표시할 컬럼 개수 (기본: 3)
summaryShowLabel?: boolean; // 요약에서 라벨 표시 여부 (기본: true)
columns?: Array<{
name: string;
label: string;
width?: number;
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
format?: {
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
decimalPlaces?: number; // 소수점 자릿수
prefix?: string; // 접두사 (예: "₩", "$")
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -113,6 +132,24 @@ export interface SplitPanelLayoutConfig {
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
// 🆕 중복 제거 설정
deduplication?: {
enabled: boolean; // 중복 제거 활성화
groupByColumn: string; // 중복 제거 기준 컬럼 (예: "item_id")
keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; // 어떤 행을 유지할지
sortColumn?: string; // keepStrategy가 latest/earliest일 때 정렬 기준 컬럼
};
// 🆕 수정 버튼 설정
editButton?: {
enabled: boolean; // 수정 버튼 표시 여부 (기본: true)
mode: "auto" | "modal"; // auto: 자동 편집 (인라인), modal: 커스텀 모달
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "수정")
buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline")
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
};
};
// 레이아웃 설정

View File

@ -1107,6 +1107,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
console.log("✅ [TableList] modalDataStore에 데이터 저장:", {
dataSourceId: tableConfig.selectedTable,
count: modalItems.length,
});
});
} else if (tableConfig.selectedTable && selectedRowsData.length === 0) {
// 선택 해제 시 데이터 제거
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
console.log("🗑️ [TableList] modalDataStore 데이터 제거:", tableConfig.selectedTable);
});
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
};
@ -1127,6 +1150,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
selectedRowsData: data,
});
}
// 🆕 modalDataStore에 전체 데이터 저장
if (tableConfig.selectedTable && data.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = data.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", {
dataSourceId: tableConfig.selectedTable,
count: modalItems.length,
});
});
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
@ -1137,6 +1177,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (onFormDataChange) {
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
}
// 🆕 modalDataStore 데이터 제거
if (tableConfig.selectedTable) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
console.log("🗑️ [TableList] modalDataStore 전체 데이터 제거:", tableConfig.selectedTable);
});
}
}
};
@ -1766,6 +1814,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [tableConfig.refreshInterval, isDesignMode]);
// 🆕 전역 테이블 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshTable = () => {
if (tableConfig.selectedTable && !isDesignMode) {
console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침");
setRefreshTrigger((prev) => prev + 1);
}
};
window.addEventListener("refreshTable", handleRefreshTable);
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [tableConfig.selectedTable, isDesignMode]);
// 초기 컬럼 너비 측정 (한 번만)
useEffect(() => {
if (!hasInitializedWidths.current && visibleColumns.length > 0) {

Some files were not shown because too many files have changed in this diff Show More