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/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 556cc323..cf53a490 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; // 편집 모달 제목 @@ -237,6 +268,12 @@ export class ButtonActionExecutor { case "code_merge": return await this.handleCodeMerge(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; @@ -3190,6 +3227,312 @@ 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; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -3293,4 +3636,21 @@ export const DEFAULT_BUTTON_ACTIONS: Record