diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 9b8ef6fc..738d1964 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -419,3 +419,66 @@ export const getTableColumns = async ( }); } }; + +// 특정 필드만 업데이트 (다른 테이블 지원) +export const updateFieldValue = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tableName, keyField, keyValue, updateField, updateValue } = req.body; + + console.log("🔄 [updateFieldValue] 요청:", { + tableName, + keyField, + keyValue, + updateField, + updateValue, + userId, + companyCode, + }); + + // 필수 필드 검증 + if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", + }); + } + + // SQL 인젝션 방지를 위한 테이블명/컬럼명 검증 + const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명 또는 컬럼명입니다.", + }); + } + + // 업데이트 쿼리 실행 + const result = await dynamicFormService.updateFieldValue( + tableName, + keyField, + keyValue, + updateField, + updateValue, + companyCode, + userId + ); + + console.log("✅ [updateFieldValue] 성공:", result); + + res.json({ + success: true, + data: result, + message: "필드 값이 업데이트되었습니다.", + }); + } catch (error: any) { + console.error("❌ [updateFieldValue] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "필드 업데이트에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 5514fb54..21140617 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -5,6 +5,7 @@ import { saveFormDataEnhanced, updateFormData, updateFormDataPartial, + updateFieldValue, deleteFormData, getFormData, getFormDataList, @@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원) router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 router.put("/:id", updateFormData); router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 +router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) router.delete("/:id", deleteFormData); router.get("/:id", getFormData); diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index b75034c2..4b13d6b8 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -299,6 +299,8 @@ export class DashboardService { /** * 대시보드 상세 조회 + * - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능 + * - company_code가 '*'인 경우 최고 관리자만 조회 가능 */ static async getDashboardById( dashboardId: string, @@ -310,44 +312,43 @@ export class DashboardService { let dashboardQuery: string; let dashboardParams: any[]; - if (userId) { - if (companyCode) { + if (companyCode) { + // 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능 + // 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능 + if (companyCode === '*') { dashboardQuery = ` SELECT d.* FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.company_code = $2 - AND (d.created_by = $3 OR d.is_public = true) - `; - dashboardParams = [dashboardId, companyCode, userId]; - } else { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND (d.created_by = $2 OR d.is_public = true) - `; - dashboardParams = [dashboardId, userId]; - } - } else { - if (companyCode) { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.company_code = $2 - AND d.is_public = true - `; - dashboardParams = [dashboardId, companyCode]; - } else { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.is_public = true `; dashboardParams = [dashboardId]; + } else { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.company_code = $2 + `; + dashboardParams = [dashboardId, companyCode]; } + } else if (userId) { + // 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개) + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND (d.created_by = $2 OR d.is_public = true) + `; + dashboardParams = [dashboardId, userId]; + } else { + // 비로그인 사용자는 공개 대시보드만 + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.is_public = true + `; + dashboardParams = [dashboardId]; } const dashboardResult = await PostgreSQLService.query( diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index c40037bb..11648577 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,4 +1,4 @@ -import { query, queryOne, transaction } from "../database/db"; +import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; @@ -1635,6 +1635,69 @@ export class DynamicFormService { // 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해 } } + + /** + * 특정 테이블의 특정 필드 값만 업데이트 + * (다른 테이블의 레코드 업데이트 지원) + */ + async updateFieldValue( + tableName: string, + keyField: string, + keyValue: any, + updateField: string, + updateValue: any, + companyCode: string, + userId: string + ): Promise<{ affectedRows: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("🔄 [updateFieldValue] 업데이트 실행:", { + tableName, + keyField, + keyValue, + updateField, + updateValue, + companyCode, + }); + + // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외) + let whereClause = `"${keyField}" = $1`; + const params: any[] = [keyValue, updateValue, userId]; + let paramIndex = 4; + + if (companyCode && companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + const sqlQuery = ` + UPDATE "${tableName}" + SET "${updateField}" = $2, + updated_by = $3, + updated_at = NOW() + WHERE ${whereClause} + `; + + console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery); + console.log("🔍 [updateFieldValue] 파라미터:", params); + + const result = await client.query(sqlQuery, params); + + console.log("✅ [updateFieldValue] 결과:", { + affectedRows: result.rowCount, + }); + + return { affectedRows: result.rowCount || 0 }; + } catch (error) { + console.error("❌ [updateFieldValue] 오류:", error); + throw error; + } finally { + client.release(); + } + } } // 싱글톤 인스턴스 생성 및 export diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index c18e89f9..90d6c18d 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -995,6 +995,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // console.log("🔧 기본 해상도 적용:", defaultResolution); } + // 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인 + const buttonComponents = layoutWithDefaultGrid.components.filter( + (c: any) => c.componentType?.startsWith("button") + ); + console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({ + id: c.id, + type: c.componentType, + actionType: c.componentConfig?.action?.type, + fullAction: c.componentConfig?.action, + }))); + setLayout(layoutWithDefaultGrid); setHistory([layoutWithDefaultGrid]); setHistoryIndex(0); @@ -1452,7 +1463,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; // 🔍 버튼 컴포넌트들의 action.type 확인 const buttonComponents = layoutWithResolution.components.filter( - (c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary", + (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", ); console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, @@ -1462,6 +1473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD buttonComponents: buttonComponents.map((c: any) => ({ id: c.id, type: c.type, + componentType: c.componentType, text: c.componentConfig?.text, actionType: c.componentConfig?.action?.type, fullAction: c.componentConfig?.action, diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 567987e4..1a4a9608 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -503,6 +503,8 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 업로드 바코드 스캔 코드 병합 + 위치정보 가져오기 + 필드 값 변경 @@ -1662,6 +1664,255 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 위치정보 가져오기 설정 */} + {(component.componentConfig?.action?.type || "save") === "geolocation" && ( +
+

📍 위치정보 설정

+ + {/* 테이블 선택 */} +
+ + +

+ 위치 정보를 저장할 테이블 (기본: 현재 화면 테이블) +

+
+ +
+
+ + onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ +
+
+ + onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ +
+
+ +

GPS를 사용하여 더 정확한 위치 (배터리 소모 증가)

+
+ onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)} + /> +
+ +
+
+ +

위치 정보를 가져온 후 자동으로 폼을 저장합니다

+
+ onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} + /> +
+ +
+

+ 사용 방법: +
+ 1. 버튼을 클릭하면 브라우저가 위치 권한을 요청합니다 +
+ 2. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다 +
+ 3. 위도/경도가 지정된 필드에 자동으로 입력됩니다 +
+
+ 참고: HTTPS 환경에서만 위치정보가 작동합니다. +

+
+
+ )} + + {/* 필드 값 변경 설정 */} + {(component.componentConfig?.action?.type || "save") === "update_field" && ( +
+

📝 필드 값 변경 설정

+ +
+ + +

+ 필드 값을 변경할 테이블 (기본: 현재 화면 테이블) +

+
+ +
+
+ + onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)} + className="h-8 text-xs" + /> +

변경할 DB 컬럼

+
+
+ + onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)} + className="h-8 text-xs" + /> +

변경할 값 (문자열, 숫자)

+
+
+ +
+
+ +

버튼 클릭 시 즉시 DB에 저장

+
+ onUpdateProperty("componentConfig.action.updateAutoSave", checked)} + /> +
+ +
+ + onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)} + className="h-8 text-xs" + /> +

입력하면 변경 전 확인 창이 표시됩니다

+
+ +
+
+ + onUpdateProperty("componentConfig.action.successMessage", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.errorMessage", e.target.value)} + className="h-8 text-xs" + /> +
+
+ +
+

+ 사용 예시: +
+ - 운행알림 버튼: status 필드를 "active"로 변경 +
+ - 승인 버튼: approval_status 필드를 "approved"로 변경 +
+ - 완료 버튼: is_completed 필드를 "Y"로 변경 +

+
+
+ )} + {/* 데이터 전달 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "transferData" && (
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index b42ec9b8..1e00442f 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -157,7 +157,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } | null>(null); // 토스트 정리를 위한 ref - const currentLoadingToastRef = useRef(); + const currentLoadingToastRef = useRef(undefined); // 컴포넌트 언마운트 시 토스트 정리 useEffect(() => { @@ -201,9 +201,11 @@ export const ButtonPrimaryComponent: React.FC = ({ }, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]); // 컴포넌트 설정 + // 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정) const componentConfig = { ...config, ...component.config, + ...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함 } as ButtonPrimaryConfig; // 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동) @@ -238,13 +240,12 @@ export const ButtonPrimaryComponent: React.FC = ({ // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", ...component.style, ...style, - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", + height: "100%", }; // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 875b1aa4..fb7cd30b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -64,6 +64,12 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 // 🆕 탭 컴포넌트 import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트 +// 🆕 반복 화면 모달 컴포넌트 +import "./repeat-screen-modal/RepeatScreenModalRenderer"; + +// 🆕 출발지/도착지 선택 컴포넌트 +import "./location-swap-selector/LocationSwapSelectorRenderer"; + // 🆕 화면 임베딩 및 분할 패널 컴포넌트 import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달) diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx new file mode 100644 index 00000000..045d62bd --- /dev/null +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -0,0 +1,432 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { ArrowLeftRight, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +interface LocationOption { + value: string; + label: string; +} + +interface DataSourceConfig { + type: "table" | "code" | "static"; + tableName?: string; + valueField?: string; + labelField?: string; + codeCategory?: string; + staticOptions?: LocationOption[]; +} + +export interface LocationSwapSelectorProps { + // 기본 props + id?: string; + style?: React.CSSProperties; + isDesignMode?: boolean; + + // 데이터 소스 설정 + dataSource?: DataSourceConfig; + + // 필드 매핑 + departureField?: string; + destinationField?: string; + departureLabelField?: string; + destinationLabelField?: string; + + // UI 설정 + departureLabel?: string; + destinationLabel?: string; + showSwapButton?: boolean; + swapButtonPosition?: "center" | "right"; + variant?: "card" | "inline" | "minimal"; + + // 폼 데이터 + formData?: Record; + onFormDataChange?: (field: string, value: any) => void; + + // componentConfig (화면 디자이너에서 전달) + componentConfig?: { + dataSource?: DataSourceConfig; + departureField?: string; + destinationField?: string; + departureLabelField?: string; + destinationLabelField?: string; + departureLabel?: string; + destinationLabel?: string; + showSwapButton?: boolean; + swapButtonPosition?: "center" | "right"; + variant?: "card" | "inline" | "minimal"; + }; +} + +/** + * LocationSwapSelector 컴포넌트 + * 출발지/도착지 선택 및 교환 기능 + */ +export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) { + const { + id, + style, + isDesignMode = false, + formData = {}, + onFormDataChange, + componentConfig, + } = props; + + // componentConfig에서 설정 가져오기 (우선순위: componentConfig > props) + const config = componentConfig || {}; + const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] }; + const departureField = config.departureField || props.departureField || "departure"; + const destinationField = config.destinationField || props.destinationField || "destination"; + const departureLabelField = config.departureLabelField || props.departureLabelField; + const destinationLabelField = config.destinationLabelField || props.destinationLabelField; + const departureLabel = config.departureLabel || props.departureLabel || "출발지"; + const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지"; + const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false; + const variant = config.variant || props.variant || "card"; + + // 상태 + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [isSwapping, setIsSwapping] = useState(false); + + // 현재 선택된 값 + const departureValue = formData[departureField] || ""; + const destinationValue = formData[destinationField] || ""; + + // 옵션 로드 + useEffect(() => { + const loadOptions = async () => { + if (dataSource.type === "static") { + setOptions(dataSource.staticOptions || []); + return; + } + + if (dataSource.type === "code" && dataSource.codeCategory) { + // 코드 관리에서 가져오기 + setLoading(true); + try { + const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`); + if (response.data.success && response.data.data) { + const codeOptions = response.data.data.map((code: any) => ({ + value: code.code_value || code.codeValue, + label: code.code_name || code.codeName, + })); + setOptions(codeOptions); + } + } catch (error) { + console.error("코드 로드 실패:", error); + } finally { + setLoading(false); + } + return; + } + + if (dataSource.type === "table" && dataSource.tableName) { + // 테이블에서 가져오기 + setLoading(true); + try { + const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, { + params: { pageSize: 1000 }, + }); + if (response.data.success && response.data.data) { + const tableOptions = response.data.data.map((row: any) => ({ + value: row[dataSource.valueField || "id"], + label: row[dataSource.labelField || "name"], + })); + setOptions(tableOptions); + } + } catch (error) { + console.error("테이블 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + } + }; + + if (!isDesignMode) { + loadOptions(); + } else { + // 디자인 모드에서는 샘플 데이터 + setOptions([ + { value: "seoul", label: "서울" }, + { value: "busan", label: "부산" }, + { value: "pohang", label: "포항" }, + { value: "gwangyang", label: "광양" }, + ]); + } + }, [dataSource, isDesignMode]); + + // 출발지 변경 + const handleDepartureChange = (value: string) => { + if (onFormDataChange) { + onFormDataChange(departureField, value); + // 라벨 필드도 업데이트 + if (departureLabelField) { + const selectedOption = options.find((opt) => opt.value === value); + onFormDataChange(departureLabelField, selectedOption?.label || ""); + } + } + }; + + // 도착지 변경 + const handleDestinationChange = (value: string) => { + if (onFormDataChange) { + onFormDataChange(destinationField, value); + // 라벨 필드도 업데이트 + if (destinationLabelField) { + const selectedOption = options.find((opt) => opt.value === value); + onFormDataChange(destinationLabelField, selectedOption?.label || ""); + } + } + }; + + // 출발지/도착지 교환 + const handleSwap = () => { + if (!onFormDataChange) return; + + setIsSwapping(true); + + // 값 교환 + const tempDeparture = departureValue; + const tempDestination = destinationValue; + + onFormDataChange(departureField, tempDestination); + onFormDataChange(destinationField, tempDeparture); + + // 라벨도 교환 + if (departureLabelField && destinationLabelField) { + const tempDepartureLabel = formData[departureLabelField]; + const tempDestinationLabel = formData[destinationLabelField]; + onFormDataChange(departureLabelField, tempDestinationLabel); + onFormDataChange(destinationLabelField, tempDepartureLabel); + } + + // 애니메이션 효과 + setTimeout(() => setIsSwapping(false), 300); + }; + + // 선택된 라벨 가져오기 + const getDepartureLabel = () => { + const option = options.find((opt) => opt.value === departureValue); + return option?.label || "선택"; + }; + + const getDestinationLabel = () => { + const option = options.find((opt) => opt.value === destinationValue); + return option?.label || "선택"; + }; + + // 스타일에서 width, height 추출 + const { width, height, ...restStyle } = style || {}; + + // Card 스타일 (이미지 참고) + if (variant === "card") { + return ( +
+
+ {/* 출발지 */} +
+ {departureLabel} + +
+ + {/* 교환 버튼 */} + {showSwapButton && ( + + )} + + {/* 도착지 */} +
+ {destinationLabel} + +
+
+
+ ); + } + + // Inline 스타일 + if (variant === "inline") { + return ( +
+
+ + +
+ + {showSwapButton && ( + + )} + +
+ + +
+
+ ); + } + + // Minimal 스타일 + return ( +
+ + + {showSwapButton && ( + + )} + + +
+ ); +} + diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx new file mode 100644 index 00000000..c18f6514 --- /dev/null +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx @@ -0,0 +1,415 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { apiClient } from "@/lib/api/client"; + +interface LocationSwapSelectorConfigPanelProps { + config: any; + onChange: (config: any) => void; + tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; + screenTableName?: string; +} + +/** + * LocationSwapSelector 설정 패널 + */ +export function LocationSwapSelectorConfigPanel({ + config, + onChange, + tableColumns = [], + screenTableName, +}: LocationSwapSelectorConfigPanelProps) { + const [tables, setTables] = useState>([]); + const [columns, setColumns] = useState>([]); + const [codeCategories, setCodeCategories] = useState>([]); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + try { + const response = await apiClient.get("/table-management/tables"); + if (response.data.success && response.data.data) { + setTables( + response.data.data.map((t: any) => ({ + name: t.tableName || t.table_name, + label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }; + loadTables(); + }, []); + + // 선택된 테이블의 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + const tableName = config?.dataSource?.tableName; + if (!tableName) { + setColumns([]); + return; + } + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + // API 응답 구조 처리: data가 배열이거나 data.columns가 배열일 수 있음 + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) { + columnData = columnData.columns; + } + + if (Array.isArray(columnData)) { + setColumns( + columnData.map((c: any) => ({ + name: c.columnName || c.column_name || c.name, + label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name, + })) + ); + } + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + } + }; + + if (config?.dataSource?.type === "table") { + loadColumns(); + } + }, [config?.dataSource?.tableName, config?.dataSource?.type]); + + // 코드 카테고리 로드 + useEffect(() => { + const loadCodeCategories = async () => { + try { + const response = await apiClient.get("/code-management/categories"); + if (response.data.success && response.data.data) { + setCodeCategories( + response.data.data.map((c: any) => ({ + value: c.category_code || c.categoryCode || c.code, + label: c.category_name || c.categoryName || c.name, + })) + ); + } + } catch (error) { + console.error("코드 카테고리 로드 실패:", error); + } + }; + loadCodeCategories(); + }, []); + + const handleChange = (path: string, value: any) => { + const keys = path.split("."); + const newConfig = { ...config }; + let current: any = newConfig; + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } + + current[keys[keys.length - 1]] = value; + onChange(newConfig); + }; + + return ( +
+ {/* 데이터 소스 타입 */} +
+ + +
+ + {/* 테이블 선택 (type이 table일 때) */} + {config?.dataSource?.type === "table" && ( + <> +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + )} + + {/* 코드 카테고리 선택 (type이 code일 때) */} + {config?.dataSource?.type === "code" && ( +
+ + +
+ )} + + {/* 필드 매핑 */} +
+

필드 매핑 (저장 위치)

+ {screenTableName && ( +

+ 현재 화면 테이블: {screenTableName} +

+ )} +
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("departureField", e.target.value)} + placeholder="departure" + /> + )} +
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("destinationField", e.target.value)} + placeholder="destination" + /> + )} +
+
+
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("departureLabelField", e.target.value)} + placeholder="departure_name" + /> + )} +
+
+ + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("destinationLabelField", e.target.value)} + placeholder="destination_name" + /> + )} +
+
+
+ + {/* UI 설정 */} +
+

UI 설정

+
+
+ + handleChange("departureLabel", e.target.value)} + /> +
+
+ + handleChange("destinationLabel", e.target.value)} + /> +
+
+ +
+ + +
+ +
+ + handleChange("showSwapButton", checked)} + /> +
+
+ + {/* 안내 */} +
+

+ 사용 방법: +
+ 1. 데이터 소스에서 장소 목록을 가져올 위치를 선택합니다 +
+ 2. 출발지/도착지 값이 저장될 필드를 지정합니다 +
+ 3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다 +

+
+
+ ); +} + diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx new file mode 100644 index 00000000..8e3fe5f7 --- /dev/null +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { LocationSwapSelectorDefinition } from "./index"; +import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent"; + +/** + * LocationSwapSelector 렌더러 + */ +export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = LocationSwapSelectorDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +LocationSwapSelectorRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + LocationSwapSelectorRenderer.enableHotReload(); +} + diff --git a/frontend/lib/registry/components/location-swap-selector/index.ts b/frontend/lib/registry/components/location-swap-selector/index.ts new file mode 100644 index 00000000..60b62008 --- /dev/null +++ b/frontend/lib/registry/components/location-swap-selector/index.ts @@ -0,0 +1,54 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent"; +import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel"; + +/** + * LocationSwapSelector 컴포넌트 정의 + * 출발지/도착지 선택 및 교환 기능을 제공하는 컴포넌트 + */ +export const LocationSwapSelectorDefinition = createComponentDefinition({ + id: "location-swap-selector", + name: "출발지/도착지 선택", + nameEng: "Location Swap Selector", + description: "출발지와 도착지를 선택하고 교환할 수 있는 컴포넌트 (모바일 최적화)", + category: ComponentCategory.INPUT, + webType: "form", + component: LocationSwapSelectorComponent, + defaultConfig: { + // 데이터 소스 설정 + dataSource: { + type: "table", // "table" | "code" | "static" + tableName: "", // 장소 테이블명 + valueField: "location_code", // 값 필드 + labelField: "location_name", // 표시 필드 + codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) + staticOptions: [], // 정적 옵션 (type이 "static"일 때) + }, + // 필드 매핑 + departureField: "departure", // 출발지 저장 필드 + destinationField: "destination", // 도착지 저장 필드 + departureLabelField: "departure_name", // 출발지명 저장 필드 (선택) + destinationLabelField: "destination_name", // 도착지명 저장 필드 (선택) + // UI 설정 + departureLabel: "출발지", + destinationLabel: "도착지", + showSwapButton: true, + swapButtonPosition: "center", // "center" | "right" + // 스타일 + variant: "card", // "card" | "inline" | "minimal" + }, + defaultSize: { width: 400, height: 100 }, + configPanel: LocationSwapSelectorConfigPanel, + icon: "ArrowLeftRight", + tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"], + version: "1.0.0", + author: "개발팀", +}); + +// 컴포넌트 내보내기 +export { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent"; +export { LocationSwapSelectorRenderer } from "./LocationSwapSelectorRenderer"; + diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 093c76e9..f7f1fc20 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -24,6 +24,9 @@ export type ButtonActionType = | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge" // 코드 병합 + | "geolocation" // 위치정보 가져오기 + | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) + | "update_field" // 특정 필드 값 변경 (예: status를 active로) | "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간) /** @@ -91,6 +94,34 @@ export interface ButtonActionConfig { mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code") mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true) + // 위치정보 관련 + geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블) + geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude") + geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude") + geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy") + geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time") + geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true) + geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000) + geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0) + geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false) + geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부 + geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능) + geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status") + geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active") + geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") + geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") + + // 필드 값 교환 관련 (출발지 ↔ 목적지) + swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure") + swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination") + swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도) + + // 필드 값 변경 관련 (특정 필드를 특정 값으로 변경) + updateTargetField?: string; // 변경할 필드명 (예: "status") + updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active") + updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true) + updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경 + // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) editMode?: "modal" | "navigate" | "inline"; // 편집 모드 editModalTitle?: string; // 편집 모달 제목 @@ -100,37 +131,37 @@ export interface ButtonActionConfig { // 데이터 전달 관련 (transferData 액션용) dataTransfer?: { // 소스 설정 - sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등) - sourceComponentType?: string; // 소스 컴포넌트 타입 - + sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등) + sourceComponentType?: string; // 소스 컴포넌트 타입 + // 타겟 설정 - targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면) - + targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면) + // 타겟이 컴포넌트인 경우 - targetComponentId?: string; // 타겟 컴포넌트 ID - + targetComponentId?: string; // 타겟 컴포넌트 ID + // 타겟이 화면인 경우 - targetScreenId?: number; // 타겟 화면 ID - + targetScreenId?: number; // 타겟 화면 ID + // 데이터 매핑 규칙 mappingRules: Array<{ - sourceField: string; // 소스 필드명 - targetField: string; // 타겟 필드명 + sourceField: string; // 소스 필드명 + targetField: string; // 타겟 필드명 transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수 - defaultValue?: any; // 기본값 + defaultValue?: any; // 기본값 }>; - + // 전달 옵션 mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append) - clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화 - confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지 - confirmMessage?: string; // 확인 메시지 내용 - + clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화 + confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지 + confirmMessage?: string; // 확인 메시지 내용 + // 검증 validation?: { - requireSelection?: boolean; // 선택 필수 (기본: true) - minSelection?: number; // 최소 선택 개수 - maxSelection?: number; // 최대 선택 개수 + requireSelection?: boolean; // 선택 필수 (기본: true) + minSelection?: number; // 최소 선택 개수 + maxSelection?: number; // 최대 선택 개수 }; }; } @@ -159,7 +190,7 @@ export interface ButtonActionContext { // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; - + // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용) allComponents?: any[]; @@ -240,6 +271,12 @@ export class ButtonActionExecutor { case "transferData": return await this.handleTransferData(config, context); + case "geolocation": + return await this.handleGeolocation(config, context); + + case "update_field": + return await this.handleUpdateField(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -258,7 +295,7 @@ export class ButtonActionExecutor { const { formData, originalData, tableName, screenId, onSave } = context; console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave }); - + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 if (onSave) { console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행"); @@ -270,20 +307,22 @@ export class ButtonActionExecutor { throw error; } } - + console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 - window.dispatchEvent(new CustomEvent("beforeFormSave", { - detail: { - formData: context.formData - } - })); - + window.dispatchEvent( + new CustomEvent("beforeFormSave", { + detail: { + formData: context.formData, + }, + }), + ); + // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 - await new Promise(resolve => setTimeout(resolve, 100)); - + await new Promise((resolve) => setTimeout(resolve, 100)); + console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) @@ -294,33 +333,41 @@ export class ButtonActionExecutor { key, isArray: Array.isArray(value), length: Array.isArray(value) ? value.length : 0, - firstItem: Array.isArray(value) && value.length > 0 ? { - hasOriginalData: !!value[0]?.originalData, - hasFieldGroups: !!value[0]?.fieldGroups, - keys: Object.keys(value[0] || {}) - } : null - })) + firstItem: + Array.isArray(value) && value.length > 0 + ? { + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + keys: Object.keys(value[0] || {}), + } + : null, + })), }); // 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정) if (Array.isArray(context.formData)) { - console.log("⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀"); + console.log( + "⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀", + ); console.log("⚠️ [handleSave] formData 배열:", context.formData); // ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀 return true; // 성공으로 반환 } - const selectedItemsKeys = Object.keys(context.formData).filter(key => { + const selectedItemsKeys = Object.keys(context.formData).filter((key) => { const value = context.formData[key]; console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, { isArray: Array.isArray(value), length: Array.isArray(value) ? value.length : 0, - firstItem: Array.isArray(value) && value.length > 0 ? { - keys: Object.keys(value[0] || {}), - hasOriginalData: !!value[0]?.originalData, - hasFieldGroups: !!value[0]?.fieldGroups, - actualValue: value[0], - } : null + firstItem: + Array.isArray(value) && value.length > 0 + ? { + keys: Object.keys(value[0] || {}), + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + actualValue: value[0], + } + : null, }); return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; }); @@ -418,9 +465,9 @@ export class ButtonActionExecutor { // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) // console.log("🔍 채번 규칙 할당 체크 시작"); // console.log("📦 현재 formData:", JSON.stringify(formData, null, 2)); - + const fieldsWithNumbering: Record = {}; - + // formData에서 채번 규칙이 설정된 필드 찾기 for (const [key, value] of Object.entries(formData)) { if (key.endsWith("_numberingRuleId") && value) { @@ -440,7 +487,7 @@ export class ButtonActionExecutor { console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering)); console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)"); } - + // console.log("✅ 채번 규칙 할당 완료"); // console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); @@ -462,7 +509,7 @@ export class ButtonActionExecutor { // 🆕 반복 필드 그룹에서 삭제된 항목 처리 // formData의 각 필드에서 _deletedItemIds가 있는지 확인 console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo)); - + for (const [key, value] of Object.entries(dataWithUserInfo)) { console.log(`🔍 [handleSave] 필드 검사: ${key}`, { type: typeof value, @@ -470,9 +517,9 @@ export class ButtonActionExecutor { isString: typeof value === "string", valuePreview: typeof value === "string" ? value.substring(0, 100) : value, }); - + let parsedValue = value; - + // JSON 문자열인 경우 파싱 시도 if (typeof value === "string" && value.startsWith("[")) { try { @@ -482,25 +529,25 @@ export class ButtonActionExecutor { // 파싱 실패하면 원본 값 유지 } } - + if (Array.isArray(parsedValue) && parsedValue.length > 0) { const firstItem = parsedValue[0]; const deletedItemIds = firstItem?._deletedItemIds; const targetTable = firstItem?._targetTable; - + console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, { firstItemKeys: firstItem ? Object.keys(firstItem) : [], deletedItemIds, targetTable, }); - + if (deletedItemIds && deletedItemIds.length > 0 && targetTable) { console.log("🗑️ [handleSave] 삭제할 항목 발견:", { fieldKey: key, targetTable, deletedItemIds, }); - + // 삭제 API 호출 for (const itemId of deletedItemIds) { try { @@ -518,7 +565,7 @@ export class ButtonActionExecutor { } } } - + saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, @@ -631,12 +678,12 @@ export class ButtonActionExecutor { * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장 */ private static async handleBatchSave( - config: ButtonActionConfig, + config: ButtonActionConfig, context: ButtonActionContext, - selectedItemsKeys: string[] + selectedItemsKeys: string[], ): Promise { const { formData, tableName, screenId, selectedRowsData, originalData } = context; - + console.log(`🔍 [handleBatchSave] context 확인:`, { hasSelectedRowsData: !!selectedRowsData, selectedRowsCount: selectedRowsData?.length || 0, @@ -657,39 +704,38 @@ export class ButtonActionExecutor { // 🆕 부모 화면 데이터 준비 (parentDataMapping용) // selectedRowsData 또는 originalData를 parentData로 사용 const parentData = selectedRowsData?.[0] || originalData || {}; - + // 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기 // (여러 단계 모달에서 전달된 데이터 접근용) let modalDataStoreRegistry: Record = {}; - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { try { // Zustand store에서 데이터 가져오기 - const { useModalDataStore } = await import('@/stores/modalDataStore'); + const { useModalDataStore } = await import("@/stores/modalDataStore"); modalDataStoreRegistry = useModalDataStore.getState().dataRegistry; } catch (error) { console.warn("⚠️ modalDataStore 로드 실패:", error); } } - + // 각 테이블의 첫 번째 항목을 modalDataStore로 변환 const modalDataStore: Record = {}; Object.entries(modalDataStoreRegistry).forEach(([key, items]) => { if (Array.isArray(items) && items.length > 0) { // ModalDataItem[] → originalData 추출 - modalDataStore[key] = items.map(item => item.originalData || item); + modalDataStore[key] = items.map((item) => item.originalData || item); } }); - // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 for (const key of selectedItemsKeys) { // 🆕 새로운 데이터 구조: ItemData[] with fieldGroups - const items = formData[key] as Array<{ - id: string; - originalData: any; - fieldGroups: Record>; + const items = formData[key] as Array<{ + id: string; + originalData: any; + fieldGroups: Record>; }>; - + // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 const componentConfig = context.componentConfigs?.[key]; const parentDataMapping = componentConfig?.parentDataMapping || []; @@ -697,44 +743,42 @@ export class ButtonActionExecutor { // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 for (const item of items) { const groupKeys = Object.keys(item.fieldGroups); - + // 각 그룹의 항목 배열 가져오기 - const groupArrays = groupKeys.map(groupKey => ({ + const groupArrays = groupKeys.map((groupKey) => ({ groupKey, - entries: item.fieldGroups[groupKey] || [] + entries: item.fieldGroups[groupKey] || [], })); - + // 카티션 곱 계산 함수 const cartesianProduct = (arrays: any[][]): any[][] => { if (arrays.length === 0) return [[]]; - if (arrays.length === 1) return arrays[0].map(item => [item]); - + if (arrays.length === 1) return arrays[0].map((item) => [item]); + const [first, ...rest] = arrays; const restProduct = cartesianProduct(rest); - - return first.flatMap(item => - restProduct.map(combination => [item, ...combination]) - ); + + return first.flatMap((item) => restProduct.map((combination) => [item, ...combination])); }; - + // 모든 그룹의 카티션 곱 생성 - const entryArrays = groupArrays.map(g => g.entries); + const entryArrays = groupArrays.map((g) => g.entries); const combinations = cartesianProduct(entryArrays); - + // 각 조합을 개별 레코드로 저장 for (let i = 0; i < combinations.length; i++) { const combination = combinations[i]; try { // 🆕 부모 데이터 매핑 적용 const mappedData: any = {}; - + // 1. parentDataMapping 설정이 있으면 적용 if (parentDataMapping.length > 0) { for (const mapping of parentDataMapping) { let sourceData: any; const sourceTableName = mapping.sourceTable; const selectedItemTable = componentConfig?.sourceTable; - + if (sourceTableName === selectedItemTable) { sourceData = item.originalData; } else { @@ -745,9 +789,9 @@ export class ButtonActionExecutor { sourceData = parentData; } } - + const sourceValue = sourceData[mapping.sourceField]; - + if (sourceValue !== undefined && sourceValue !== null) { mappedData[mapping.targetField] = sourceValue; } else if (mapping.defaultValue !== undefined) { @@ -759,12 +803,12 @@ export class ButtonActionExecutor { if (item.originalData.id) { mappedData.item_id = item.originalData.id; } - + if (parentData.id || parentData.customer_id) { mappedData.customer_id = parentData.customer_id || parentData.id; } } - + // 공통 필드 복사 (company_code, currency_code 등) if (item.originalData.company_code && !mappedData.company_code) { mappedData.company_code = item.originalData.company_code; @@ -772,10 +816,10 @@ export class ButtonActionExecutor { if (item.originalData.currency_code && !mappedData.currency_code) { mappedData.currency_code = item.originalData.currency_code; } - + // 원본 데이터로 시작 (매핑된 데이터 사용) let mergedData = { ...mappedData }; - + // 각 그룹의 항목 데이터를 순차적으로 병합 for (let j = 0; j < combination.length; j++) { const entry = combination[j]; @@ -1103,13 +1147,13 @@ export class ButtonActionExecutor { // 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지 let dataSourceId = config.dataSourceId; - + if (!dataSourceId && context.allComponents) { // TableList 우선 감지 const tableListComponent = context.allComponents.find( - (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName + (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName, ); - + if (tableListComponent) { dataSourceId = tableListComponent.componentConfig.tableName; console.log("✨ TableList 자동 감지:", { @@ -1119,9 +1163,9 @@ export class ButtonActionExecutor { } else { // TableList가 없으면 SplitPanelLayout의 좌측 패널 감지 const splitPanelComponent = context.allComponents.find( - (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName + (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName, ); - + if (splitPanelComponent) { dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName; console.log("✨ 분할 패널 좌측 테이블 자동 감지:", { @@ -1131,7 +1175,7 @@ export class ButtonActionExecutor { } } } - + // 여전히 없으면 context.tableName 또는 "default" 사용 if (!dataSourceId) { dataSourceId = context.tableName || "default"; @@ -1141,7 +1185,7 @@ export class ButtonActionExecutor { try { const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; - + const modalData = dataRegistry[dataSourceId] || []; console.log("📊 현재 화면 데이터 확인:", { @@ -1171,13 +1215,13 @@ export class ButtonActionExecutor { // 6. 동적 모달 제목 생성 const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; - + let finalTitle = "데이터 입력"; - + // 🆕 블록 기반 제목 (우선순위 1) if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) { const titleParts: string[] = []; - + config.modalTitleBlocks.forEach((block) => { if (block.type === "text") { // 텍스트 블록: 그대로 추가 @@ -1186,13 +1230,13 @@ export class ButtonActionExecutor { // 필드 블록: 데이터에서 값 가져오기 const tableName = block.tableName; const columnName = block.value; - + if (tableName && columnName) { const tableData = dataRegistry[tableName]; if (tableData && tableData.length > 0) { const firstItem = tableData[0].originalData || tableData[0]; const value = firstItem[columnName]; - + if (value !== undefined && value !== null) { titleParts.push(String(value)); console.log(`✨ 동적 필드: ${tableName}.${columnName} → ${value}`); @@ -1207,28 +1251,28 @@ export class ButtonActionExecutor { } } }); - + finalTitle = titleParts.join(""); console.log("📋 블록 기반 제목 생성:", finalTitle); } // 기존 방식: {tableName.columnName} 패턴 (우선순위 2) else if (config.modalTitle) { finalTitle = config.modalTitle; - + if (finalTitle.includes("{")) { const matches = finalTitle.match(/\{([^}]+)\}/g); - + if (matches) { matches.forEach((match) => { const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name const [tableName, columnName] = path.split("."); - + if (tableName && columnName) { const tableData = dataRegistry[tableName]; if (tableData && tableData.length > 0) { const firstItem = tableData[0].originalData || tableData[0]; const value = firstItem[columnName]; - + if (value !== undefined && value !== null) { finalTitle = finalTitle.replace(match, String(value)); console.log(`✨ 동적 제목: ${match} → ${value}`); @@ -1239,7 +1283,7 @@ export class ButtonActionExecutor { } } } - + // 7. 모달 열기 + URL 파라미터로 dataSourceId 전달 if (config.targetScreenId) { // config에 modalDescription이 있으면 우선 사용 @@ -1267,10 +1311,10 @@ export class ButtonActionExecutor { }); window.dispatchEvent(modalEvent); - + // 성공 메시지 (간단하게) toast.success(config.successMessage || "다음 단계로 진행합니다."); - + return true; } else { console.error("모달로 열 화면이 지정되지 않았습니다."); @@ -1473,15 +1517,15 @@ export class ButtonActionExecutor { const layoutData = await screenApi.getLayout(config.targetScreenId); if (layoutData?.components) { hasSplitPanel = layoutData.components.some( - (comp: any) => - comp.type === "screen-split-panel" || + (comp: any) => + comp.type === "screen-split-panel" || comp.componentType === "screen-split-panel" || - comp.type === "split-panel-layout" || - comp.componentType === "split-panel-layout" + comp.type === "split-panel-layout" || + comp.componentType === "split-panel-layout", ); } - console.log("🔍 [openEditModal] 분할 패널 확인:", { - targetScreenId: config.targetScreenId, + console.log("🔍 [openEditModal] 분할 패널 확인:", { + targetScreenId: config.targetScreenId, hasSplitPanel, componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [], }); @@ -1632,7 +1676,8 @@ export class ButtonActionExecutor { if (copiedData[field] !== undefined) { const originalValue = copiedData[field]; const ruleIdKey = `${field}_numberingRuleId`; - const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; + const hasNumberingRule = + rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; // 품목코드를 무조건 공백으로 초기화 copiedData[field] = ""; @@ -1821,7 +1866,7 @@ export class ButtonActionExecutor { // flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주 const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig; - + if (isFlowMode && config.dataflowConfig?.flowConfig) { console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig); @@ -2604,14 +2649,14 @@ export class ButtonActionExecutor { if (context.tableName) { const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); const storedData = tableDisplayStore.getTableData(context.tableName); - + // 필터 조건은 저장소 또는 context에서 가져오기 const filterConditions = storedData?.filterConditions || context.filterConditions; const searchTerm = storedData?.searchTerm || context.searchTerm; try { const { entityJoinApi } = await import("@/lib/api/entityJoin"); - + const apiParams = { page: 1, size: 10000, // 최대 10,000개 @@ -2621,7 +2666,7 @@ export class ButtonActionExecutor { enableEntityJoin: true, // ✅ Entity 조인 // autoFilter는 entityJoinApi.getTableDataWithJoins 내부에서 자동으로 적용됨 }; - + // 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용 const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams); @@ -2637,7 +2682,7 @@ export class ButtonActionExecutor { if (Array.isArray(response)) { // 배열로 직접 반환된 경우 dataToExport = response; - } else if (response && 'data' in response) { + } else if (response && "data" in response) { // EntityJoinResponse 객체인 경우 dataToExport = response.data; } else { @@ -2678,7 +2723,7 @@ export class ButtonActionExecutor { // 파일명 생성 (메뉴 이름 우선 사용) let defaultFileName = context.tableName || "데이터"; - + // localStorage에서 메뉴 이름 가져오기 if (typeof window !== "undefined") { const menuName = localStorage.getItem("currentMenuName"); @@ -2686,107 +2731,104 @@ export class ButtonActionExecutor { defaultFileName = menuName; } } - + const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; - // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 - let visibleColumns: string[] | undefined = undefined; - let columnLabels: Record | undefined = undefined; - + // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 + let visibleColumns: string[] | undefined = undefined; + let columnLabels: Record | undefined = undefined; + + try { + // 화면 레이아웃 데이터 가져오기 (별도 API 사용) + const { apiClient } = await import("@/lib/api/client"); + const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); + + if (layoutResponse.data?.success && layoutResponse.data?.data) { + let layoutData = layoutResponse.data.data; + + // components가 문자열이면 파싱 + if (typeof layoutData.components === "string") { + layoutData.components = JSON.parse(layoutData.components); + } + + // 테이블 리스트 컴포넌트 찾기 + const findTableListComponent = (components: any[]): any => { + if (!Array.isArray(components)) return null; + + for (const comp of components) { + // componentType이 'table-list'인지 확인 + const isTableList = comp.componentType === "table-list"; + + // componentConfig 안에서 테이블명 확인 + const matchesTable = + comp.componentConfig?.selectedTable === context.tableName || + comp.componentConfig?.tableName === context.tableName; + + if (isTableList && matchesTable) { + return comp; + } + if (comp.children && comp.children.length > 0) { + const found = findTableListComponent(comp.children); + if (found) return found; + } + } + return null; + }; + + const tableListComponent = findTableListComponent(layoutData.components || []); + + if (tableListComponent && tableListComponent.componentConfig?.columns) { + const columns = tableListComponent.componentConfig.columns; + + // visible이 true인 컬럼만 추출 + visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName); + + // 🎯 column_labels 테이블에서 실제 라벨 가져오기 try { - // 화면 레이아웃 데이터 가져오기 (별도 API 사용) - const { apiClient } = await import("@/lib/api/client"); - const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); - - if (layoutResponse.data?.success && layoutResponse.data?.data) { - let layoutData = layoutResponse.data.data; - - // components가 문자열이면 파싱 - if (typeof layoutData.components === 'string') { - layoutData.components = JSON.parse(layoutData.components); + const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { + params: { page: 1, size: 9999 }, + }); + + if (columnsResponse.data?.success && columnsResponse.data?.data) { + let columnData = columnsResponse.data.data; + + // data가 객체이고 columns 필드가 있으면 추출 + if (columnData.columns && Array.isArray(columnData.columns)) { + columnData = columnData.columns; } - - // 테이블 리스트 컴포넌트 찾기 - const findTableListComponent = (components: any[]): any => { - if (!Array.isArray(components)) return null; - - for (const comp of components) { - // componentType이 'table-list'인지 확인 - const isTableList = comp.componentType === 'table-list'; - - // componentConfig 안에서 테이블명 확인 - const matchesTable = - comp.componentConfig?.selectedTable === context.tableName || - comp.componentConfig?.tableName === context.tableName; - - if (isTableList && matchesTable) { - return comp; + + if (Array.isArray(columnData)) { + columnLabels = {}; + + // API에서 가져온 라벨로 매핑 + columnData.forEach((colData: any) => { + const colName = colData.column_name || colData.columnName; + // 우선순위: column_label > label > displayName > columnName + const labelValue = colData.column_label || colData.label || colData.displayName || colName; + if (colName && labelValue) { + columnLabels![colName] = labelValue; } - if (comp.children && comp.children.length > 0) { - const found = findTableListComponent(comp.children); - if (found) return found; - } - } - return null; - }; - - const tableListComponent = findTableListComponent(layoutData.components || []); - - if (tableListComponent && tableListComponent.componentConfig?.columns) { - const columns = tableListComponent.componentConfig.columns; - - // visible이 true인 컬럼만 추출 - visibleColumns = columns - .filter((col: any) => col.visible !== false) - .map((col: any) => col.columnName); - - // 🎯 column_labels 테이블에서 실제 라벨 가져오기 - try { - const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { - params: { page: 1, size: 9999 } - }); - - if (columnsResponse.data?.success && columnsResponse.data?.data) { - let columnData = columnsResponse.data.data; - - // data가 객체이고 columns 필드가 있으면 추출 - if (columnData.columns && Array.isArray(columnData.columns)) { - columnData = columnData.columns; - } - - if (Array.isArray(columnData)) { - columnLabels = {}; - - // API에서 가져온 라벨로 매핑 - columnData.forEach((colData: any) => { - const colName = colData.column_name || colData.columnName; - // 우선순위: column_label > label > displayName > columnName - const labelValue = colData.column_label || colData.label || colData.displayName || colName; - if (colName && labelValue) { - columnLabels![colName] = labelValue; - } - }); - } - } - } catch (error) { - // 실패 시 컴포넌트 설정의 displayName 사용 - columnLabels = {}; - columns.forEach((col: any) => { - if (col.columnName) { - columnLabels![col.columnName] = col.displayName || col.label || col.columnName; - } - }); - } - } else { - console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); + }); } } } catch (error) { - console.error("❌ 화면 레이아웃 조회 실패:", error); + // 실패 시 컴포넌트 설정의 displayName 사용 + columnLabels = {}; + columns.forEach((col: any) => { + if (col.columnName) { + columnLabels![col.columnName] = col.displayName || col.label || col.columnName; + } + }); } - + } else { + console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); + } + } + } catch (error) { + console.error("❌ 화면 레이아웃 조회 실패:", error); + } // 🎨 카테고리 값들 조회 (한 번만) const categoryMap: Record> = {}; @@ -2796,20 +2838,20 @@ export class ButtonActionExecutor { if (context.tableName) { try { const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue"); - + const categoryColumnsResponse = await getCategoryColumns(context.tableName); - + if (categoryColumnsResponse.success && categoryColumnsResponse.data) { // 백엔드에서 정의된 카테고리 컬럼들 - categoryColumns = categoryColumnsResponse.data.map((col: any) => - col.column_name || col.columnName || col.name - ).filter(Boolean); // undefined 제거 - + categoryColumns = categoryColumnsResponse.data + .map((col: any) => col.column_name || col.columnName || col.name) + .filter(Boolean); // undefined 제거 + // 각 카테고리 컬럼의 값들 조회 for (const columnName of categoryColumns) { try { const valuesResponse = await getCategoryValues(context.tableName, columnName, false); - + if (valuesResponse.success && valuesResponse.data) { // valueCode → valueLabel 매핑 categoryMap[columnName] = {}; @@ -2820,7 +2862,6 @@ export class ButtonActionExecutor { categoryMap[columnName][code] = label; } }); - } } catch (error) { console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error); @@ -2840,34 +2881,33 @@ export class ButtonActionExecutor { visibleColumns.forEach((columnName: string) => { // __checkbox__ 컬럼은 제외 if (columnName === "__checkbox__") return; - + if (columnName in row) { // 라벨 우선 사용, 없으면 컬럼명 사용 const label = columnLabels?.[columnName] || columnName; - + // 🎯 Entity 조인된 값 우선 사용 let value = row[columnName]; - + // writer → writer_name 사용 - if (columnName === 'writer' && row['writer_name']) { - value = row['writer_name']; + if (columnName === "writer" && row["writer_name"]) { + value = row["writer_name"]; } // 다른 엔티티 필드들도 _name 우선 사용 else if (row[`${columnName}_name`]) { value = row[`${columnName}_name`]; } // 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만) - else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) { + else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) { value = categoryMap[columnName][value]; } - + filteredRow[label] = value; } }); return filteredRow; }); - } // 최대 행 수 제한 @@ -2894,8 +2934,8 @@ export class ButtonActionExecutor { */ private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("📤 엑셀 업로드 모달 열기:", { - config, + console.log("📤 엑셀 업로드 모달 열기:", { + config, context, userId: context.userId, tableName: context.tableName, @@ -2993,7 +3033,7 @@ export class ButtonActionExecutor { userId: context.userId, onScanSuccess: (barcode: string) => { console.log("✅ 바코드 스캔 성공:", barcode); - + // 대상 필드에 값 입력 if (config.barcodeTargetField && context.onFormDataChange) { context.onFormDataChange({ @@ -3003,7 +3043,7 @@ export class ButtonActionExecutor { } toast.success(`바코드 스캔 완료: ${barcode}`); - + // 자동 제출 옵션이 켜져있으면 저장 if (config.barcodeAutoSubmit) { this.handleSave(config, context); @@ -3130,7 +3170,7 @@ export class ButtonActionExecutor { // 미리보기 표시 (옵션) if (config.mergeShowPreview !== false) { const { apiClient } = await import("@/lib/api/client"); - + const previewResponse = await apiClient.post("/code-merge/preview", { columnName, oldValue, @@ -3142,12 +3182,12 @@ export class ButtonActionExecutor { const confirmMerge = confirm( `⚠️ 코드 병합 확인\n\n` + - `${oldValue} → ${newValue}\n\n` + - `영향받는 데이터:\n` + - `- 테이블 수: ${preview.preview.length}개\n` + - `- 총 행 수: ${totalRows}개\n\n` + - `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + - `계속하시겠습니까?` + `${oldValue} → ${newValue}\n\n` + + `영향받는 데이터:\n` + + `- 테이블 수: ${preview.preview.length}개\n` + + `- 총 행 수: ${totalRows}개\n\n` + + `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + + `계속하시겠습니까?`, ); if (!confirmMerge) { @@ -3160,7 +3200,7 @@ export class ButtonActionExecutor { toast.loading("코드 병합 중...", { duration: Infinity }); const { apiClient } = await import("@/lib/api/client"); - + const response = await apiClient.post("/code-merge/merge-all-tables", { columnName, oldValue, @@ -3172,8 +3212,7 @@ export class ButtonActionExecutor { if (response.data.success) { const data = response.data.data; toast.success( - `코드 병합 완료!\n` + - `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트` + `코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, ); // 화면 새로고침 @@ -3202,7 +3241,7 @@ export class ButtonActionExecutor { // 선택된 행 데이터 확인 const selectedRows = context.selectedRowsData || context.flowSelectedData || []; - + if (!selectedRows || selectedRows.length === 0) { toast.error("전달할 데이터를 선택해주세요."); return false; @@ -3212,11 +3251,11 @@ export class ButtonActionExecutor { // dataTransfer 설정 확인 const dataTransfer = config.dataTransfer; - + if (!dataTransfer) { // dataTransfer 설정이 없으면 기본 동작: 전역 이벤트로 데이터 전달 console.log("📤 [handleTransferData] dataTransfer 설정 없음 - 전역 이벤트 발생"); - + const transferEvent = new CustomEvent("splitPanelDataTransfer", { detail: { data: selectedRows, @@ -3225,7 +3264,7 @@ export class ButtonActionExecutor { }, }); window.dispatchEvent(transferEvent); - + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } @@ -3236,7 +3275,7 @@ export class ButtonActionExecutor { if (targetType === "component" && targetComponentId) { // 같은 화면 내 컴포넌트로 전달 console.log("📤 [handleTransferData] 컴포넌트로 전달:", targetComponentId); - + const transferEvent = new CustomEvent("componentDataTransfer", { detail: { targetComponentId, @@ -3246,13 +3285,13 @@ export class ButtonActionExecutor { }, }); window.dispatchEvent(transferEvent); - + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } else if (targetType === "screen" && targetScreenId) { // 다른 화면으로 전달 (분할 패널 등) console.log("📤 [handleTransferData] 화면으로 전달:", targetScreenId); - + const transferEvent = new CustomEvent("screenDataTransfer", { detail: { targetScreenId, @@ -3262,13 +3301,13 @@ export class ButtonActionExecutor { }, }); window.dispatchEvent(transferEvent); - + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } else { // 기본: 분할 패널 데이터 전달 이벤트 console.log("📤 [handleTransferData] 기본 분할 패널 전달"); - + const transferEvent = new CustomEvent("splitPanelDataTransfer", { detail: { data: selectedRows, @@ -3278,7 +3317,7 @@ export class ButtonActionExecutor { }, }); window.dispatchEvent(transferEvent); - + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } @@ -3289,6 +3328,318 @@ export class ButtonActionExecutor { } } + /** + * 위치정보 가져오기 액션 처리 + */ + private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); + + // 브라우저 Geolocation API 지원 확인 + if (!navigator.geolocation) { + toast.error("이 브라우저는 위치정보를 지원하지 않습니다."); + return false; + } + + // 위도/경도 저장 필드 확인 + const latField = config.geolocationLatField; + const lngField = config.geolocationLngField; + + if (!latField || !lngField) { + toast.error("위도/경도 저장 필드가 설정되지 않았습니다."); + return false; + } + + // 로딩 토스트 표시 + const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); + + // Geolocation 옵션 설정 + const options: PositionOptions = { + enableHighAccuracy: config.geolocationHighAccuracy !== false, // 기본 true + timeout: config.geolocationTimeout || 10000, // 기본 10초 + maximumAge: config.geolocationMaxAge || 0, // 기본 0 (항상 새로운 위치) + }; + + // 위치 정보 가져오기 (Promise로 래핑) + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, options); + }); + + // 로딩 토스트 제거 + toast.dismiss(loadingToastId); + + const { latitude, longitude, accuracy, altitude, heading, speed } = position.coords; + const timestamp = new Date(position.timestamp); + + console.log("📍 위치정보 획득 성공:", { + latitude, + longitude, + accuracy, + timestamp: timestamp.toISOString(), + }); + + // 폼 데이터 업데이트 + const updates: Record = { + [latField]: latitude, + [lngField]: longitude, + }; + + // 선택적 필드들 + if (config.geolocationAccuracyField && accuracy !== null) { + updates[config.geolocationAccuracyField] = accuracy; + } + if (config.geolocationTimestampField) { + updates[config.geolocationTimestampField] = timestamp.toISOString(); + } + + // 🆕 추가 필드 변경 (위치정보 + 상태변경) + let extraTableUpdated = false; + if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) { + const extraTableName = config.geolocationExtraTableName; + const currentTableName = config.geolocationTableName || context.tableName; + + // 다른 테이블에 UPDATE하는 경우 + if (extraTableName && extraTableName !== currentTableName) { + console.log("📍 다른 테이블 필드 변경:", { + targetTable: extraTableName, + field: config.geolocationExtraField, + value: config.geolocationExtraValue, + keyField: config.geolocationExtraKeyField, + keySourceField: config.geolocationExtraKeySourceField, + }); + + // 키 값 가져오기 + const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""]; + + if (keyValue && config.geolocationExtraKeyField) { + try { + // 다른 테이블 UPDATE API 호출 + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: extraTableName, + keyField: config.geolocationExtraKeyField, + keyValue: keyValue, + updateField: config.geolocationExtraField, + updateValue: config.geolocationExtraValue, + }); + + if (response.data?.success) { + extraTableUpdated = true; + console.log("✅ 다른 테이블 UPDATE 성공:", response.data); + } else { + console.error("❌ 다른 테이블 UPDATE 실패:", response.data); + toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`); + } + } catch (apiError) { + console.error("❌ 다른 테이블 UPDATE API 오류:", apiError); + toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`); + } + } else { + console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", { + keySourceField: config.geolocationExtraKeySourceField, + keyValue, + }); + } + } else { + // 같은 테이블 (현재 폼 데이터에 추가) + updates[config.geolocationExtraField] = config.geolocationExtraValue; + console.log("📍 같은 테이블 추가 필드 변경:", { + field: config.geolocationExtraField, + value: config.geolocationExtraValue, + }); + } + } + + // formData 업데이트 + if (context.onFormDataChange) { + Object.entries(updates).forEach(([field, value]) => { + context.onFormDataChange?.(field, value); + }); + } + + // 성공 메시지 생성 + let successMsg = + config.successMessage || + `위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`; + + // 추가 필드 변경이 있으면 메시지에 포함 + if (config.geolocationUpdateField && config.geolocationExtraField) { + if (extraTableUpdated) { + successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`; + } else if ( + !config.geolocationExtraTableName || + config.geolocationExtraTableName === (config.geolocationTableName || context.tableName) + ) { + successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`; + } + } + + // 성공 메시지 표시 + toast.success(successMsg); + + // 자동 저장 옵션이 활성화된 경우 + if (config.geolocationAutoSave && context.onSave) { + console.log("📍 위치정보 자동 저장 실행"); + try { + await context.onSave(); + toast.success("위치 정보가 저장되었습니다."); + } catch (saveError) { + console.error("❌ 위치정보 자동 저장 실패:", saveError); + toast.error("위치 정보 저장에 실패했습니다."); + } + } + + return true; + } catch (error: any) { + console.error("❌ 위치정보 가져오기 실패:", error); + toast.dismiss(); + + // GeolocationPositionError 처리 + if (error.code) { + switch (error.code) { + case 1: // PERMISSION_DENIED + toast.error("위치 정보 접근이 거부되었습니다.\n브라우저 설정에서 위치 권한을 허용해주세요."); + break; + case 2: // POSITION_UNAVAILABLE + toast.error("위치 정보를 사용할 수 없습니다.\nGPS 신호를 확인해주세요."); + break; + case 3: // TIMEOUT + toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요."); + break; + default: + toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다."); + } + } else { + toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다."); + } + + return false; + } + } + + /** + * 필드 값 변경 액션 처리 (예: status를 active로 변경) + */ + private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("🔄 필드 값 변경 액션 실행:", { config, context }); + + const { formData, tableName, onFormDataChange, onSave } = context; + + // 변경할 필드 확인 + const targetField = config.updateTargetField; + const targetValue = config.updateTargetValue; + const multipleFields = config.updateMultipleFields || []; + + // 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함 + if (!targetField && multipleFields.length === 0) { + toast.error("변경할 필드가 설정되지 않았습니다."); + return false; + } + + // 확인 메시지 표시 (설정된 경우) + if (config.confirmMessage) { + const confirmed = window.confirm(config.confirmMessage); + if (!confirmed) { + console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)"); + return false; + } + } + + // 변경할 필드 목록 구성 + const updates: Record = {}; + + // 단일 필드 변경 + if (targetField && targetValue !== undefined) { + updates[targetField] = targetValue; + } + + // 다중 필드 변경 + multipleFields.forEach(({ field, value }) => { + updates[field] = value; + }); + + console.log("🔄 변경할 필드들:", updates); + + // formData 업데이트 + if (onFormDataChange) { + Object.entries(updates).forEach(([field, value]) => { + onFormDataChange(field, value); + }); + } + + // 자동 저장 (기본값: true) + const autoSave = config.updateAutoSave !== false; + + if (autoSave) { + // onSave 콜백이 있으면 사용 + if (onSave) { + console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)"); + try { + await onSave(); + toast.success(config.successMessage || "상태가 변경되었습니다."); + return true; + } catch (saveError) { + console.error("❌ 필드 값 변경 저장 실패:", saveError); + toast.error(config.errorMessage || "상태 변경 저장에 실패했습니다."); + return false; + } + } + + // API를 통한 직접 저장 + if (tableName && formData) { + console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)"); + try { + // PK 필드 찾기 (id 또는 테이블명_id) + const pkField = formData.id !== undefined ? "id" : `${tableName}_id`; + const pkValue = formData[pkField] || formData.id; + + if (!pkValue) { + toast.error("레코드 ID를 찾을 수 없습니다."); + return false; + } + + // 업데이트할 데이터 구성 (변경할 필드들만) + const updateData = { + ...updates, + [pkField]: pkValue, // PK 포함 + }; + + const response = await DynamicFormApi.updateData(tableName, updateData); + + if (response.success) { + toast.success(config.successMessage || "상태가 변경되었습니다."); + + // 테이블 새로고침 이벤트 발생 + window.dispatchEvent( + new CustomEvent("refreshTableData", { + detail: { tableName }, + }), + ); + + return true; + } else { + toast.error(response.message || config.errorMessage || "상태 변경에 실패했습니다."); + return false; + } + } catch (apiError) { + console.error("❌ 필드 값 변경 API 호출 실패:", apiError); + toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다."); + return false; + } + } + } + + // 자동 저장이 비활성화된 경우 폼 데이터만 변경 + toast.success(config.successMessage || "필드 값이 변경되었습니다. 저장 버튼을 눌러 저장하세요."); + return true; + } catch (error) { + console.error("❌ 필드 값 변경 실패:", error); + toast.error(config.errorMessage || "필드 값 변경 중 오류가 발생했습니다."); + return false; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -3397,4 +3748,21 @@ export const DEFAULT_BUTTON_ACTIONS: Record