From 67e6a8008dd43158606f1c5081273762a922c82c Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 28 Nov 2025 18:35:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=EC=A4=91=20=EC=9C=84=EC=B9=98=EC=A0=95=EB=B3=B4=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0,=20=20=ED=95=84=EB=93=9C=EA=B0=92?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 63 +++ backend-node/src/routes/dynamicFormRoutes.ts | 2 + .../src/services/dynamicFormService.ts | 65 ++- frontend/components/screen/ScreenDesigner.tsx | 14 +- .../config-panels/ButtonConfigPanel.tsx | 534 +++++++++++++++++- .../button-primary/ButtonPrimaryComponent.tsx | 9 +- frontend/lib/registry/components/index.ts | 6 + .../LocationSwapSelectorComponent.tsx | 432 ++++++++++++++ .../LocationSwapSelectorConfigPanel.tsx | 415 ++++++++++++++ .../LocationSwapSelectorRenderer.tsx | 26 + .../location-swap-selector/index.ts | 54 ++ .../RepeatScreenModalComponent.tsx | 236 ++++++++ .../RepeatScreenModalConfigPanel.tsx | 122 ++++ .../RepeatScreenModalRenderer.tsx | 26 + .../components/repeat-screen-modal/index.ts | 52 ++ frontend/lib/utils/buttonActions.ts | 362 +++++++++++- 16 files changed, 2401 insertions(+), 17 deletions(-) create mode 100644 frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx create mode 100644 frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx create mode 100644 frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx create mode 100644 frontend/lib/registry/components/location-swap-selector/index.ts create mode 100644 frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx create mode 100644 frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx create mode 100644 frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx create mode 100644 frontend/lib/registry/components/repeat-screen-modal/index.ts 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 08199609..66bec96d 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -996,6 +996,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); @@ -1453,7 +1464,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, @@ -1463,6 +1474,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 7af50458..91ee99e7 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -375,6 +375,40 @@ export const ButtonConfigPanel: React.FC = ({ fetchTableColumns(); }, [config.action?.type, config.action?.historyTableName, currentTableName]); + // 🆕 geolocation/update_field 테이블 컬럼 자동 로드 + useEffect(() => { + const actionType = config.action?.type; + + // geolocation 액션일 때 + if (actionType === "geolocation") { + // 위치정보 저장 테이블 컬럼 로드 + const tableName = config.action?.geolocationTableName || currentTableName; + if (tableName) { + loadTableColumns(tableName); + } + + // 🔥 추가 필드 변경용 대상 테이블 컬럼도 로드 + const extraTableName = config.action?.geolocationExtraTableName; + if (extraTableName) { + loadTableColumns(extraTableName); + } + } + + // update_field 액션일 때 + if (actionType === "update_field") { + const tableName = config.action?.updateTableName || currentTableName; + if (tableName) { + loadTableColumns(tableName); + } + } + }, [ + config.action?.type, + config.action?.geolocationTableName, + config.action?.geolocationExtraTableName, // 🔥 추가 + config.action?.updateTableName, + currentTableName, + ]); + // 검색 필터링 함수 const filterScreens = (searchTerm: string) => { if (!searchTerm.trim()) return screens; @@ -442,6 +476,8 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 업로드 바코드 스캔 코드 병합 + 위치정보 가져오기 + 필드 값 변경 @@ -508,8 +544,7 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0" - className="text-xs" + className="h-6 w-full justify-between px-2 py-0 text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -899,8 +934,7 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0" - className="text-xs" + className="h-6 w-full justify-between px-2 py-0 text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -977,8 +1011,7 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0" - className="text-xs" + className="h-6 w-full justify-between px-2 py-0 text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1131,8 +1164,7 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0" - className="text-xs" + className="h-6 w-full justify-between px-2 py-0 text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1347,8 +1379,7 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={navScreenOpen} - className="h-6 w-full justify-between px-2 py-0" - className="text-xs" + className="h-6 w-full justify-between px-2 py-0 text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1601,6 +1632,489 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 위치정보 가져오기 설정 */} + {(component.componentConfig?.action?.type || "save") === "geolocation" && ( +
+

📍 위치정보 설정

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

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

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + onUpdateProperty("componentConfig.action.geolocationTimeout", parseInt(e.target.value) || 10000)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationMaxAge", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +

0 = 항상 새로운 위치

+
+
+ +
+
+ +

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

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

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

+
+ onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} + /> +
+ + {/* 추가 필드 변경 (위치정보 + 상태변경) */} +
+
+
+ +

위치정보와 함께 다른 테이블/필드 값도 변경합니다

+
+ { + onUpdateProperty("componentConfig.action.geolocationUpdateField", checked); + if (!checked) { + // 비활성화 시 관련 필드 초기화 + onUpdateProperty("componentConfig.action.geolocationExtraTableName", ""); + onUpdateProperty("componentConfig.action.geolocationExtraField", ""); + onUpdateProperty("componentConfig.action.geolocationExtraValue", ""); + onUpdateProperty("componentConfig.action.geolocationExtraKeyField", ""); + onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", ""); + } + }} + /> +
+ + {config.action?.geolocationUpdateField && ( +
+ {/* 대상 테이블 선택 */} +
+ + +

+ 다른 테이블 선택 시 해당 테이블의 레코드를 UPDATE합니다 +

+
+ + {/* 다른 테이블 선택 시 연결 키 설정 */} + {config.action?.geolocationExtraTableName && + config.action?.geolocationExtraTableName !== (config.action?.geolocationTableName || currentTableName) && ( +
+
+ + +

예: vehicle_id

+
+
+ + +

예: vehicle_id

+
+
+ )} + + {/* 변경할 필드와 값 */} +
+
+ + +
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} + className="h-8 text-xs" + /> +
+
+

+ {config.action?.geolocationExtraTableName && + config.action?.geolocationExtraTableName !== (config.action?.geolocationTableName || currentTableName) + ? `다른 테이블(${config.action?.geolocationExtraTableName})의 레코드를 UPDATE합니다` + : "예: status 필드를 \"active\"로 변경하여 운행 시작 상태로 표시"} +

+
+ )} +
+ +
+

+ 사용 방법: +
+ 1. 버튼을 클릭하면 브라우저가 위치 권한을 요청합니다 +
+ 2. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다 +
+ 3. 위도/경도가 지정된 필드에 자동으로 입력됩니다 + {config.action?.geolocationUpdateField && ( + <> +
+ 4. 추가로 지정한 필드 값도 함께 변경됩니다 + + )} +
+
+ 참고: HTTPS 환경에서만 위치정보가 작동합니다. +

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

📝 필드 값 변경 설정

+ +
+ + +

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

+
+ +
+
+ + +

변경할 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"로 변경 +

+
+
+ )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index d2b69074..f4ea2b94 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -146,7 +146,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } | null>(null); // 토스트 정리를 위한 ref - const currentLoadingToastRef = useRef(); + const currentLoadingToastRef = useRef(undefined); // 컴포넌트 언마운트 시 토스트 정리 useEffect(() => { @@ -190,9 +190,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; // 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동) @@ -227,13 +229,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 12e6e944..8a9b38ff 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -62,6 +62,12 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 // 🆕 탭 컴포넌트 import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트 +// 🆕 반복 화면 모달 컴포넌트 +import "./repeat-screen-modal/RepeatScreenModalRenderer"; + +// 🆕 출발지/도착지 선택 컴포넌트 +import "./location-swap-selector/LocationSwapSelectorRenderer"; + /** * 컴포넌트 초기화 함수 */ 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/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx new file mode 100644 index 00000000..7506c3a3 --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -0,0 +1,236 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface ContentRow { + label?: string; + field?: string; + type?: string; +} + +interface DataSource { + filterField?: string; + sourceTable?: string; +} + +interface Grouping { + enabled?: boolean; + aggregations?: any[]; + groupField?: string; +} + +interface TableLayout { + headerRows?: any[]; + tableColumns?: any[]; +} + +interface CardLayoutItem { + field?: string; + label?: string; + width?: string; +} + +export interface RepeatScreenModalProps { + // 기본 props + id?: string; + label?: string; + style?: React.CSSProperties; + + // 컴포넌트 설정 + cardMode?: "simple" | "detailed"; + cardSpacing?: string; + cardTitle?: string; + contentRows?: ContentRow[]; + dataSource?: DataSource; + grouping?: Grouping; + saveMode?: "all" | "single"; + showCardBorder?: boolean; + showCardTitle?: boolean; + tableLayout?: TableLayout; + cardLayout?: CardLayoutItem[]; + + // 컴포넌트 config (componentConfig에서 전달됨) + componentConfig?: { + type?: string; + webType?: string; + cardMode?: string; + cardSpacing?: string; + cardTitle?: string; + contentRows?: ContentRow[]; + dataSource?: DataSource; + grouping?: Grouping; + saveMode?: string; + showCardBorder?: boolean; + showCardTitle?: boolean; + tableLayout?: TableLayout; + cardLayout?: CardLayoutItem[]; + }; +} + +/** + * RepeatScreenModal 컴포넌트 + * 카드/테이블 형태로 데이터를 반복 표시하고 편집할 수 있는 모달 + */ +export function RepeatScreenModalComponent(props: RepeatScreenModalProps) { + const { + id, + label = "반복 화면 모달", + style, + componentConfig, + } = props; + + // componentConfig에서 설정 가져오기 + const config = componentConfig || {}; + const { + cardMode = "simple", + cardSpacing = "24px", + cardTitle = "", + contentRows = [], + dataSource, + grouping, + saveMode = "all", + showCardBorder = true, + showCardTitle = true, + tableLayout, + cardLayout = [], + } = config; + + // 스타일에서 width, height 추출 + const { width, height, ...restStyle } = style || {}; + + return ( +
+ + {showCardTitle && ( + + {label} + {cardTitle && ( +

{cardTitle}

+ )} +
+ )} + + {/* 데이터 소스 정보 표시 */} + {dataSource?.sourceTable && ( +
+

+ 데이터 소스:{" "} + {dataSource.sourceTable} +

+ {dataSource.filterField && ( +

+ 필터 필드:{" "} + {dataSource.filterField} +

+ )} +
+ )} + + {/* 카드 레이아웃 표시 */} + {cardLayout && cardLayout.length > 0 && ( +
+ {cardLayout.map((item, index) => ( +
+

{item.label || item.field}

+

{item.field || "-"}

+
+ ))} +
+ )} + + {/* 컨텐츠 행 표시 */} + {contentRows && contentRows.length > 0 && ( +
+ {contentRows.map((row, index) => ( +
+ + {row.label || row.field || `Row ${index + 1}`} + + + {row.field || "-"} + +
+ ))} +
+ )} + + {/* 테이블 레이아웃이 있으면 테이블 형태로 표시 */} + {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( +
+ + + + {tableLayout.tableColumns.map((col: any, index: number) => ( + + ))} + + + + + + + +
+ {col.label || col.field || `Column ${index + 1}`} +
+ 데이터가 없습니다 +
+
+ )} + + {/* 빈 상태 표시 */} + {(!contentRows || contentRows.length === 0) && + (!cardLayout || cardLayout.length === 0) && + (!tableLayout?.tableColumns || tableLayout.tableColumns.length === 0) && ( +
+
+

+ 반복 화면 모달 +

+

+ 컴포넌트 설정을 구성해주세요 +

+
+
+ )} + + {/* 그룹핑 정보 */} + {grouping?.enabled && ( +
+ 그룹핑 활성화됨 {grouping.groupField && `(${grouping.groupField})`} +
+ )} + + {/* 저장 모드 표시 */} +
+ 저장 모드: {saveMode === "all" ? "전체 저장" : "개별 저장"} +
+
+
+
+ ); +} + diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx new file mode 100644 index 00000000..0851a253 --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React 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"; + +interface RepeatScreenModalConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +/** + * RepeatScreenModal 설정 패널 + */ +export function RepeatScreenModalConfigPanel({ + config, + onChange, +}: RepeatScreenModalConfigPanelProps) { + const handleChange = (key: string, value: any) => { + onChange({ + ...config, + [key]: value, + }); + }; + + return ( +
+
+ + +
+ +
+ + handleChange("cardSpacing", e.target.value)} + placeholder="예: 24px" + /> +
+ +
+ + handleChange("cardTitle", e.target.value)} + placeholder="카드 제목 (변수 사용 가능: {field_name})" + /> +
+ +
+ + +
+ +
+ + handleChange("showCardBorder", checked)} + /> +
+ +
+ + handleChange("showCardTitle", checked)} + /> +
+ +
+ + + handleChange("grouping", { ...config?.grouping, enabled: checked }) + } + /> +
+ +
+

데이터 소스 설정

+

+ 소스 테이블: {config?.dataSource?.sourceTable || "미설정"} +

+

필터 필드: {config?.dataSource?.filterField || "미설정"}

+
+
+ ); +} + diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx new file mode 100644 index 00000000..640a9e9e --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalRenderer.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { RepeatScreenModalDefinition } from "./index"; +import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent"; + +/** + * RepeatScreenModal 렌더러 + */ +export class RepeatScreenModalRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = RepeatScreenModalDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +RepeatScreenModalRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + RepeatScreenModalRenderer.enableHotReload(); +} + diff --git a/frontend/lib/registry/components/repeat-screen-modal/index.ts b/frontend/lib/registry/components/repeat-screen-modal/index.ts new file mode 100644 index 00000000..d8c72356 --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/index.ts @@ -0,0 +1,52 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent"; +import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel"; + +/** + * RepeatScreenModal 컴포넌트 정의 + * 반복 화면 모달 - 카드/테이블 형태로 데이터를 표시하고 편집할 수 있는 모달 + */ +export const RepeatScreenModalDefinition = createComponentDefinition({ + id: "repeat-screen-modal", + name: "반복 화면 모달", + nameEng: "Repeat Screen Modal", + description: "카드/테이블 형태로 데이터를 반복 표시하고 편집할 수 있는 모달 컴포넌트", + category: ComponentCategory.DATA, + webType: "form", + component: RepeatScreenModalComponent, + defaultConfig: { + cardMode: "simple", + cardSpacing: "24px", + cardTitle: "", + contentRows: [], + dataSource: { + filterField: "", + sourceTable: "", + }, + grouping: { + enabled: false, + aggregations: [], + }, + saveMode: "all", + showCardBorder: true, + showCardTitle: true, + tableLayout: { + headerRows: [], + tableColumns: [], + }, + }, + defaultSize: { width: 1000, height: 800 }, + configPanel: RepeatScreenModalConfigPanel, + icon: "LayoutGrid", + tags: ["모달", "반복", "카드", "테이블", "폼"], + version: "1.0.0", + author: "개발팀", +}); + +// 컴포넌트 내보내기 +export { RepeatScreenModalComponent } from "./RepeatScreenModalComponent"; +export { RepeatScreenModalRenderer } from "./RepeatScreenModalRenderer"; + diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3dcdf6a0..5a75170c 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -23,7 +23,10 @@ export type ButtonActionType = | "excel_download" // 엑셀 다운로드 | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 - | "code_merge"; // 코드 병합 + | "code_merge" // 코드 병합 + | "geolocation" // 위치정보 가져오기 + | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) + | "update_field"; // 특정 필드 값 변경 (예: status를 active로) /** * 버튼 액션 설정 @@ -90,6 +93,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; // 편집 모달 제목 @@ -199,6 +230,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; @@ -3041,6 +3078,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; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -3144,4 +3487,21 @@ export const DEFAULT_BUTTON_ACTIONS: Record Date: Mon, 1 Dec 2025 10:14:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20company=5Fcode=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A0=91=EA=B7=BC=20=EA=B6=8C=ED=95=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) 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(