diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 0ba9924c..521f5250 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -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 }); diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts index e38f2466..82043964 100644 --- a/backend-node/src/controllers/orderController.ts +++ b/backend-node/src/controllers/orderController.ts @@ -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, diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 92b5ed39..b75034c2 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -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++; } diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 9e06804b..965d2833 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -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)) { diff --git a/backend-node/src/types/order.ts b/backend-node/src/types/order.ts new file mode 100644 index 00000000..30731b86 --- /dev/null +++ b/backend-node/src/types/order.ts @@ -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; // 전체 금액 +} + diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 5560f1cb..2f472b56 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -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); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts index 098d33ee..ebedb9f2 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts +++ b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts @@ -162,3 +162,4 @@ export function getAllDescendants( return descendants; } + diff --git a/frontend/components/order/orderConstants.ts b/frontend/components/order/orderConstants.ts new file mode 100644 index 00000000..f93451f8 --- /dev/null +++ b/frontend/components/order/orderConstants.ts @@ -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]; diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index d1e942ff..4228f389 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -337,12 +337,22 @@ export const SaveModal: React.FC = ({ 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} /> diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index 72f54164..c50755b6 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -131,6 +131,12 @@ export const dashboardApi = { * 대시보드 생성 */ async createDashboard(data: CreateDashboardRequest): Promise { + console.log("🔍 [API createDashboard] 요청 데이터:", { + data, + settings: data.settings, + stringified: JSON.stringify(data), + }); + const result = await apiRequest("/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): Promise { + console.log("🔍 [API updateDashboard] 요청 데이터:", { + id, + data, + settings: data.settings, + stringified: JSON.stringify(data), + }); + const result = await apiRequest(`/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; }, diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index 42baabdc..263cb294 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -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 { +export interface AutocompleteSearchInputProps extends ComponentRendererProps { config?: AutocompleteSearchInputConfig; + tableName?: string; + displayField?: string; + valueField?: string; + searchFields?: string[]; filterCondition?: Record; - 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(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" /> -
+
{loading && ( )} @@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({ {/* 드롭다운 결과 */} {isOpen && (results.length > 0 || loading) && ( -
+
{loading && results.length === 0 ? (
- + 검색 중...
) : 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" >
{item[displayField]}
- {additionalFields.length > 0 && ( -
- {additionalFields.map((field) => ( -
- {field}: {item[field] || "-"} -
- ))} -
- )} ))}
)}
)} - - {/* 추가 정보 표시 */} - {showAdditionalInfo && selectedData && additionalFields.length > 0 && ( -
- {additionalFields.map((field) => ( -
- {field}: - {selectedData[field] || "-"} -
- ))} -
- )}
); } - diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx index 96a212b1..e6942704 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx @@ -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([]); - const [tableColumns, setTableColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [targetTableColumns, setTargetTableColumns] = useState([]); 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([]); - 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) => { + 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 ( -
+
+ {/* 1. 외부 테이블 선택 */}
- - + + @@ -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({ -

- 검색할 데이터가 저장된 테이블을 선택하세요 -

+ {/* 2. 표시 필드 선택 */}
- + @@ -244,7 +197,7 @@ export function AutocompleteSearchInputConfigPanel({ 필드를 찾을 수 없습니다. - {tableColumns.map((column) => ( + {sourceTableColumns.map((column) => ( -

- 사용자에게 보여줄 필드 (예: 거래처명) -

+ {/* 3. 저장 대상 테이블 선택 */}
- - + + - + - 필드를 찾을 수 없습니다. + 테이블을 찾을 수 없습니다. - {tableColumns.map((column) => ( + {allTables.map((table) => ( { - updateConfig({ valueField: column.columnName }); - setOpenValueFieldCombo(false); + updateConfig({ targetTable: table.tableName }); + setOpenTargetTableCombo(false); }} className="text-xs sm:text-sm" > - +
- {column.displayName || column.columnName} - {column.displayName && {column.columnName}} + {table.displayName || table.tableName} + {table.displayName && {table.tableName}}
))} @@ -316,11 +267,124 @@ export function AutocompleteSearchInputConfigPanel({
-

- 검색 테이블에서 가져올 값의 컬럼 (예: customer_code) -

+ {/* 4. 필드 매핑 */} +
+
+ + +
+ + {(localConfig.fieldMappings || []).length === 0 && ( +
+

+ 매핑 추가 버튼을 눌러 필드 매핑을 설정하세요 +

+
+ )} + +
+ {(localConfig.fieldMappings || []).map((mapping, index) => ( +
+
+ + 매핑 #{index + 1} + + +
+ +
+ + + updateFieldMapping(index, { label: e.target.value }) + } + placeholder="예: 거래처 코드" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + +
+ +
+ + +
+ + {mapping.sourceField && mapping.targetField && ( +
+

+ + {localConfig.tableName}.{mapping.sourceField} + + {" → "} + + {localConfig.targetTable}.{mapping.targetField} + +

+
+ )} +
+ ))} +
+
+ + {/* 플레이스홀더 */}
- {/* 값 필드 저장 위치 설정 */} -
-
-

값 필드 저장 위치 (고급)

-

- 위에서 선택한 "값 필드"의 데이터를 어느 테이블/컬럼에 저장할지 지정합니다. -
- 미설정 시 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 자동 저장됩니다. -

-
- - {/* 저장 테이블 선택 */} -
- - - - - - - - - - 테이블을 찾을 수 없습니다. - - {/* 기본값 옵션 */} - { - updateConfig({ - valueFieldStorage: { - ...localConfig.valueFieldStorage, - targetTable: undefined, - targetColumn: undefined, - }, - }); - setOpenStorageTableCombo(false); - }} - className="text-xs sm:text-sm" - > - -
- 기본값 - 화면의 연결 테이블 사용 -
-
- {allTables.map((table) => ( - { - updateConfig({ - valueFieldStorage: { - ...localConfig.valueFieldStorage, - targetTable: table.tableName, - targetColumn: undefined, // 테이블 변경 시 컬럼 초기화 - }, - }); - setOpenStorageTableCombo(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.displayName || table.tableName} - {table.displayName && {table.tableName}} -
-
- ))} -
-
-
-
-
-

- 값을 저장할 테이블 (기본값: 화면 연결 테이블) -

-
- - {/* 저장 컬럼 선택 */} - {localConfig.valueFieldStorage?.targetTable && ( -
- - - - - - - - - - 컬럼을 찾을 수 없습니다. - - {storageTableColumns.map((column) => ( - { - updateConfig({ - valueFieldStorage: { - ...localConfig.valueFieldStorage, - targetColumn: column.columnName, - }, - }); - setOpenStorageColumnCombo(false); - }} - className="text-xs sm:text-sm" - > - -
- {column.displayName || column.columnName} - {column.displayName && {column.columnName}} -
-
- ))} -
-
-
-
-
-

- 값을 저장할 컬럼명 + {/* 설정 요약 */} + {localConfig.tableName && localConfig.targetTable && (localConfig.fieldMappings || []).length > 0 && ( +

+

+ 설정 요약 +

+
+

+ 외부 테이블: {localConfig.tableName} +

+

+ 표시 필드: {localConfig.displayField} +

+

+ 저장 테이블: {localConfig.targetTable} +

+

+ 매핑 개수: {(localConfig.fieldMappings || []).length}개

-
- )} - - {/* 설명 박스 */} -
-

- 저장 위치 동작 -

-
- {localConfig.valueFieldStorage?.targetTable ? ( - <> -

- 선택한 값({localConfig.valueField})을 -

-

- - {localConfig.valueFieldStorage.targetTable} - {" "} - 테이블의{" "} - - {localConfig.valueFieldStorage.targetColumn || "(컬럼 미지정)"} - {" "} - 컬럼에 저장합니다. -

- - ) : ( -

기본값: 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 저장됩니다.

- )} -
-
-
- -
-
- - -
-
- {(localConfig.searchFields || []).map((field, index) => ( -
- - -
- ))} -
-
- -
-
- - - updateConfig({ showAdditionalInfo: checked }) - } - /> -
-
- - {localConfig.showAdditionalInfo && ( -
-
- - -
-
- {(localConfig.additionalFields || []).map((field, index) => ( -
- - -
- ))}
)} - - {/* 필드 자동 매핑 설정 */} -
-
-

필드 자동 매핑

-

- 선택한 항목의 필드를 화면의 다른 입력 필드에 자동으로 채워넣습니다 -

-
- -
-
- - - updateConfig({ enableFieldMapping: checked }) - } - /> -
-

- 활성화하면 항목 선택 시 설정된 필드들이 자동으로 채워집니다 -

-
- - {localConfig.enableFieldMapping && ( -
-
- - -
- -
- {(localConfig.fieldMappings || []).map((mapping, index) => ( -
-
- - 매핑 #{index + 1} - - -
- - {/* 표시명 */} -
- - - updateFieldMapping(index, { label: e.target.value }) - } - placeholder="예: 거래처명" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 이 매핑의 설명 (선택사항) -

-
- - {/* 소스 필드 (테이블의 컬럼) */} -
- - -

- 가져올 데이터의 컬럼명 -

-
- - {/* 타겟 필드 (화면의 input ID) */} -
- - - updateFieldMapping(index, { targetField: e.target.value }) - } - placeholder="예: customer_name_input" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 값을 채울 화면 컴포넌트의 ID (예: input의 id 속성) -

-
- - {/* 예시 설명 */} -
-

- {mapping.sourceField && mapping.targetField ? ( - <> - {mapping.label || "이 필드"}: 테이블의{" "} - - {mapping.sourceField} - {" "} - 값을 화면의{" "} - - {mapping.targetField} - {" "} - 컴포넌트에 자동으로 채웁니다 - - ) : ( - "소스 필드와 타겟 필드를 모두 선택하세요" - )} -

-
-
- ))} -
- - {/* 사용 안내 */} - {localConfig.fieldMappings && localConfig.fieldMappings.length > 0 && ( -
-

- 사용 방법 -

-
    -
  • 화면에서 이 검색 컴포넌트로 항목을 선택하면
  • -
  • 설정된 매핑에 따라 다른 입력 필드들이 자동으로 채워집니다
  • -
  • 타겟 필드 ID는 화면 디자이너에서 설정한 컴포넌트 ID와 일치해야 합니다
  • -
-
- )} -
- )} -
); } - diff --git a/frontend/lib/registry/components/autocomplete-search-input/types.ts b/frontend/lib/registry/components/autocomplete-search-input/types.ts index 802f27c7..85101e89 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/types.ts +++ b/frontend/lib/registry/components/autocomplete-search-input/types.ts @@ -27,5 +27,7 @@ export interface AutocompleteSearchInputConfig { // 필드 자동 매핑 설정 enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부 fieldMappings?: FieldMapping[]; // 매핑할 필드 목록 + // 저장 대상 테이블 (간소화 버전) + targetTable?: string; } diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 2003c5ef..d903cc9f 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -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 { +// ✅ 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; + 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({ diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx index 6362e1ce..6f61c052 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx @@ -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 ; + // ✅ props를 그대로 전달 (Component에서 모든 로직 처리) + return ; } - - /** - * 값 변경 처리 (레거시 메서드 - 호환성 유지) - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected handleValueChange = (value: any) => { - this.updateComponent({ value }); - }; } // 자동 등록 실행 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index d6ddd96c..5f825cdc 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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, // }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)