Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
@@ -41,6 +41,7 @@ export class DashboardController {
|
||||
isPublic = false,
|
||||
tags,
|
||||
category,
|
||||
settings,
|
||||
}: CreateDashboardRequest = req.body;
|
||||
|
||||
// 유효성 검증
|
||||
@@ -85,6 +86,7 @@ export class DashboardController {
|
||||
elements,
|
||||
tags,
|
||||
category,
|
||||
settings,
|
||||
};
|
||||
|
||||
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
|
||||
|
||||
@@ -165,7 +165,7 @@ export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 목록 조회 API
|
||||
* 수주 목록 조회 API (마스터 + 품목 JOIN)
|
||||
* GET /api/orders
|
||||
*/
|
||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
@@ -184,14 +184,14 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`writer LIKE $${paramIndex}`);
|
||||
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
||||
params.push(`%${companyCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (searchText) {
|
||||
whereConditions.push(`objid LIKE $${paramIndex}`);
|
||||
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
@@ -201,16 +201,47 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 카운트 쿼리
|
||||
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
|
||||
// 카운트 쿼리 (고유한 수주 개수)
|
||||
const countQuery = `
|
||||
SELECT COUNT(DISTINCT m.objid) as count
|
||||
FROM order_mng_master m
|
||||
${whereClause}
|
||||
`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||
|
||||
// 데이터 쿼리
|
||||
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
||||
const dataQuery = `
|
||||
SELECT * FROM order_mng_master
|
||||
SELECT
|
||||
m.objid as order_no,
|
||||
m.partner_objid,
|
||||
m.final_delivery_date,
|
||||
m.reason,
|
||||
m.status,
|
||||
m.reg_date,
|
||||
m.writer,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
CASE WHEN s.objid IS NOT NULL THEN
|
||||
json_build_object(
|
||||
'sub_objid', s.objid,
|
||||
'part_objid', s.part_objid,
|
||||
'partner_price', s.partner_price,
|
||||
'partner_qty', s.partner_qty,
|
||||
'delivery_date', s.delivery_date,
|
||||
'status', s.status,
|
||||
'regdate', s.regdate
|
||||
)
|
||||
END
|
||||
ORDER BY s.regdate
|
||||
) FILTER (WHERE s.objid IS NOT NULL),
|
||||
'[]'::json
|
||||
) as items
|
||||
FROM order_mng_master m
|
||||
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
|
||||
${whereClause}
|
||||
ORDER BY reg_date DESC
|
||||
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
|
||||
ORDER BY m.reg_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
@@ -219,6 +250,13 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("수주 목록 조회 성공", {
|
||||
companyCode,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
itemCount: dataResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
|
||||
@@ -24,10 +24,20 @@ export class DashboardService {
|
||||
const dashboardId = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
console.log("🔍 [createDashboard] 받은 데이터:", {
|
||||
title: data.title,
|
||||
settings: data.settings,
|
||||
settingsType: typeof data.settings,
|
||||
settingsStringified: JSON.stringify(data.settings || {}),
|
||||
});
|
||||
|
||||
try {
|
||||
// 트랜잭션으로 대시보드와 요소들을 함께 생성
|
||||
const result = await PostgreSQLService.transaction(async (client) => {
|
||||
// 1. 대시보드 메인 정보 저장
|
||||
const settingsJson = JSON.stringify(data.settings || {});
|
||||
console.log("🔍 [createDashboard] DB INSERT settings:", settingsJson);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO dashboards (
|
||||
@@ -46,7 +56,7 @@ export class DashboardService {
|
||||
JSON.stringify(data.tags || []),
|
||||
data.category || null,
|
||||
0,
|
||||
JSON.stringify(data.settings || {}),
|
||||
settingsJson,
|
||||
companyCode || "DEFAULT",
|
||||
]
|
||||
);
|
||||
@@ -351,6 +361,13 @@ export class DashboardService {
|
||||
|
||||
const dashboard = dashboardResult.rows[0];
|
||||
|
||||
// 🔍 디버깅: settings 원본 확인
|
||||
console.log("🔍 [getDashboardById] dashboard.settings 원본:", {
|
||||
type: typeof dashboard.settings,
|
||||
value: dashboard.settings,
|
||||
raw: JSON.stringify(dashboard.settings),
|
||||
});
|
||||
|
||||
// 2. 대시보드 요소들 조회
|
||||
const elementsQuery = `
|
||||
SELECT * FROM dashboard_elements
|
||||
@@ -400,7 +417,21 @@ export class DashboardService {
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
// 🔍 디버깅: settings 파싱
|
||||
let parsedSettings = undefined;
|
||||
if (dashboard.settings) {
|
||||
if (typeof dashboard.settings === 'string') {
|
||||
parsedSettings = JSON.parse(dashboard.settings);
|
||||
console.log("🔍 [getDashboardById] settings 문자열 파싱:", parsedSettings);
|
||||
} else {
|
||||
parsedSettings = dashboard.settings;
|
||||
console.log("🔍 [getDashboardById] settings 이미 객체:", parsedSettings);
|
||||
}
|
||||
} else {
|
||||
console.log("🔍 [getDashboardById] settings 없음 (null/undefined)");
|
||||
}
|
||||
|
||||
const result = {
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
@@ -412,9 +443,13 @@ export class DashboardService {
|
||||
tags: JSON.parse(dashboard.tags || "[]"),
|
||||
category: dashboard.category,
|
||||
viewCount: parseInt(dashboard.view_count || "0"),
|
||||
settings: dashboard.settings || undefined,
|
||||
settings: parsedSettings,
|
||||
elements,
|
||||
};
|
||||
|
||||
console.log("🔍 [getDashboardById] 최종 반환 settings:", result.settings);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Dashboard get error:", error);
|
||||
throw error;
|
||||
@@ -477,8 +512,13 @@ export class DashboardService {
|
||||
paramIndex++;
|
||||
}
|
||||
if (data.settings !== undefined) {
|
||||
const settingsJson = JSON.stringify(data.settings);
|
||||
console.log("🔍 [updateDashboard] DB UPDATE settings:", {
|
||||
original: data.settings,
|
||||
stringified: settingsJson,
|
||||
});
|
||||
updateFields.push(`settings = $${paramIndex}`);
|
||||
updateParams.push(JSON.stringify(data.settings));
|
||||
updateParams.push(settingsJson);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
|
||||
@@ -320,19 +320,34 @@ export class DynamicFormService {
|
||||
Object.keys(dataToInsert).forEach((key) => {
|
||||
const value = dataToInsert[key];
|
||||
|
||||
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
|
||||
if (
|
||||
// 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열)
|
||||
let parsedArray: any[] | null = null;
|
||||
|
||||
// 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등)
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
parsedArray = value;
|
||||
console.log(
|
||||
`🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||||
);
|
||||
}
|
||||
// 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput)
|
||||
else if (
|
||||
typeof value === "string" &&
|
||||
value.trim().startsWith("[") &&
|
||||
value.trim().endsWith("]")
|
||||
) {
|
||||
try {
|
||||
const parsedArray = JSON.parse(value);
|
||||
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||
parsedArray = JSON.parse(value);
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||||
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
|
||||
);
|
||||
} catch (parseError) {
|
||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 파싱된 배열이 있으면 처리
|
||||
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||
let targetTable: string | undefined;
|
||||
@@ -352,13 +367,34 @@ export class DynamicFormService {
|
||||
componentId: key,
|
||||
});
|
||||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||
}
|
||||
|
||||
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
||||
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
||||
itemCount: actualData.length,
|
||||
firstItem: actualData[0],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
|
||||
const separateRepeaterData: typeof repeaterData = [];
|
||||
const mergedRepeaterData: typeof repeaterData = [];
|
||||
|
||||
repeaterData.forEach(repeater => {
|
||||
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||||
// 다른 테이블: 나중에 별도 저장
|
||||
separateRepeaterData.push(repeater);
|
||||
} else {
|
||||
// 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에)
|
||||
mergedRepeaterData.push(repeater);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🔄 Repeater 데이터 분류:`, {
|
||||
separate: separateRepeaterData.length, // 별도 테이블
|
||||
merged: mergedRepeaterData.length, // 메인 테이블과 병합
|
||||
});
|
||||
|
||||
// 존재하지 않는 컬럼 제거
|
||||
Object.keys(dataToInsert).forEach((key) => {
|
||||
if (!tableColumns.includes(key)) {
|
||||
@@ -369,9 +405,6 @@ export class DynamicFormService {
|
||||
}
|
||||
});
|
||||
|
||||
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
|
||||
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
|
||||
|
||||
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
||||
tableName,
|
||||
dataToInsert,
|
||||
@@ -452,28 +485,95 @@ export class DynamicFormService {
|
||||
const userId = data.updated_by || data.created_by || "system";
|
||||
const clientIp = ipAddress || "unknown";
|
||||
|
||||
const result = await transaction(async (client) => {
|
||||
// 세션 변수 설정
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
|
||||
// UPSERT 실행
|
||||
const res = await client.query(upsertQuery, values);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||
let result: any[];
|
||||
|
||||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||
if (mergedRepeaterData.length > 0) {
|
||||
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
||||
|
||||
result = [];
|
||||
|
||||
for (const repeater of mergedRepeaterData) {
|
||||
for (const item of repeater.data) {
|
||||
// 헤더 + 품목을 병합
|
||||
const mergedData = { ...dataToInsert, ...item };
|
||||
|
||||
// 타입 변환
|
||||
Object.keys(mergedData).forEach((columnName) => {
|
||||
const column = columnInfo.find((col) => col.column_name === columnName);
|
||||
if (column) {
|
||||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||
mergedData[columnName],
|
||||
column.data_type
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const mergedColumns = Object.keys(mergedData);
|
||||
const mergedValues: any[] = Object.values(mergedData);
|
||||
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
|
||||
|
||||
let mergedUpsertQuery: string;
|
||||
if (primaryKeys.length > 0) {
|
||||
const conflictColumns = primaryKeys.join(", ");
|
||||
const updateSet = mergedColumns
|
||||
.filter((col) => !primaryKeys.includes(col))
|
||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||
.join(", ");
|
||||
|
||||
mergedUpsertQuery = updateSet
|
||||
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||
VALUES (${mergedPlaceholders})
|
||||
ON CONFLICT (${conflictColumns})
|
||||
DO UPDATE SET ${updateSet}
|
||||
RETURNING *`
|
||||
: `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||
VALUES (${mergedPlaceholders})
|
||||
ON CONFLICT (${conflictColumns})
|
||||
DO NOTHING
|
||||
RETURNING *`;
|
||||
} else {
|
||||
mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||
VALUES (${mergedPlaceholders})
|
||||
RETURNING *`;
|
||||
}
|
||||
|
||||
console.log(`📝 병합 INSERT:`, { mergedData });
|
||||
|
||||
const itemResult = await transaction(async (client) => {
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
const res = await client.query(mergedUpsertQuery, mergedValues);
|
||||
return res.rows[0];
|
||||
});
|
||||
|
||||
result.push(itemResult);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
|
||||
} else {
|
||||
// 일반 모드: 헤더만 저장
|
||||
result = await transaction(async (client) => {
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
const res = await client.query(upsertQuery, values);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||
}
|
||||
|
||||
// 결과를 표준 형식으로 변환
|
||||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
|
||||
if (repeaterData.length > 0) {
|
||||
// 📝 별도 테이블 Repeater 데이터 저장
|
||||
if (separateRepeaterData.length > 0) {
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater`
|
||||
`🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개`
|
||||
);
|
||||
|
||||
for (const repeater of repeaterData) {
|
||||
for (const repeater of separateRepeaterData) {
|
||||
const targetTableName = repeater.targetTable || tableName;
|
||||
console.log(
|
||||
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
||||
@@ -497,8 +597,13 @@ export class DynamicFormService {
|
||||
created_by,
|
||||
updated_by,
|
||||
regdate: new Date(),
|
||||
// 🔥 멀티테넌시: company_code 필수 추가
|
||||
company_code: data.company_code || company_code,
|
||||
};
|
||||
|
||||
// 🔥 별도 테이블인 경우에만 외래키 추가
|
||||
// (같은 테이블이면 이미 병합 모드에서 처리됨)
|
||||
|
||||
// 대상 테이블에 존재하는 컬럼만 필터링
|
||||
Object.keys(itemData).forEach((key) => {
|
||||
if (!targetColumnNames.includes(key)) {
|
||||
|
||||
80
backend-node/src/types/order.ts
Normal file
80
backend-node/src/types/order.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 수주 관리 타입 정의
|
||||
*/
|
||||
|
||||
/**
|
||||
* 수주 품목 (order_mng_sub)
|
||||
*/
|
||||
export interface OrderItem {
|
||||
sub_objid: string; // 품목 고유 ID (예: ORD-20251121-051_1)
|
||||
part_objid: string; // 품목 코드
|
||||
partner_price: number; // 단가
|
||||
partner_qty: number; // 수량
|
||||
delivery_date: string | null; // 납기일
|
||||
status: string; // 상태
|
||||
regdate: string; // 등록일
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 마스터 (order_mng_master)
|
||||
*/
|
||||
export interface OrderMaster {
|
||||
order_no: string; // 수주 번호 (예: ORD-20251121-051)
|
||||
partner_objid: string; // 거래처 코드
|
||||
final_delivery_date: string | null; // 최종 납품일
|
||||
reason: string | null; // 메모/사유
|
||||
status: string; // 상태
|
||||
reg_date: string; // 등록일
|
||||
writer: string; // 작성자 (userId|companyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 + 품목 (API 응답)
|
||||
*/
|
||||
export interface OrderWithItems extends OrderMaster {
|
||||
items: OrderItem[]; // 품목 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 요청
|
||||
*/
|
||||
export interface CreateOrderRequest {
|
||||
inputMode: string; // 입력 방식
|
||||
salesType?: string; // 판매 유형 (국내/해외)
|
||||
priceType?: string; // 단가 방식
|
||||
customerCode: string; // 거래처 코드
|
||||
contactPerson?: string; // 담당자
|
||||
deliveryDestination?: string; // 납품처
|
||||
deliveryAddress?: string; // 납품장소
|
||||
deliveryDate?: string; // 납품일
|
||||
items: Array<{
|
||||
item_code?: string; // 품목 코드
|
||||
id?: string; // 품목 ID (item_code 대체)
|
||||
quantity?: number; // 수량
|
||||
unit_price?: number; // 단가
|
||||
selling_price?: number; // 판매가
|
||||
amount?: number; // 금액
|
||||
delivery_date?: string; // 품목별 납기일
|
||||
}>;
|
||||
memo?: string; // 메모
|
||||
tradeInfo?: {
|
||||
// 해외 판매 시
|
||||
incoterms?: string;
|
||||
paymentTerms?: string;
|
||||
currency?: string;
|
||||
portOfLoading?: string;
|
||||
portOfDischarge?: string;
|
||||
hsCode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 응답
|
||||
*/
|
||||
export interface CreateOrderResponse {
|
||||
orderNo: string; // 생성된 수주 번호
|
||||
masterObjid: string; // 마스터 ID
|
||||
itemCount: number; // 품목 개수
|
||||
totalAmount: number; // 전체 금액
|
||||
}
|
||||
|
||||
@@ -157,6 +157,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const dashboard = await dashboardApi.getDashboard(id);
|
||||
|
||||
console.log("🔍 [loadDashboard] 대시보드 응답:", {
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
settingsType: typeof (dashboard as any).settings,
|
||||
settingsValue: (dashboard as any).settings,
|
||||
});
|
||||
|
||||
// 대시보드 정보 설정
|
||||
setDashboardId(dashboard.id);
|
||||
setDashboardTitle(dashboard.title);
|
||||
@@ -164,6 +171,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
// 저장된 설정 복원
|
||||
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
|
||||
|
||||
console.log("🔍 [loadDashboard] 파싱된 settings:", {
|
||||
settings,
|
||||
resolution: settings?.resolution,
|
||||
backgroundColor: settings?.backgroundColor,
|
||||
});
|
||||
|
||||
// 배경색 설정
|
||||
if (settings?.backgroundColor) {
|
||||
setCanvasBackgroundColor(settings.backgroundColor);
|
||||
@@ -171,6 +184,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
|
||||
// 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
|
||||
const loadedResolution = settings?.resolution || "fhd";
|
||||
console.log("🔍 [loadDashboard] 로드할 resolution:", loadedResolution);
|
||||
setResolution(loadedResolution);
|
||||
|
||||
// 요소들 설정
|
||||
@@ -457,6 +471,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
},
|
||||
};
|
||||
|
||||
console.log("🔍 [handleSave] 업데이트 데이터:", {
|
||||
dashboardId,
|
||||
settings: updateData.settings,
|
||||
});
|
||||
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
@@ -471,6 +490,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||
},
|
||||
};
|
||||
|
||||
console.log("🔍 [handleSave] 생성 데이터:", {
|
||||
settings: dashboardData.settings,
|
||||
});
|
||||
|
||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||
setDashboardId(savedDashboard.id);
|
||||
}
|
||||
|
||||
@@ -162,3 +162,4 @@ export function getAllDescendants(
|
||||
return descendants;
|
||||
}
|
||||
|
||||
|
||||
|
||||
21
frontend/components/order/orderConstants.ts
Normal file
21
frontend/components/order/orderConstants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const INPUT_MODE = {
|
||||
CUSTOMER_FIRST: "customer_first",
|
||||
QUOTATION: "quotation",
|
||||
UNIT_PRICE: "unit_price",
|
||||
} as const;
|
||||
|
||||
export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
|
||||
|
||||
export const SALES_TYPE = {
|
||||
DOMESTIC: "domestic",
|
||||
EXPORT: "export",
|
||||
} as const;
|
||||
|
||||
export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
|
||||
|
||||
export const PRICE_TYPE = {
|
||||
STANDARD: "standard",
|
||||
CUSTOMER: "customer",
|
||||
} as const;
|
||||
|
||||
export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];
|
||||
@@ -337,12 +337,22 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
value,
|
||||
componentType: component.type,
|
||||
componentId: component.id,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📦 새 formData:", newData);
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
mode={initialData ? "edit" : "create"}
|
||||
mode="edit"
|
||||
isInModal={true}
|
||||
isInteractive={true}
|
||||
/>
|
||||
|
||||
@@ -131,6 +131,12 @@ export const dashboardApi = {
|
||||
* 대시보드 생성
|
||||
*/
|
||||
async createDashboard(data: CreateDashboardRequest): Promise<Dashboard> {
|
||||
console.log("🔍 [API createDashboard] 요청 데이터:", {
|
||||
data,
|
||||
settings: data.settings,
|
||||
stringified: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await apiRequest<Dashboard>("/dashboards", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -140,6 +146,10 @@ export const dashboardApi = {
|
||||
throw new Error(result.message || "대시보드 생성에 실패했습니다.");
|
||||
}
|
||||
|
||||
console.log("🔍 [API createDashboard] 응답 데이터:", {
|
||||
settings: result.data.settings,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
},
|
||||
|
||||
@@ -226,6 +236,13 @@ export const dashboardApi = {
|
||||
* 대시보드 수정
|
||||
*/
|
||||
async updateDashboard(id: string, data: Partial<CreateDashboardRequest>): Promise<Dashboard> {
|
||||
console.log("🔍 [API updateDashboard] 요청 데이터:", {
|
||||
id,
|
||||
data,
|
||||
settings: data.settings,
|
||||
stringified: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await apiRequest<Dashboard>(`/dashboards/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
@@ -235,6 +252,10 @@ export const dashboardApi = {
|
||||
throw new Error(result.message || "대시보드 수정에 실패했습니다.");
|
||||
}
|
||||
|
||||
console.log("🔍 [API updateDashboard] 응답 데이터:", {
|
||||
settings: result.data.settings,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -7,18 +7,23 @@ 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";
|
||||
import { AutocompleteSearchInputConfig } from "./types";
|
||||
import { ComponentRendererProps } from "../../DynamicComponentRenderer";
|
||||
|
||||
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
|
||||
export interface AutocompleteSearchInputProps extends ComponentRendererProps {
|
||||
config?: AutocompleteSearchInputConfig;
|
||||
tableName?: string;
|
||||
displayField?: string;
|
||||
valueField?: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
value?: any;
|
||||
onChange?: (value: any, fullData?: any) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
}
|
||||
|
||||
export function AutocompleteSearchInputComponent({
|
||||
component,
|
||||
config,
|
||||
tableName: propTableName,
|
||||
displayField: propDisplayField,
|
||||
@@ -29,9 +34,10 @@ export function AutocompleteSearchInputComponent({
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
showAdditionalInfo: propShowAdditionalInfo,
|
||||
additionalFields: propAdditionalFields,
|
||||
className,
|
||||
isInteractive = false,
|
||||
onFormDataChange,
|
||||
formData,
|
||||
}: AutocompleteSearchInputProps) {
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const tableName = config?.tableName || propTableName || "";
|
||||
@@ -39,8 +45,7 @@ export function AutocompleteSearchInputComponent({
|
||||
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);
|
||||
@@ -52,15 +57,20 @@ export function AutocompleteSearchInputComponent({
|
||||
filterCondition,
|
||||
});
|
||||
|
||||
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
||||
const currentValue = isInteractive && formData && component?.columnName
|
||||
? formData[component.columnName]
|
||||
: value;
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
if (currentValue && selectedData) {
|
||||
setInputValue(selectedData[displayField] || "");
|
||||
} else if (!value) {
|
||||
} else if (!currentValue) {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
}, [currentValue, displayField, selectedData]);
|
||||
|
||||
// 외부 클릭 감지
|
||||
useEffect(() => {
|
||||
@@ -81,45 +91,61 @@ export function AutocompleteSearchInputComponent({
|
||||
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);
|
||||
console.log("🔍 AutocompleteSearchInput handleSelect:", {
|
||||
item,
|
||||
valueField,
|
||||
value: item[valueField],
|
||||
config,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName: component?.columnName,
|
||||
});
|
||||
|
||||
// isInteractive 모드에서만 저장
|
||||
if (isInteractive && onFormDataChange) {
|
||||
// 필드 매핑 처리
|
||||
if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
|
||||
console.log("📋 필드 매핑 처리 시작:", config.fieldMappings);
|
||||
|
||||
config.fieldMappings.forEach((mapping: any, index: number) => {
|
||||
const targetField = mapping.targetField || mapping.targetColumn;
|
||||
|
||||
console.log(` 매핑 ${index + 1}:`, {
|
||||
sourceField: mapping.sourceField,
|
||||
targetField,
|
||||
label: mapping.label,
|
||||
});
|
||||
|
||||
if (mapping.sourceField && targetField) {
|
||||
const sourceValue = item[mapping.sourceField];
|
||||
|
||||
console.log(` 값: ${mapping.sourceField} = ${sourceValue}`);
|
||||
|
||||
if (sourceValue !== undefined) {
|
||||
console.log(` ✅ 저장: ${targetField} = ${sourceValue}`);
|
||||
onFormDataChange(targetField, sourceValue);
|
||||
} else {
|
||||
console.warn(` ⚠️ sourceField "${mapping.sourceField}"의 값이 undefined입니다`);
|
||||
}
|
||||
} else {
|
||||
console.warn(` ⚠️ 매핑 불완전: sourceField=${mapping.sourceField}, targetField=${targetField}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 기본 필드 저장 (columnName이 설정된 경우)
|
||||
if (component?.columnName) {
|
||||
console.log(`💾 기본 필드 저장: ${component.columnName} = ${item[valueField]}`);
|
||||
onFormDataChange(component.columnName, item[valueField]);
|
||||
}
|
||||
}
|
||||
|
||||
// onChange 콜백 호출 (호환성)
|
||||
onChange?.(item[valueField], item);
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
@@ -149,9 +175,9 @@ export function AutocompleteSearchInputComponent({
|
||||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
|
||||
className="h-8 pr-16 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<div className="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
{loading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
@@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({
|
||||
|
||||
{/* 드롭다운 결과 */}
|
||||
{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">
|
||||
<div className="absolute z-50 mt-1 max-h-[300px] w-full overflow-y-auto rounded-md border bg-background shadow-lg">
|
||||
{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" />
|
||||
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
|
||||
검색 중...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
@@ -189,37 +215,15 @@ export function AutocompleteSearchInputComponent({
|
||||
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"
|
||||
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ 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 { AutocompleteSearchInputConfig } from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -24,83 +23,14 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
}: AutocompleteSearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
|
||||
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [isLoadingSourceColumns, setIsLoadingSourceColumns] = useState(false);
|
||||
const [isLoadingTargetColumns, setIsLoadingTargetColumns] = useState(false);
|
||||
const [openSourceTableCombo, setOpenSourceTableCombo] = useState(false);
|
||||
const [openTargetTableCombo, setOpenTargetTableCombo] = 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);
|
||||
@@ -112,52 +42,76 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.searchFields || [];
|
||||
updateConfig({ searchFields: [...fields, ""] });
|
||||
};
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
setAllTables([]);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
const updateSearchField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
// 외부 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!localConfig.tableName) {
|
||||
setSourceTableColumns([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingSourceColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
||||
if (response.success && response.data) {
|
||||
setSourceTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
setSourceTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingSourceColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [localConfig.tableName]);
|
||||
|
||||
const removeSearchField = (index: number) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
// 저장 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadTargetColumns = async () => {
|
||||
if (!localConfig.targetTable) {
|
||||
setTargetTableColumns([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingTargetColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.targetTable);
|
||||
if (response.success && response.data) {
|
||||
setTargetTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
setTargetTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingTargetColumns(false);
|
||||
}
|
||||
};
|
||||
loadTargetColumns();
|
||||
}, [localConfig.targetTable]);
|
||||
|
||||
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: "" },
|
||||
],
|
||||
fieldMappings: [...mappings, { sourceField: "", targetField: "", label: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
const updateFieldMapping = (index: number, updates: Partial<FieldMapping>) => {
|
||||
const updateFieldMapping = (index: number, updates: any) => {
|
||||
const mappings = [...(localConfig.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], ...updates };
|
||||
updateConfig({ fieldMappings: mappings });
|
||||
@@ -170,21 +124,22 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 1. 외부 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<Label className="text-xs font-semibold sm:text-sm">1. 외부 테이블 선택 *</Label>
|
||||
<Popover open={openSourceTableCombo} onOpenChange={setOpenSourceTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
aria-expanded={openSourceTableCombo}
|
||||
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 ? "로딩 중..." : "테이블 선택"}
|
||||
: isLoadingTables ? "로딩 중..." : "데이터를 가져올 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -200,7 +155,7 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({ tableName: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
setOpenSourceTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
@@ -216,13 +171,11 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
검색할 데이터가 저장된 테이블을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2. 표시 필드 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">표시 필드 *</Label>
|
||||
<Label className="text-xs font-semibold sm:text-sm">2. 표시 필드 *</Label>
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -230,11 +183,11 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||
>
|
||||
{localConfig.displayField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -244,7 +197,7 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
{sourceTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
@@ -266,48 +219,46 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
사용자에게 보여줄 필드 (예: 거래처명)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3. 저장 대상 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
||||
<Label className="text-xs font-semibold sm:text-sm">3. 저장 대상 테이블 *</Label>
|
||||
<Popover open={openTargetTableCombo} onOpenChange={setOpenTargetTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openValueFieldCombo}
|
||||
aria-expanded={openTargetTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.valueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
{localConfig.targetTable
|
||||
? allTables.find((t) => t.tableName === localConfig.targetTable)?.displayName || localConfig.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" />
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
setOpenValueFieldCombo(false);
|
||||
updateConfig({ targetTable: table.tableName });
|
||||
setOpenTargetTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.targetTable === table.tableName ? "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>}
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -316,11 +267,124 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
검색 테이블에서 가져올 값의 컬럼 (예: customer_code)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 4. 필드 매핑 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold sm:text-sm">4. 필드 매핑 *</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addFieldMapping}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || !localConfig.targetTable || isLoadingSourceColumns || isLoadingTargetColumns}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(localConfig.fieldMappings || []).length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
매핑 추가 버튼을 눌러 필드 매핑을 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{(localConfig.fieldMappings || []).map((mapping, index) => (
|
||||
<div key={index} className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<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"
|
||||
/>
|
||||
</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 || isLoadingSourceColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="가져올 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { targetField: value })
|
||||
}
|
||||
disabled={!localConfig.targetTable || isLoadingTargetColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="저장할 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mapping.sourceField && mapping.targetField && (
|
||||
<div className="rounded bg-blue-50 p-2 dark:bg-blue-950">
|
||||
<p className="text-[10px] text-blue-700 dark:text-blue-300">
|
||||
<code className="rounded bg-blue-100 px-1 font-mono dark:bg-blue-900">
|
||||
{localConfig.tableName}.{mapping.sourceField}
|
||||
</code>
|
||||
{" → "}
|
||||
<code className="rounded bg-blue-100 px-1 font-mono dark:bg-blue-900">
|
||||
{localConfig.targetTable}.{mapping.targetField}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 플레이스홀더 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
@@ -331,471 +395,28 @@ export function AutocompleteSearchInputConfigPanel({
|
||||
/>
|
||||
</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">
|
||||
값을 저장할 컬럼명
|
||||
{/* 설정 요약 */}
|
||||
{localConfig.tableName && localConfig.targetTable && (localConfig.fieldMappings || []).length > 0 && (
|
||||
<div className="rounded-lg border bg-green-50 p-4 dark:bg-green-950">
|
||||
<h3 className="mb-2 text-sm font-semibold text-green-800 dark:text-green-200">
|
||||
설정 요약
|
||||
</h3>
|
||||
<div className="space-y-1 text-xs text-green-700 dark:text-green-300">
|
||||
<p>
|
||||
<strong>외부 테이블:</strong> {localConfig.tableName}
|
||||
</p>
|
||||
<p>
|
||||
<strong>표시 필드:</strong> {localConfig.displayField}
|
||||
</p>
|
||||
<p>
|
||||
<strong>저장 테이블:</strong> {localConfig.targetTable}
|
||||
</p>
|
||||
<p>
|
||||
<strong>매핑 개수:</strong> {(localConfig.fieldMappings || []).length}개
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,5 +27,7 @@ export interface AutocompleteSearchInputConfig {
|
||||
// 필드 자동 매핑 설정
|
||||
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
|
||||
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
||||
// 저장 대상 테이블 (간소화 버전)
|
||||
targetTable?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,26 @@ import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./
|
||||
import { useCalculation } from "./useCalculation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
|
||||
interface ModalRepeaterTableComponentProps extends Partial<ModalRepeaterTableProps> {
|
||||
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
|
||||
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
|
||||
config?: ModalRepeaterTableProps;
|
||||
// ModalRepeaterTableProps의 개별 prop들도 지원 (호환성)
|
||||
sourceTable?: string;
|
||||
sourceColumns?: string[];
|
||||
sourceSearchFields?: string[];
|
||||
targetTable?: string;
|
||||
modalTitle?: string;
|
||||
modalButtonText?: string;
|
||||
multiSelect?: boolean;
|
||||
columns?: RepeaterColumnConfig[];
|
||||
calculationRules?: any[];
|
||||
value?: any[];
|
||||
onChange?: (newData: any[]) => void;
|
||||
uniqueField?: string;
|
||||
filterCondition?: Record<string, any>;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,10 +139,25 @@ async function fetchReferenceValue(
|
||||
}
|
||||
|
||||
export function ModalRepeaterTableComponent({
|
||||
// ComponentRendererProps (자동 전달)
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
|
||||
// ModalRepeaterTable 전용 props
|
||||
config,
|
||||
sourceTable: propSourceTable,
|
||||
sourceColumns: propSourceColumns,
|
||||
sourceSearchFields: propSourceSearchFields,
|
||||
targetTable: propTargetTable,
|
||||
modalTitle: propModalTitle,
|
||||
modalButtonText: propModalButtonText,
|
||||
multiSelect: propMultiSelect,
|
||||
@@ -136,36 +168,55 @@ export function ModalRepeaterTableComponent({
|
||||
uniqueField: propUniqueField,
|
||||
filterCondition: propFilterCondition,
|
||||
companyCode: propCompanyCode,
|
||||
className,
|
||||
|
||||
...props
|
||||
}: ModalRepeaterTableComponentProps) {
|
||||
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component?.config,
|
||||
};
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const sourceTable = config?.sourceTable || propSourceTable || "";
|
||||
const sourceTable = componentConfig?.sourceTable || propSourceTable || "";
|
||||
const targetTable = componentConfig?.targetTable || propTargetTable;
|
||||
|
||||
// sourceColumns에서 빈 문자열 필터링
|
||||
const rawSourceColumns = config?.sourceColumns || propSourceColumns || [];
|
||||
const sourceColumns = rawSourceColumns.filter((col) => col && col.trim() !== "");
|
||||
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
|
||||
const sourceColumns = rawSourceColumns.filter((col: string) => 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 || (() => {});
|
||||
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
|
||||
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
|
||||
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
|
||||
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
|
||||
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||
|
||||
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
||||
const columnName = component?.columnName;
|
||||
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||
|
||||
// ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리)
|
||||
const handleChange = (newData: any[]) => {
|
||||
// 기존 onChange 콜백 호출 (호환성)
|
||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||
if (externalOnChange) {
|
||||
externalOnChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
|
||||
const rawUniqueField = config?.uniqueField || propUniqueField;
|
||||
const rawUniqueField = componentConfig?.uniqueField || propUniqueField;
|
||||
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
|
||||
? "item_number"
|
||||
: rawUniqueField;
|
||||
|
||||
const filterCondition = config?.filterCondition || propFilterCondition || {};
|
||||
const companyCode = config?.companyCode || propCompanyCode;
|
||||
const filterCondition = componentConfig?.filterCondition || propFilterCondition || {};
|
||||
const companyCode = componentConfig?.companyCode || propCompanyCode;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||
const configuredColumns = config?.columns || propColumns || [];
|
||||
const configuredColumns = componentConfig?.columns || propColumns || [];
|
||||
|
||||
if (configuredColumns.length > 0) {
|
||||
console.log("✅ 설정된 columns 사용:", configuredColumns);
|
||||
@@ -188,7 +239,7 @@ export function ModalRepeaterTableComponent({
|
||||
|
||||
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
|
||||
return [];
|
||||
}, [config?.columns, propColumns, sourceColumns]);
|
||||
}, [componentConfig?.columns, propColumns, sourceColumns]);
|
||||
|
||||
// 초기 props 로깅
|
||||
useEffect(() => {
|
||||
@@ -221,6 +272,59 @@ export function ModalRepeaterTableComponent({
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
|
||||
useEffect(() => {
|
||||
const handleSaveRequest = async (event: Event) => {
|
||||
const componentKey = columnName || component?.id || "modal_repeater_data";
|
||||
|
||||
console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", {
|
||||
componentKey,
|
||||
itemsCount: value.length,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName,
|
||||
componentId: component?.id,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
if (value.length === 0) {
|
||||
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 targetTable 메타데이터를 배열 항목에 추가
|
||||
const dataWithTargetTable = targetTable
|
||||
? value.map(item => ({
|
||||
...item,
|
||||
_targetTable: targetTable, // 백엔드가 인식할 메타데이터
|
||||
}))
|
||||
: value;
|
||||
|
||||
// ✅ CustomEvent의 detail에 데이터 추가
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
event.detail.formData[componentKey] = dataWithTargetTable;
|
||||
console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", {
|
||||
key: componentKey,
|
||||
itemCount: dataWithTargetTable.length,
|
||||
targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)",
|
||||
sampleItem: dataWithTargetTable[0],
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(componentKey, dataWithTargetTable);
|
||||
console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료");
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 버튼 클릭 시 데이터 수집
|
||||
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
};
|
||||
}, [value, columnName, component?.id, onFormDataChange, targetTable]);
|
||||
|
||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||
|
||||
// 초기 데이터에 계산 필드 적용
|
||||
@@ -338,7 +442,8 @@ export function ModalRepeaterTableComponent({
|
||||
const newData = [...value, ...calculatedItems];
|
||||
console.log("✅ 최종 데이터:", newData.length, "개 항목");
|
||||
|
||||
onChange(newData);
|
||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const handleRowChange = (index: number, newRow: any) => {
|
||||
@@ -348,12 +453,16 @@ export function ModalRepeaterTableComponent({
|
||||
// 데이터 업데이트
|
||||
const newData = [...value];
|
||||
newData[index] = calculatedRow;
|
||||
onChange(newData);
|
||||
|
||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const handleRowDelete = (index: number) => {
|
||||
const newData = value.filter((_, i) => i !== index);
|
||||
onChange(newData);
|
||||
|
||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
// 컬럼명 -> 라벨명 매핑 생성
|
||||
@@ -382,7 +491,7 @@ export function ModalRepeaterTableComponent({
|
||||
<RepeaterTable
|
||||
columns={columns}
|
||||
data={value}
|
||||
onDataChange={onChange}
|
||||
onDataChange={handleChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
/>
|
||||
|
||||
@@ -7,40 +7,15 @@ import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||
|
||||
/**
|
||||
* ModalRepeaterTable 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
* ✅ 단순 전달만 수행 (TextInput 패턴 따름)
|
||||
*/
|
||||
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} />;
|
||||
// ✅ props를 그대로 전달 (Component에서 모든 로직 처리)
|
||||
return <ModalRepeaterTableComponent {...this.props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 변경 처리 (레거시 메서드 - 호환성 유지)
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
|
||||
@@ -345,11 +345,11 @@ export class ButtonActionExecutor {
|
||||
// console.log("👤 [buttonActions] 사용자 정보:", {
|
||||
// userId: context.userId,
|
||||
// userName: context.userName,
|
||||
// companyCode: context.companyCode, // ✅ 회사 코드
|
||||
// formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값
|
||||
// formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||
// companyCode: context.companyCode,
|
||||
// formDataWriter: formData.writer,
|
||||
// formDataCompanyCode: formData.company_code,
|
||||
// defaultWriterValue: writerValue,
|
||||
// companyCodeValue, // ✅ 최종 회사 코드 값
|
||||
// companyCodeValue,
|
||||
// });
|
||||
|
||||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||
|
||||
Reference in New Issue
Block a user