From c657d6f7a05537842e6efc0d85c49bfdca94618c Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 1 Dec 2025 10:32:12 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=B6=9C=EB=B0=9C=EC=A7=80=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocationSwapSelectorComponent.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 045d62bd..732deb8b 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -115,11 +115,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) // 코드 관리에서 가져오기 setLoading(true); try { - const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`); + const response = await apiClient.get(`/code-management/codes`, { + params: { categoryCode: 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, + value: code.code_value || code.codeValue || code.code, + label: code.code_name || code.codeName || code.name, })); setOptions(codeOptions); } @@ -135,13 +137,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) // 테이블에서 가져오기 setLoading(true); try { - const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, { - params: { pageSize: 1000 }, + const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, { + params: { page: 1, 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"], + // data가 배열인지 또는 data.rows인지 확인 + const rows = Array.isArray(response.data.data) + ? response.data.data + : response.data.data.rows || []; + const tableOptions = rows.map((row: any) => ({ + value: String(row[dataSource.valueField || "id"] || ""), + label: String(row[dataSource.labelField || "name"] || ""), })); setOptions(tableOptions); } From d7ee63a85782d8dda1ab5e3d555112b175ab1e71 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 1 Dec 2025 11:07:16 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=EC=B6=9C=EB=B0=9C=EC=A7=80=20=EB=AA=A9?= =?UTF-8?q?=EC=A0=81=EC=A7=80=20=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../divider-line/DividerLineComponent.tsx | 35 +++- .../LocationSwapSelectorComponent.tsx | 152 +++++++++++------- .../LocationSwapSelectorConfigPanel.tsx | 76 ++++++++- .../LocationSwapSelectorRenderer.tsx | 23 ++- .../location-swap-selector/index.ts | 7 +- frontend/lib/utils/buttonActions.ts | 56 +++++++ 6 files changed, 284 insertions(+), 65 deletions(-) diff --git a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx index 5cc4fcfd..d2b61c90 100644 --- a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx +++ b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx @@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC = ({ }; // DOM에 전달하면 안 되는 React-specific props 필터링 + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { selectedScreen, onZoneComponentDrop, @@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC = ({ tableName: _tableName, onRefresh: _onRefresh, onClose: _onClose, + // 추가된 props 필터링 + webType: _webType, + autoGeneration: _autoGeneration, + isInteractive: _isInteractive, + formData: _formData, + onFormDataChange: _onFormDataChange, + menuId: _menuId, + menuObjid: _menuObjid, + onSave: _onSave, + userId: _userId, + userName: _userName, + companyCode: _companyCode, + isInModal: _isInModal, + readonly: _readonly, + originalData: _originalData, + allComponents: _allComponents, + onUpdateLayout: _onUpdateLayout, + selectedRows: _selectedRows, + selectedRowsData: _selectedRowsData, + onSelectedRowsChange: _onSelectedRowsChange, + sortBy: _sortBy, + sortOrder: _sortOrder, + tableDisplayData: _tableDisplayData, + flowSelectedData: _flowSelectedData, + flowSelectedStepId: _flowSelectedStepId, + onFlowSelectedDataChange: _onFlowSelectedDataChange, + onConfigChange: _onConfigChange, + refreshKey: _refreshKey, + flowRefreshKey: _flowRefreshKey, + onFlowRefresh: _onFlowRefresh, + isPreview: _isPreview, + groupedData: _groupedData, ...domProps - } = props; + } = props as any; return (
diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 732deb8b..02f4e436 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -103,11 +103,36 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) const departureValue = formData[departureField] || ""; const destinationValue = formData[destinationField] || ""; + // 기본 옵션 (포항/광양) + const DEFAULT_OPTIONS: LocationOption[] = [ + { value: "pohang", label: "포항" }, + { value: "gwangyang", label: "광양" }, + ]; + // 옵션 로드 useEffect(() => { const loadOptions = async () => { - if (dataSource.type === "static") { - setOptions(dataSource.staticOptions || []); + console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode }); + + // 정적 옵션 처리 (기본값) + // type이 없거나 static이거나, table인데 tableName이 없는 경우 + const shouldUseStatic = + !dataSource.type || + dataSource.type === "static" || + (dataSource.type === "table" && !dataSource.tableName) || + (dataSource.type === "code" && !dataSource.codeCategory); + + if (shouldUseStatic) { + const staticOpts = dataSource.staticOptions || []; + // 정적 옵션이 설정되어 있으면 사용 + if (staticOpts.length > 0 && staticOpts[0]?.value) { + console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts); + setOptions(staticOpts); + } else { + // 기본값 (포항/광양) + console.log("[LocationSwapSelector] 기본 옵션 사용:", DEFAULT_OPTIONS); + setOptions(DEFAULT_OPTIONS); + } return; } @@ -159,17 +184,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) } }; - if (!isDesignMode) { - loadOptions(); - } else { - // 디자인 모드에서는 샘플 데이터 - setOptions([ - { value: "seoul", label: "서울" }, - { value: "busan", label: "부산" }, - { value: "pohang", label: "포항" }, - { value: "gwangyang", label: "광양" }, - ]); - } + loadOptions(); }, [dataSource, isDesignMode]); // 출발지 변경 @@ -250,7 +265,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
@@ -276,7 +295,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) variant="ghost" size="icon" onClick={handleSwap} - disabled={isDesignMode || !departureValue || !destinationValue} className={cn( "mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted", isSwapping && "rotate-180" @@ -292,7 +310,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) @@ -328,17 +350,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) @@ -349,7 +375,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) variant="outline" size="icon" onClick={handleSwap} - disabled={isDesignMode} className="mt-5 h-10 w-10" > @@ -361,17 +386,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) @@ -389,17 +418,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) @@ -409,7 +442,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) variant="ghost" size="sm" onClick={handleSwap} - disabled={isDesignMode} className="h-8 w-8 p-0" > @@ -419,17 +451,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx index c18f6514..518b6172 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx @@ -139,13 +139,83 @@ export function LocationSwapSelectorConfigPanel({ - 정적 옵션 (하드코딩) - 테이블 - 코드 관리 + 고정 옵션 (포항/광양 등) + 테이블에서 가져오기 + 코드 관리에서 가져오기 + {/* 고정 옵션 설정 (type이 static일 때) */} + {(!config?.dataSource?.type || config?.dataSource?.type === "static") && ( +
+

고정 옵션 설정

+
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[0] = { ...newOptions[0], value: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: pohang" + className="h-8 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[0] = { ...newOptions[0], label: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: 포항" + className="h-8 text-xs" + /> +
+
+
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[1] = { ...newOptions[1], value: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: gwangyang" + className="h-8 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[1] = { ...newOptions[1], label: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: 광양" + className="h-8 text-xs" + /> +
+
+

+ 고정된 2개 장소만 사용할 때 설정하세요. (예: 포항 ↔ 광양) +

+
+ )} + {/* 테이블 선택 (type이 table일 때) */} {config?.dataSource?.type === "table" && ( <> diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx index 8e3fe5f7..6adc4724 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx @@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender static componentDefinition = LocationSwapSelectorDefinition; render(): React.ReactElement { - return ; + const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props; + + // component.componentConfig에서 설정 가져오기 + const componentConfig = component?.componentConfig || {}; + + console.log("[LocationSwapSelectorRenderer] render:", { + componentConfig, + formData, + isDesignMode + }); + + return ( + + ); } } diff --git a/frontend/lib/registry/components/location-swap-selector/index.ts b/frontend/lib/registry/components/location-swap-selector/index.ts index 60b62008..c4c30418 100644 --- a/frontend/lib/registry/components/location-swap-selector/index.ts +++ b/frontend/lib/registry/components/location-swap-selector/index.ts @@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({ defaultConfig: { // 데이터 소스 설정 dataSource: { - type: "table", // "table" | "code" | "static" + type: "static", // "table" | "code" | "static" tableName: "", // 장소 테이블명 valueField: "location_code", // 값 필드 labelField: "location_name", // 표시 필드 codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) - staticOptions: [], // 정적 옵션 (type이 "static"일 때) + staticOptions: [ + { value: "pohang", label: "포항" }, + { value: "gwangyang", label: "광양" }, + ], // 정적 옵션 (type이 "static"일 때) }, // 필드 매핑 departureField: "departure", // 출발지 저장 필드 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index cf53a490..537a6e7d 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -271,6 +271,9 @@ export class ButtonActionExecutor { case "geolocation": return await this.handleGeolocation(config, context); + case "swap_fields": + return await this.handleSwapFields(config, context); + case "update_field": return await this.handleUpdateField(config, context); @@ -3412,6 +3415,59 @@ export class ButtonActionExecutor { } } + /** + * 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지) + */ + private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("🔄 필드 값 교환 액션 실행:", { config, context }); + + const { formData, onFormDataChange } = context; + + // 교환할 필드 확인 + const fieldA = config.swapFieldA; + const fieldB = config.swapFieldB; + + if (!fieldA || !fieldB) { + toast.error("교환할 필드가 설정되지 않았습니다."); + return false; + } + + // 현재 값 가져오기 + const valueA = formData?.[fieldA]; + const valueB = formData?.[fieldB]; + + console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB }); + + // 값 교환 + if (onFormDataChange) { + onFormDataChange(fieldA, valueB); + onFormDataChange(fieldB, valueA); + } + + // 관련 필드도 함께 교환 (예: 위도/경도) + if (config.swapRelatedFields && config.swapRelatedFields.length > 0) { + for (const related of config.swapRelatedFields) { + const relatedValueA = formData?.[related.fieldA]; + const relatedValueB = formData?.[related.fieldB]; + if (onFormDataChange) { + onFormDataChange(related.fieldA, relatedValueB); + onFormDataChange(related.fieldB, relatedValueA); + } + } + } + + console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA }); + + toast.success(config.successMessage || "값이 교환되었습니다."); + return true; + } catch (error) { + console.error("❌ 필드 값 교환 오류:", error); + toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다."); + return false; + } + } + /** * 필드 값 변경 액션 처리 (예: status를 active로 변경) */ From fd7a1bbf53d4d1493edb5c0d4a88f395c3565fd9 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 1 Dec 2025 12:27:24 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EC=B6=9C=EB=B0=9C=EC=A7=80=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=EC=A7=80=20=EC=84=A0=ED=83=9D=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EA=B5=90=ED=99=98=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=9E=91=EB=8F=99=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocationSwapSelectorComponent.tsx | 268 ++++++++++-------- 1 file changed, 148 insertions(+), 120 deletions(-) diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 02f4e436..53fd0c0c 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -94,21 +94,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) 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] || ""; - // 기본 옵션 (포항/광양) const DEFAULT_OPTIONS: LocationOption[] = [ { value: "pohang", label: "포항" }, { value: "gwangyang", label: "광양" }, ]; + // 상태 + const [options, setOptions] = useState(DEFAULT_OPTIONS); + const [loading, setLoading] = useState(false); + const [isSwapping, setIsSwapping] = useState(false); + + // 로컬 선택 상태 (Select 컴포넌트용) + const [localDeparture, setLocalDeparture] = useState(""); + const [localDestination, setLocalDestination] = useState(""); + // 옵션 로드 useEffect(() => { const loadOptions = async () => { @@ -124,13 +124,19 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) if (shouldUseStatic) { const staticOpts = dataSource.staticOptions || []; - // 정적 옵션이 설정되어 있으면 사용 - if (staticOpts.length > 0 && staticOpts[0]?.value) { + // 정적 옵션이 설정되어 있고, value가 유효한 경우 사용 + // (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용) + const isValidOptions = staticOpts.length > 0 && + staticOpts[0]?.value && + staticOpts[0].value !== departureField && + staticOpts[0].value !== destinationField; + + if (isValidOptions) { console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts); setOptions(staticOpts); } else { // 기본값 (포항/광양) - console.log("[LocationSwapSelector] 기본 옵션 사용:", DEFAULT_OPTIONS); + console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS }); setOptions(DEFAULT_OPTIONS); } return; @@ -187,69 +193,109 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) loadOptions(); }, [dataSource, isDesignMode]); + // formData에서 초기값 동기화 + useEffect(() => { + const depVal = formData[departureField]; + const destVal = formData[destinationField]; + + if (depVal && options.some(o => o.value === depVal)) { + setLocalDeparture(depVal); + } + if (destVal && options.some(o => o.value === destVal)) { + setLocalDestination(destVal); + } + }, [formData, departureField, destinationField, options]); + // 출발지 변경 - const handleDepartureChange = (value: string) => { + const handleDepartureChange = (selectedValue: string) => { + console.log("[LocationSwapSelector] 출발지 변경:", { selectedValue, options }); + + // 로컬 상태 업데이트 + setLocalDeparture(selectedValue); + + // 부모에게 전달 if (onFormDataChange) { - onFormDataChange(departureField, value); + onFormDataChange(departureField, selectedValue); // 라벨 필드도 업데이트 if (departureLabelField) { - const selectedOption = options.find((opt) => opt.value === value); - onFormDataChange(departureLabelField, selectedOption?.label || ""); + const selectedOption = options.find((opt) => opt.value === selectedValue); + if (selectedOption) { + onFormDataChange(departureLabelField, selectedOption.label); + } } } }; // 도착지 변경 - const handleDestinationChange = (value: string) => { + const handleDestinationChange = (selectedValue: string) => { + console.log("[LocationSwapSelector] 도착지 변경:", { selectedValue, options }); + + // 로컬 상태 업데이트 + setLocalDestination(selectedValue); + + // 부모에게 전달 if (onFormDataChange) { - onFormDataChange(destinationField, value); + onFormDataChange(destinationField, selectedValue); // 라벨 필드도 업데이트 if (destinationLabelField) { - const selectedOption = options.find((opt) => opt.value === value); - onFormDataChange(destinationLabelField, selectedOption?.label || ""); + const selectedOption = options.find((opt) => opt.value === selectedValue); + if (selectedOption) { + onFormDataChange(destinationLabelField, selectedOption.label); + } } } }; // 출발지/도착지 교환 const handleSwap = () => { - if (!onFormDataChange) return; - setIsSwapping(true); - // 값 교환 - const tempDeparture = departureValue; - const tempDestination = destinationValue; + // 로컬 상태 교환 + const tempDeparture = localDeparture; + const tempDestination = localDestination; + + setLocalDeparture(tempDestination); + setLocalDestination(tempDeparture); - onFormDataChange(departureField, tempDestination); - onFormDataChange(destinationField, tempDeparture); + // 부모에게 전달 + if (onFormDataChange) { + onFormDataChange(departureField, tempDestination); + onFormDataChange(destinationField, tempDeparture); - // 라벨도 교환 - if (departureLabelField && destinationLabelField) { - const tempDepartureLabel = formData[departureLabelField]; - const tempDestinationLabel = formData[destinationLabelField]; - onFormDataChange(departureLabelField, tempDestinationLabel); - onFormDataChange(destinationLabelField, tempDepartureLabel); + // 라벨도 교환 + if (departureLabelField && destinationLabelField) { + const depOption = options.find(o => o.value === tempDestination); + const destOption = options.find(o => o.value === tempDeparture); + onFormDataChange(departureLabelField, depOption?.label || ""); + onFormDataChange(destinationLabelField, destOption?.label || ""); + } } // 애니메이션 효과 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 || {}; + // 선택된 라벨 가져오기 + const getDepartureLabel = () => { + const opt = options.find(o => o.value === localDeparture); + return opt?.label || ""; + }; + + const getDestinationLabel = () => { + const opt = options.find(o => o.value === localDestination); + return opt?.label || ""; + }; + + // 디버그 로그 + console.log("[LocationSwapSelector] 렌더:", { + localDeparture, + localDestination, + options: options.map(o => `${o.value}:${o.label}`), + }); + // Card 스타일 (이미지 참고) if (variant === "card") { return ( @@ -263,27 +309,26 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
{departureLabel}
@@ -308,27 +353,26 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
{destinationLabel}
@@ -348,23 +392,19 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
@@ -384,23 +424,19 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
@@ -416,23 +452,19 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) style={restStyle} > @@ -449,23 +481,19 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) )} From 8d2ec8e737cdcd13d22192284f29f98491bfe1db Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 1 Dec 2025 15:23:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EA=B3=B5=EC=B0=A8=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EC=84=B1=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/dynamicFormRoutes.ts | 2 +- .../src/services/dynamicFormService.ts | 49 ++- .../config-panels/ButtonConfigPanel.tsx | 379 +++++++++++++++++- .../LocationSwapSelectorConfigPanel.tsx | 21 +- frontend/lib/utils/buttonActions.ts | 339 +++++++++++++++- 5 files changed, 751 insertions(+), 39 deletions(-) diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 21140617..dae17283 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -22,9 +22,9 @@ router.use(authenticateToken); // 폼 데이터 CRUD router.post("/save", saveFormData); // 기존 버전 (레거시 지원) router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 +router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언! 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 11648577..7d03f257 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1662,12 +1662,47 @@ export class DynamicFormService { companyCode, }); - // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외) - let whereClause = `"${keyField}" = $1`; - const params: any[] = [keyValue, updateValue, userId]; - let paramIndex = 4; + // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인) + const columnQuery = ` + SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code') + `; + const columnResult = await client.query(columnQuery, [tableName]); + const existingColumns = columnResult.rows.map((row: any) => row.column_name); + + const hasUpdatedBy = existingColumns.includes('updated_by'); + const hasUpdatedAt = existingColumns.includes('updated_at'); + const hasCompanyCode = existingColumns.includes('company_code'); - if (companyCode && companyCode !== "*") { + console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", { + hasUpdatedBy, + hasUpdatedAt, + hasCompanyCode, + }); + + // 동적 SET 절 구성 + let setClause = `"${updateField}" = $1`; + const params: any[] = [updateValue]; + let paramIndex = 2; + + if (hasUpdatedBy) { + setClause += `, updated_by = $${paramIndex}`; + params.push(userId); + paramIndex++; + } + + if (hasUpdatedAt) { + setClause += `, updated_at = NOW()`; + } + + // WHERE 절 구성 + let whereClause = `"${keyField}" = $${paramIndex}`; + params.push(keyValue); + paramIndex++; + + // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만) + if (hasCompanyCode && companyCode && companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; @@ -1675,9 +1710,7 @@ export class DynamicFormService { const sqlQuery = ` UPDATE "${tableName}" - SET "${updateField}" = $2, - updated_by = $3, - updated_at = NOW() + SET ${setClause} WHERE ${whereClause} `; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 1a4a9608..5c9360c8 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1774,6 +1774,255 @@ export const ButtonConfigPanel: React.FC = ({ /> + {/* 첫 번째 추가 테이블 설정 (위치정보와 함께 상태 변경) */} +
+
+
+ +

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

+
+ onUpdateProperty("componentConfig.action.geolocationUpdateField", checked)} + /> +
+ + {config.action?.geolocationUpdateField && ( +
+
+ + +
+
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraKeyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+
+ )} +
+ + {/* 두 번째 추가 테이블 설정 */} +
+
+
+ +

두 번째 테이블의 필드 값도 함께 변경합니다

+
+ onUpdateProperty("componentConfig.action.geolocationSecondTableEnabled", checked)} + /> +
+ + {config.action?.geolocationSecondTableEnabled && ( +
+
+
+ + +
+
+ + +
+
+
+
+ + onUpdateProperty("componentConfig.action.geolocationSecondField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationSecondValue", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+
+ + onUpdateProperty("componentConfig.action.geolocationSecondKeyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + {config.action?.geolocationSecondMode === "insert" && ( +
+
+ +

위도/경도를 이 테이블에도 저장

+
+ onUpdateProperty("componentConfig.action.geolocationSecondInsertFields", { + ...config.action?.geolocationSecondInsertFields, + includeLocation: checked + })} + /> +
+ )} + +

+ {config.action?.geolocationSecondMode === "insert" + ? "새 레코드를 생성합니다. 연결 필드로 현재 폼 데이터와 연결됩니다." + : "기존 레코드를 수정합니다. 키 필드로 레코드를 찾아 값을 변경합니다."} +

+
+ )} +
+

사용 방법: @@ -1784,6 +2033,11 @@ export const ButtonConfigPanel: React.FC = ({
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
+ 4. 추가 테이블 설정이 있으면 해당 테이블의 필드도 함께 변경됩니다 +
+
+ 예시: 위치정보 저장 + vehicles.status를 inactive로 변경 +

참고: HTTPS 환경에서만 위치정보가 작동합니다.

@@ -1852,6 +2106,62 @@ export const ButtonConfigPanel: React.FC = ({
+ {/* 🆕 키 필드 설정 (레코드 식별용) */} +
+
레코드 식별 설정
+
+
+ + onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)} + className="h-8 text-xs" + /> +

레코드를 찾을 DB 컬럼명

+
+
+ + +

키 값을 가져올 소스

+
+
+
+
@@ -1899,15 +2209,78 @@ export const ButtonConfigPanel: React.FC = ({
+ {/* 위치정보 수집 옵션 */} +
+
+
+ +

상태 변경과 함께 현재 GPS 좌표를 수집합니다

+
+ onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)} + /> +
+ + {config.action?.updateWithGeolocation && ( +
+
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)} + className="h-8 text-xs" + /> +
+
+

+ 버튼 클릭 시 GPS 위치를 수집하여 위 필드에 저장합니다. +

+
+ )} +
+

사용 예시:
- - 운행알림 버튼: status 필드를 "active"로 변경 + - 운행알림 버튼: status를 "active"로 + 위치정보 수집
- - 승인 버튼: approval_status 필드를 "approved"로 변경 + - 출발 버튼: status를 "inactive"로 + 위치정보 수집
- - 완료 버튼: is_completed 필드를 "Y"로 변경 + - 완료 버튼: is_completed를 "Y"로 변경

diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx index 518b6172..4e21cddf 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx @@ -90,7 +90,7 @@ export function LocationSwapSelectorConfigPanel({ } }, [config?.dataSource?.tableName, config?.dataSource?.type]); - // 코드 카테고리 로드 + // 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시) useEffect(() => { const loadCodeCategories = async () => { try { @@ -103,8 +103,11 @@ export function LocationSwapSelectorConfigPanel({ })) ); } - } catch (error) { - console.error("코드 카테고리 로드 실패:", error); + } catch (error: any) { + // 404는 API가 없는 것이므로 무시 + if (error?.response?.status !== 404) { + console.error("코드 카테고리 로드 실패:", error); + } } }; loadCodeCategories(); @@ -368,14 +371,14 @@ export function LocationSwapSelectorConfigPanel({ {tableColumns.length > 0 ? ( handleChange("destinationLabelField", value)} + value={config?.destinationLabelField || "__none__"} + onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)} > - 없음 + 없음 {tableColumns.map((col) => ( {col.columnLabel || col.columnName} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 537a6e7d..fe544706 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -110,6 +110,16 @@ export interface ButtonActionConfig { geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active") geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") + + // 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에) + geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부 + geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles") + geolocationSecondMode?: "update" | "insert"; // 작업 모드 (기본: update) + geolocationSecondField?: string; // 두 번째 테이블에서 변경할 필드명 (예: "status") + geolocationSecondValue?: string | number | boolean; // 두 번째 테이블에서 변경할 값 (예: "inactive") + geolocationSecondKeyField?: string; // 두 번째 테이블의 키 필드 (예: "id") - UPDATE 모드에서만 사용 + geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용 + geolocationSecondInsertFields?: Record; // INSERT 모드에서 추가로 넣을 필드들 // 필드 값 교환 관련 (출발지 ↔ 목적지) swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure") @@ -121,6 +131,13 @@ export interface ButtonActionConfig { updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active") updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true) updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경 + + // 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용) + updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부 + updateGeolocationLatField?: string; // 위도 저장 필드 + updateGeolocationLngField?: string; // 경도 저장 필드 + updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택) + updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택) // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) editMode?: "modal" | "navigate" | "inline"; // 편집 모드 @@ -217,6 +234,44 @@ export interface ButtonActionContext { componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 } +/** + * 🆕 특수 키워드를 실제 값으로 변환하는 헬퍼 함수 + * 지원하는 키워드: + * - __userId__ : 로그인한 사용자 ID + * - __userName__ : 로그인한 사용자 이름 + * - __companyCode__ : 로그인한 사용자의 회사 코드 + * - __screenId__ : 현재 화면 ID + * - __tableName__ : 현재 테이블명 + */ +export function resolveSpecialKeyword( + sourceField: string | undefined, + context: ButtonActionContext +): any { + if (!sourceField) return undefined; + + // 특수 키워드 처리 + switch (sourceField) { + case "__userId__": + console.log("🔑 특수 키워드 변환: __userId__ →", context.userId); + return context.userId; + case "__userName__": + console.log("🔑 특수 키워드 변환: __userName__ →", context.userName); + return context.userName; + case "__companyCode__": + console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode); + return context.companyCode; + case "__screenId__": + console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId); + return context.screenId; + case "__tableName__": + console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName); + return context.tableName; + default: + // 일반 폼 데이터에서 가져오기 + return context.formData?.[sourceField]; + } +} + /** * 버튼 액션 실행기 */ @@ -3236,6 +3291,14 @@ export class ButtonActionExecutor { private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); + console.log("📍 [디버그] 추가 필드 설정값:", { + geolocationUpdateField: config.geolocationUpdateField, + geolocationExtraField: config.geolocationExtraField, + geolocationExtraValue: config.geolocationExtraValue, + geolocationExtraTableName: config.geolocationExtraTableName, + geolocationExtraKeyField: config.geolocationExtraKeyField, + geolocationExtraKeySourceField: config.geolocationExtraKeySourceField, + }); // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { @@ -3296,26 +3359,35 @@ export class ButtonActionExecutor { // 🆕 추가 필드 변경 (위치정보 + 상태변경) let extraTableUpdated = false; + let secondTableUpdated = false; + if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) { - const extraTableName = config.geolocationExtraTableName; + const extraTableName = config.geolocationExtraTableName || context.tableName; // 🆕 대상 테이블이 없으면 현재 테이블 사용 const currentTableName = config.geolocationTableName || context.tableName; + const keySourceField = config.geolocationExtraKeySourceField; - // 다른 테이블에 UPDATE하는 경우 - if (extraTableName && extraTableName !== currentTableName) { - console.log("📍 다른 테이블 필드 변경:", { + // 🆕 특수 키워드가 설정되어 있으면 바로 DB UPDATE (같은 테이블이어도) + const hasSpecialKeyword = keySourceField?.startsWith("__") && keySourceField?.endsWith("__"); + const isDifferentTable = extraTableName && extraTableName !== currentTableName; + + // 다른 테이블이거나 특수 키워드가 설정된 경우 → 바로 DB UPDATE + if (isDifferentTable || hasSpecialKeyword) { + console.log("📍 DB 직접 UPDATE:", { targetTable: extraTableName, field: config.geolocationExtraField, value: config.geolocationExtraValue, keyField: config.geolocationExtraKeyField, - keySourceField: config.geolocationExtraKeySourceField, + keySourceField: keySourceField, + hasSpecialKeyword, + isDifferentTable, }); - // 키 값 가져오기 - const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""]; + // 키 값 가져오기 (특수 키워드 지원) + const keyValue = resolveSpecialKeyword(keySourceField, context); if (keyValue && config.geolocationExtraKeyField) { try { - // 다른 테이블 UPDATE API 호출 + // DB UPDATE API 호출 const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.put(`/dynamic-form/update-field`, { tableName: extraTableName, @@ -3327,30 +3399,131 @@ export class ButtonActionExecutor { if (response.data?.success) { extraTableUpdated = true; - console.log("✅ 다른 테이블 UPDATE 성공:", response.data); + console.log("✅ DB UPDATE 성공:", response.data); } else { - console.error("❌ 다른 테이블 UPDATE 실패:", response.data); + console.error("❌ DB UPDATE 실패:", response.data); toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`); } } catch (apiError) { - console.error("❌ 다른 테이블 UPDATE API 오류:", apiError); + console.error("❌ DB UPDATE API 오류:", apiError); toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`); } } else { - console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", { - keySourceField: config.geolocationExtraKeySourceField, + console.warn("⚠️ 키 값이 없어서 DB UPDATE를 건너뜁니다:", { + keySourceField: keySourceField, keyValue, }); } } else { - // 같은 테이블 (현재 폼 데이터에 추가) + // 같은 테이블이고 특수 키워드가 없는 경우 (현재 폼 데이터에 추가) updates[config.geolocationExtraField] = config.geolocationExtraValue; - console.log("📍 같은 테이블 추가 필드 변경:", { + console.log("📍 같은 테이블 추가 필드 변경 (폼 데이터):", { field: config.geolocationExtraField, value: config.geolocationExtraValue, }); } } + + // 🆕 두 번째 테이블 INSERT 또는 UPDATE + if (config.geolocationSecondTableEnabled && + config.geolocationSecondTableName) { + + const secondMode = config.geolocationSecondMode || "update"; + + console.log("📍 두 번째 테이블 작업:", { + mode: secondMode, + targetTable: config.geolocationSecondTableName, + field: config.geolocationSecondField, + value: config.geolocationSecondValue, + keyField: config.geolocationSecondKeyField, + keySourceField: config.geolocationSecondKeySourceField, + }); + + try { + const { apiClient } = await import("@/lib/api/client"); + + if (secondMode === "insert") { + // INSERT 모드: 새 레코드 생성 + const insertData: Record = { + // 위치정보 포함 (선택적) + ...(config.geolocationSecondInsertFields || {}), + }; + + // 기본 필드 추가 + if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) { + insertData[config.geolocationSecondField] = config.geolocationSecondValue; + } + + // 위치정보도 두 번째 테이블에 저장하려면 추가 + // (선택적으로 위도/경도도 저장) + if (config.geolocationSecondInsertFields?.includeLocation) { + insertData[latField] = latitude; + insertData[lngField] = longitude; + if (config.geolocationAccuracyField) { + insertData[config.geolocationAccuracyField] = accuracy; + } + if (config.geolocationTimestampField) { + insertData[config.geolocationTimestampField] = timestamp.toISOString(); + } + } + + // 현재 폼에서 키 값 가져와서 연결 (외래키) - 특수 키워드 지원 + if (config.geolocationSecondKeySourceField && config.geolocationSecondKeyField) { + const keyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context); + if (keyValue) { + insertData[config.geolocationSecondKeyField] = keyValue; + } + } + + console.log("📍 두 번째 테이블 INSERT 데이터:", insertData); + + const response = await apiClient.post(`/dynamic-form/save`, { + tableName: config.geolocationSecondTableName, + data: insertData, + }); + + if (response.data?.success) { + secondTableUpdated = true; + console.log("✅ 두 번째 테이블 INSERT 성공:", response.data); + } else { + console.error("❌ 두 번째 테이블 INSERT 실패:", response.data); + toast.error(`${config.geolocationSecondTableName} 테이블 저장에 실패했습니다.`); + } + } else { + // UPDATE 모드: 기존 레코드 수정 + if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) { + // 특수 키워드 지원 + const secondKeyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context); + + if (secondKeyValue && config.geolocationSecondKeyField) { + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: config.geolocationSecondTableName, + keyField: config.geolocationSecondKeyField, + keyValue: secondKeyValue, + updateField: config.geolocationSecondField, + updateValue: config.geolocationSecondValue, + }); + + if (response.data?.success) { + secondTableUpdated = true; + console.log("✅ 두 번째 테이블 UPDATE 성공:", response.data); + } else { + console.error("❌ 두 번째 테이블 UPDATE 실패:", response.data); + toast.error(`${config.geolocationSecondTableName} 테이블 업데이트에 실패했습니다.`); + } + } else { + console.warn("⚠️ 두 번째 테이블 키 값이 없어서 UPDATE를 건너뜁니다:", { + keySourceField: config.geolocationSecondKeySourceField, + keyValue: secondKeyValue, + }); + } + } + } + } catch (apiError) { + console.error("❌ 두 번째 테이블 API 오류:", apiError); + toast.error(`${config.geolocationSecondTableName} 테이블 작업 중 오류가 발생했습니다.`); + } + } // formData 업데이트 if (context.onFormDataChange) { @@ -3371,6 +3544,11 @@ export class ButtonActionExecutor { successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`; } } + + // 두 번째 테이블 변경이 있으면 메시지에 포함 + if (secondTableUpdated && config.geolocationSecondTableName) { + successMsg += `\n[${config.geolocationSecondTableName}] ${config.geolocationSecondField}: ${config.geolocationSecondValue}`; + } // 성공 메시지 표시 toast.success(successMsg); @@ -3470,6 +3648,7 @@ export class ButtonActionExecutor { /** * 필드 값 변경 액션 처리 (예: status를 active로 변경) + * 🆕 위치정보 수집 기능 추가 */ private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { @@ -3483,7 +3662,7 @@ export class ButtonActionExecutor { const multipleFields = config.updateMultipleFields || []; // 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함 - if (!targetField && multipleFields.length === 0) { + if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) { toast.error("변경할 필드가 설정되지 않았습니다."); return false; } @@ -3510,6 +3689,69 @@ export class ButtonActionExecutor { updates[field] = value; }); + // 🆕 위치정보 수집 (updateWithGeolocation이 true인 경우) + if (config.updateWithGeolocation) { + const latField = config.updateGeolocationLatField; + const lngField = config.updateGeolocationLngField; + + if (!latField || !lngField) { + toast.error("위도/경도 저장 필드가 설정되지 않았습니다."); + return false; + } + + // 브라우저 Geolocation API 지원 확인 + if (!navigator.geolocation) { + toast.error("이 브라우저는 위치정보를 지원하지 않습니다."); + return false; + } + + // 로딩 토스트 표시 + const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); + + try { + // 위치 정보 가져오기 + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + }); + }); + + toast.dismiss(loadingToastId); + + const { latitude, longitude, accuracy } = position.coords; + const timestamp = new Date(position.timestamp); + + console.log("📍 위치정보 획득:", { latitude, longitude, accuracy }); + + // 위치정보를 updates에 추가 + updates[latField] = latitude; + updates[lngField] = longitude; + + if (config.updateGeolocationAccuracyField && accuracy !== null) { + updates[config.updateGeolocationAccuracyField] = accuracy; + } + if (config.updateGeolocationTimestampField) { + updates[config.updateGeolocationTimestampField] = timestamp.toISOString(); + } + } catch (geoError: any) { + toast.dismiss(loadingToastId); + + // GeolocationPositionError 처리 + if (geoError.code === 1) { + toast.error("위치 정보 접근이 거부되었습니다."); + } else if (geoError.code === 2) { + toast.error("위치 정보를 사용할 수 없습니다."); + } else if (geoError.code === 3) { + toast.error("위치 정보 요청 시간이 초과되었습니다."); + } else { + toast.error("위치 정보를 가져오는 중 오류가 발생했습니다."); + } + return false; + } + } + console.log("🔄 변경할 필드들:", updates); // formData 업데이트 @@ -3523,6 +3765,67 @@ export class ButtonActionExecutor { const autoSave = config.updateAutoSave !== false; if (autoSave) { + // 🆕 키 필드 설정이 있는 경우 (특수 키워드 지원) - 직접 DB UPDATE + const keyField = config.updateKeyField; + const keySourceField = config.updateKeySourceField; + const targetTableName = config.updateTableName || tableName; + + if (keyField && keySourceField) { + // 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID) + const keyValue = resolveSpecialKeyword(keySourceField, context); + + console.log("🔄 필드 값 변경 - 키 필드 사용:", { + targetTable: targetTableName, + keyField, + keySourceField, + keyValue, + updates, + }); + + if (!keyValue) { + console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue }); + toast.error("레코드를 식별할 키 값이 없습니다."); + return false; + } + + try { + // 각 필드에 대해 개별 UPDATE 호출 + const { apiClient } = await import("@/lib/api/client"); + + for (const [field, value] of Object.entries(updates)) { + console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`); + + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: targetTableName, + keyField: keyField, + keyValue: keyValue, + updateField: field, + updateValue: value, + }); + + if (!response.data?.success) { + console.error(`❌ ${field} 업데이트 실패:`, response.data); + toast.error(`${field} 업데이트에 실패했습니다.`); + return false; + } + } + + console.log("✅ 모든 필드 업데이트 성공"); + toast.success(config.successMessage || "상태가 변경되었습니다."); + + // 테이블 새로고침 이벤트 발생 + window.dispatchEvent(new CustomEvent("refreshTableData", { + detail: { tableName: targetTableName } + })); + + return true; + } catch (apiError) { + console.error("❌ 필드 값 변경 API 호출 실패:", apiError); + toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다."); + return false; + } + } + // onSave 콜백이 있으면 사용 if (onSave) { console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)"); @@ -3537,7 +3840,7 @@ export class ButtonActionExecutor { } } - // API를 통한 직접 저장 + // API를 통한 직접 저장 (기존 방식: formData에 PK가 있는 경우) if (tableName && formData) { console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)"); try { @@ -3546,7 +3849,7 @@ export class ButtonActionExecutor { const pkValue = formData[pkField] || formData.id; if (!pkValue) { - toast.error("레코드 ID를 찾을 수 없습니다."); + toast.error("레코드 ID를 찾을 수 없습니다. 키 필드를 설정해주세요."); return false; } From 6545410d49bbdba4395bc89f14f2402d3b389d2f Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 1 Dec 2025 15:42:40 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=EA=B3=B5=EC=B0=A8=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocationSwapSelectorComponent.tsx | 22 ++++- frontend/lib/utils/buttonActions.ts | 87 +++++++++++++++++-- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 53fd0c0c..5dc4a165 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -208,41 +208,59 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) // 출발지 변경 const handleDepartureChange = (selectedValue: string) => { - console.log("[LocationSwapSelector] 출발지 변경:", { selectedValue, options }); + console.log("[LocationSwapSelector] 출발지 변경:", { + selectedValue, + departureField, + hasOnFormDataChange: !!onFormDataChange, + options + }); // 로컬 상태 업데이트 setLocalDeparture(selectedValue); // 부모에게 전달 if (onFormDataChange) { + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`); onFormDataChange(departureField, selectedValue); // 라벨 필드도 업데이트 if (departureLabelField) { const selectedOption = options.find((opt) => opt.value === selectedValue); if (selectedOption) { + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`); onFormDataChange(departureLabelField, selectedOption.label); } } + } else { + console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!"); } }; // 도착지 변경 const handleDestinationChange = (selectedValue: string) => { - console.log("[LocationSwapSelector] 도착지 변경:", { selectedValue, options }); + console.log("[LocationSwapSelector] 도착지 변경:", { + selectedValue, + destinationField, + hasOnFormDataChange: !!onFormDataChange, + options + }); // 로컬 상태 업데이트 setLocalDestination(selectedValue); // 부모에게 전달 if (onFormDataChange) { + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`); onFormDataChange(destinationField, selectedValue); // 라벨 필드도 업데이트 if (destinationLabelField) { const selectedOption = options.find((opt) => opt.value === selectedValue); if (selectedOption) { + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`); onFormDataChange(destinationLabelField, selectedOption.label); } } + } else { + console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!"); } }; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index fe544706..6ff97083 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3554,14 +3554,87 @@ export class ButtonActionExecutor { toast.success(successMsg); // 자동 저장 옵션이 활성화된 경우 - if (config.geolocationAutoSave && context.onSave) { + if (config.geolocationAutoSave) { console.log("📍 위치정보 자동 저장 실행"); - try { - await context.onSave(); - toast.success("위치 정보가 저장되었습니다."); - } catch (saveError) { - console.error("❌ 위치정보 자동 저장 실패:", saveError); - toast.error("위치 정보 저장에 실패했습니다."); + + // onSave 콜백이 있으면 사용 + if (context.onSave) { + try { + await context.onSave(); + toast.success("위치 정보가 저장되었습니다."); + } catch (saveError) { + console.error("❌ 위치정보 자동 저장 실패:", saveError); + toast.error("위치 정보 저장에 실패했습니다."); + } + } else if (context.tableName && context.formData) { + // onSave가 없으면 직접 API 호출 + // 키 필드 설정이 있으면 update-field API 사용 (더 안전) + const keyField = config.geolocationExtraKeyField; + const keySourceField = config.geolocationExtraKeySourceField; + + if (keyField && keySourceField) { + try { + const { apiClient } = await import("@/lib/api/client"); + const keyValue = resolveSpecialKeyword(keySourceField, context); + + if (keyValue) { + // formData에서 저장할 필드들 추출 (위치정보 + 출발지/도착지 등) + const fieldsToSave = { ...updates }; + + // formData에서 추가로 저장할 필드들 (테이블에 존재할 가능성이 높은 필드만) + // departure, arrival은 location-swap-selector에서 설정한 필드명 사용 + const additionalFields = ['departure', 'arrival']; + additionalFields.forEach(field => { + if (context.formData?.[field] !== undefined && context.formData[field] !== '') { + fieldsToSave[field] = context.formData[field]; + } + }); + + console.log("📍 개별 필드 UPDATE:", { + tableName: context.tableName, + keyField, + keyValue, + fieldsToSave, + }); + + // 각 필드를 개별적으로 UPDATE (에러가 나도 다른 필드 계속 저장) + let successCount = 0; + let failCount = 0; + + for (const [field, value] of Object.entries(fieldsToSave)) { + try { + console.log(`🔄 UPDATE: ${context.tableName}.${field} = ${value}`); + + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: context.tableName, + keyField: keyField, + keyValue: keyValue, + updateField: field, + updateValue: value, + }); + + if (response.data?.success) { + successCount++; + console.log(`✅ ${field} 업데이트 성공`); + } else { + failCount++; + console.warn(`⚠️ ${field} 업데이트 실패:`, response.data); + } + } catch (fieldError) { + failCount++; + console.warn(`⚠️ ${field} 업데이트 오류 (컬럼이 없을 수 있음):`, fieldError); + } + } + + console.log(`✅ 필드 저장 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + } + } catch (saveError) { + console.error("❌ 위치정보 자동 저장 실패:", saveError); + toast.error("위치 정보 저장에 실패했습니다."); + } + } else { + console.warn("⚠️ 키 필드가 설정되지 않아 자동 저장을 건너뜁니다."); + } } } From fbeb3ec2c952a5b3d46bc8863789a270dd6469a9 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 1 Dec 2025 16:49:02 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B0=EA=B8=88=20=EB=B3=B5=EC=9E=A1=ED=95=98?= =?UTF-8?q?=EC=A7=80=EB=A7=8C=20=EC=9C=84=EB=8F=84=EA=B2=BD=EB=8F=84=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=EC=B6=94=EC=A0=81=EA=B8=B0=EB=8A=A5=EB=8F=84?= =?UTF-8?q?=20=EB=84=A3=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 122 ++++ backend-node/src/routes/dynamicFormRoutes.ts | 6 + .../src/services/dynamicFormService.ts | 185 +++++ .../config-panels/ButtonConfigPanel.tsx | 407 ++++------- frontend/lib/utils/buttonActions.ts | 681 ++++++++++-------- 5 files changed, 823 insertions(+), 578 deletions(-) diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 6adf8cd6..30364189 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -482,3 +482,125 @@ export const updateFieldValue = async ( }); } }; + +/** + * 위치 이력 저장 (연속 위치 추적용) + * POST /api/dynamic-form/location-history + */ +export const saveLocationHistory = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + tripId, + tripStatus, + departure, + arrival, + departureName, + destinationName, + recordedAt, + vehicleId, + } = req.body; + + console.log("📍 [saveLocationHistory] 요청:", { + userId, + companyCode, + latitude, + longitude, + tripId, + }); + + // 필수 필드 검증 + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (latitude, longitude)", + }); + } + + const result = await dynamicFormService.saveLocationHistory({ + userId, + companyCode, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + tripId, + tripStatus: tripStatus || "active", + departure, + arrival, + departureName, + destinationName, + recordedAt: recordedAt || new Date().toISOString(), + vehicleId, + }); + + console.log("✅ [saveLocationHistory] 성공:", result); + + res.json({ + success: true, + data: result, + message: "위치 이력이 저장되었습니다.", + }); + } catch (error: any) { + console.error("❌ [saveLocationHistory] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 이력 저장에 실패했습니다.", + }); + } +}; + +/** + * 위치 이력 조회 (경로 조회용) + * GET /api/dynamic-form/location-history/:tripId + */ +export const getLocationHistory = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.params; + const { userId, startDate, endDate, limit } = req.query; + + console.log("📍 [getLocationHistory] 요청:", { + tripId, + userId, + startDate, + endDate, + limit, + }); + + const result = await dynamicFormService.getLocationHistory({ + companyCode, + tripId, + userId: userId as string, + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 1000, + }); + + res.json({ + success: true, + data: result, + count: result.length, + }); + } catch (error: any) { + console.error("❌ [getLocationHistory] 실패:", 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 dae17283..cec78990 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -12,6 +12,8 @@ import { validateFormData, getTableColumns, getTablePrimaryKeys, + saveLocationHistory, + getLocationHistory, } from "../controllers/dynamicFormController"; const router = express.Router(); @@ -40,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns); // 테이블 기본키 조회 router.get("/table/:tableName/primary-keys", getTablePrimaryKeys); +// 위치 이력 (연속 위치 추적) +router.post("/location-history", saveLocationHistory); +router.get("/location-history/:tripId", getLocationHistory); + export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e94d1ea2..04586d65 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1731,6 +1731,191 @@ export class DynamicFormService { client.release(); } } + + /** + * 위치 이력 저장 (연속 위치 추적용) + */ + async saveLocationHistory(data: { + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + altitude?: number; + speed?: number; + heading?: number; + tripId?: string; + tripStatus?: string; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + recordedAt?: string; + vehicleId?: number; + }): Promise<{ id: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("📍 [saveLocationHistory] 저장 시작:", data); + + const sqlQuery = ` + INSERT INTO vehicle_location_history ( + user_id, + company_code, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + trip_id, + trip_status, + departure, + arrival, + departure_name, + destination_name, + recorded_at, + vehicle_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING id + `; + + const params = [ + data.userId, + data.companyCode, + data.latitude, + data.longitude, + data.accuracy || null, + data.altitude || null, + data.speed || null, + data.heading || null, + data.tripId || null, + data.tripStatus || "active", + data.departure || null, + data.arrival || null, + data.departureName || null, + data.destinationName || null, + data.recordedAt ? new Date(data.recordedAt) : new Date(), + data.vehicleId || null, + ]; + + const result = await client.query(sqlQuery, params); + + console.log("✅ [saveLocationHistory] 저장 완료:", { + id: result.rows[0]?.id, + }); + + return { id: result.rows[0]?.id }; + } catch (error) { + console.error("❌ [saveLocationHistory] 오류:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 위치 이력 조회 (경로 조회용) + */ + async getLocationHistory(params: { + companyCode: string; + tripId?: string; + userId?: string; + startDate?: string; + endDate?: string; + limit?: number; + }): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("📍 [getLocationHistory] 조회 시작:", params); + + const conditions: string[] = []; + const queryParams: any[] = []; + let paramIndex = 1; + + // 멀티테넌시: company_code 필터 + if (params.companyCode && params.companyCode !== "*") { + conditions.push(`company_code = $${paramIndex}`); + queryParams.push(params.companyCode); + paramIndex++; + } + + // trip_id 필터 + if (params.tripId) { + conditions.push(`trip_id = $${paramIndex}`); + queryParams.push(params.tripId); + paramIndex++; + } + + // user_id 필터 + if (params.userId) { + conditions.push(`user_id = $${paramIndex}`); + queryParams.push(params.userId); + paramIndex++; + } + + // 날짜 범위 필터 + if (params.startDate) { + conditions.push(`recorded_at >= $${paramIndex}`); + queryParams.push(new Date(params.startDate)); + paramIndex++; + } + + if (params.endDate) { + conditions.push(`recorded_at <= $${paramIndex}`); + queryParams.push(new Date(params.endDate)); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000"; + + const sqlQuery = ` + SELECT + id, + user_id, + vehicle_id, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + trip_id, + trip_status, + departure, + arrival, + departure_name, + destination_name, + recorded_at, + created_at, + company_code + FROM vehicle_location_history + ${whereClause} + ORDER BY recorded_at ASC + ${limitClause} + `; + + console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery); + console.log("🔍 [getLocationHistory] 파라미터:", queryParams); + + const result = await client.query(sqlQuery, queryParams); + + console.log("✅ [getLocationHistory] 조회 완료:", { + count: result.rowCount, + }); + + return result.rows; + } catch (error) { + console.error("❌ [getLocationHistory] 오류:", error); + throw error; + } finally { + client.release(); + } + } } // 싱글톤 인스턴스 생성 및 export diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5c9360c8..1b7699c4 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1671,18 +1671,10 @@ export const ButtonConfigPanel: React.FC = ({ {/* 테이블 선택 */}
- + -

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

onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)} className="h-8 text-xs" />
onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)} className="h-8 text-xs" /> @@ -1729,20 +1718,20 @@ export const ButtonConfigPanel: React.FC = ({
- + onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)} className="h-8 text-xs" />
- + onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)} className="h-8 text-xs" @@ -1753,7 +1742,7 @@ export const ButtonConfigPanel: React.FC = ({
-

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

+

GPS 사용 (배터리 소모 증가)

= ({ />
-
-
- -

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

-
- onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} - /> -
- - {/* 첫 번째 추가 테이블 설정 (위치정보와 함께 상태 변경) */} -
+ {/* 자동 저장 옵션 */} +
- -

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

+ +

위치 정보를 바로 DB에 저장

onUpdateProperty("componentConfig.action.geolocationUpdateField", checked)} + id="geolocation-auto-save" + checked={config.action?.geolocationAutoSave === true} + onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} />
- - {config.action?.geolocationUpdateField && ( + + {config.action?.geolocationAutoSave && (
-
- - -
- + onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + {/* 추가 필드 변경 (status 등) */} +
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)} className="h-8 text-xs" @@ -1821,203 +1827,15 @@ export const ButtonConfigPanel: React.FC = ({
onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} className="h-8 text-xs" />
-
-
- - onUpdateProperty("componentConfig.action.geolocationExtraKeyField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - -
-
-
- )} -
- - {/* 두 번째 추가 테이블 설정 */} -
-
-
- -

두 번째 테이블의 필드 값도 함께 변경합니다

-
- onUpdateProperty("componentConfig.action.geolocationSecondTableEnabled", checked)} - /> -
- - {config.action?.geolocationSecondTableEnabled && ( -
-
-
- - -
-
- - -
-
-
-
- - onUpdateProperty("componentConfig.action.geolocationSecondField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationSecondValue", e.target.value)} - className="h-8 text-xs" - /> -
-
-
-
- - onUpdateProperty("componentConfig.action.geolocationSecondKeyField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - -
-
- - {config.action?.geolocationSecondMode === "insert" && ( -
-
- -

위도/경도를 이 테이블에도 저장

-
- onUpdateProperty("componentConfig.action.geolocationSecondInsertFields", { - ...config.action?.geolocationSecondInsertFields, - includeLocation: checked - })} - /> -
- )} - -

- {config.action?.geolocationSecondMode === "insert" - ? "새 레코드를 생성합니다. 연결 필드로 현재 폼 데이터와 연결됩니다." - : "기존 레코드를 수정합니다. 키 필드로 레코드를 찾아 값을 변경합니다."} +

+ 위치 정보와 함께 status 같은 필드도 변경할 수 있습니다.

)} @@ -2025,21 +1843,7 @@ export const ButtonConfigPanel: React.FC = ({

- 사용 방법: -
- 1. 버튼을 클릭하면 브라우저가 위치 권한을 요청합니다 -
- 2. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다 -
- 3. 위도/경도가 지정된 필드에 자동으로 입력됩니다 -
- 4. 추가 테이블 설정이 있으면 해당 테이블의 필드도 함께 변경됩니다 -
-
- 예시: 위치정보 저장 + vehicles.status를 inactive로 변경 -
-
- 참고: HTTPS 환경에서만 위치정보가 작동합니다. + 참고: HTTPS 환경에서만 작동합니다.

@@ -2272,15 +2076,70 @@ export const ButtonConfigPanel: React.FC = ({ )}
+ {/* 🆕 연속 위치 추적 설정 */} +
+
+ +

10초마다 위치를 경로 테이블에 저장합니다

+
+ onUpdateProperty("componentConfig.action.updateWithTracking", checked)} + /> +
+ + {config.action?.updateWithTracking && ( +
+
+ + +
+ + {config.action?.updateTrackingMode === "start" && ( +
+ + onUpdateProperty("componentConfig.action.updateTrackingInterval", parseInt(e.target.value) * 1000 || 10000)} + className="h-8 text-xs" + min={5} + max={300} + /> +

5초 ~ 300초 사이로 설정 (기본: 10초)

+
+ )} + +

+ {config.action?.updateTrackingMode === "start" + ? "버튼 클릭 시 연속 위치 추적이 시작되고, vehicle_location_history 테이블에 경로가 저장됩니다." + : "버튼 클릭 시 진행 중인 위치 추적이 종료됩니다."} +

+
+ )} +

사용 예시:
- - 운행알림 버튼: status를 "active"로 + 위치정보 수집 + - 운행 시작: status를 "active"로 + 연속 추적 시작
- - 출발 버튼: status를 "inactive"로 + 위치정보 수집 + - 운행 종료: status를 "completed"로 + 연속 추적 종료
- - 완료 버튼: is_completed를 "Y"로 변경 + - 공차등록: status를 "inactive"로 + 1회성 위치정보 수집

diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 953e7c24..c61536eb 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -24,10 +24,10 @@ export type ButtonActionType = | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge" // 코드 병합 - | "geolocation" // 위치정보 가져오기 + | "geolocation" // 위치정보 가져오기 (1회성, 폼 업데이트만) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) - | "update_field" // 특정 필드 값 변경 (예: status를 active로) - | "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간) + | "update_field" // 필드 값 변경 + 위치 수집 + 연속 추적 (통합) + | "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간) /** * 버튼 액션 설정 @@ -104,6 +104,8 @@ export interface ButtonActionConfig { geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000) geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0) geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false) + geolocationKeyField?: string; // DB UPDATE 시 WHERE 조건에 사용할 키 필드 (예: "user_id") + geolocationKeySourceField?: string; // 키 값 소스 (예: "__userId__" 또는 폼 필드명) geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부 geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능) geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status") @@ -121,6 +123,20 @@ export interface ButtonActionConfig { geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용 geolocationSecondInsertFields?: Record; // INSERT 모드에서 추가로 넣을 필드들 + // 🆕 연속 위치 추적 설정 (update_field 액션의 updateWithTracking 옵션용) + trackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초) + trackingTripIdField?: string; // 운행 ID를 저장할 필드명 (예: "trip_id") + trackingAutoGenerateTripId?: boolean; // 운행 ID 자동 생성 여부 (기본: true) + trackingDepartureField?: string; // 출발지 필드명 (formData에서 가져옴) + trackingArrivalField?: string; // 도착지 필드명 (formData에서 가져옴) + trackingVehicleIdField?: string; // 차량 ID 필드명 (formData에서 가져옴) + trackingStatusOnStart?: string; // 추적 시작 시 상태값 (예: "active") + trackingStatusOnStop?: string; // 추적 종료 시 상태값 (예: "completed") + trackingStatusField?: string; // 상태 필드명 (vehicles 테이블 등) + trackingStatusTableName?: string; // 상태 변경 대상 테이블명 + trackingStatusKeyField?: string; // 상태 변경 키 필드 (예: "user_id") + trackingStatusKeySourceField?: string; // 키 값 소스 (예: "__userId__") + // 필드 값 교환 관련 (출발지 ↔ 목적지) swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure") swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination") @@ -131,11 +147,19 @@ export interface ButtonActionConfig { updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active") updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true) updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경 + updateTableName?: string; // 대상 테이블명 (다른 테이블 UPDATE 시) + updateKeyField?: string; // 키 필드명 (WHERE 조건에 사용) + updateKeySourceField?: string; // 키 값 소스 (폼 필드명 또는 __userId__ 등 특수 키워드) // 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용) updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부 updateGeolocationLatField?: string; // 위도 저장 필드 updateGeolocationLngField?: string; // 경도 저장 필드 + + // 🆕 필드 값 변경 + 연속 위치 추적 (update_field 액션에서 사용) + updateWithTracking?: boolean; // 연속 위치 추적 사용 여부 + updateTrackingMode?: "start" | "stop"; // 추적 모드 (시작/종료) + updateTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000) updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택) updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택) @@ -3301,6 +3325,258 @@ export class ButtonActionExecutor { } } + // 🆕 연속 위치 추적 상태 저장 (전역) + private static trackingIntervalId: NodeJS.Timeout | null = null; + private static currentTripId: string | null = null; + private static trackingContext: ButtonActionContext | null = null; + private static trackingConfig: ButtonActionConfig | null = null; + + /** + * 연속 위치 추적 시작 + */ + private static async handleTrackingStart(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("🚀 [handleTrackingStart] 위치 추적 시작:", { config, context }); + + // 이미 추적 중인지 확인 + if (this.trackingIntervalId) { + toast.warning("이미 위치 추적이 진행 중입니다."); + return false; + } + + // 위치 권한 확인 + if (!navigator.geolocation) { + toast.error("이 브라우저는 위치 정보를 지원하지 않습니다."); + return false; + } + + // Trip ID 생성 + const tripId = config.trackingAutoGenerateTripId !== false + ? `TRIP_${Date.now()}_${context.userId || "unknown"}` + : context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`; + + this.currentTripId = tripId; + this.trackingContext = context; + this.trackingConfig = config; + + // 출발지/도착지 정보 + const departure = context.formData?.[config.trackingDepartureField || "departure"] || null; + const arrival = context.formData?.[config.trackingArrivalField || "arrival"] || null; + const departureName = context.formData?.["departure_name"] || null; + const destinationName = context.formData?.["destination_name"] || null; + const vehicleId = context.formData?.[config.trackingVehicleIdField || "vehicle_id"] || null; + + console.log("📍 [handleTrackingStart] 운행 정보:", { + tripId, + departure, + arrival, + departureName, + destinationName, + vehicleId, + }); + + // 상태 변경 (vehicles 테이블 등) + if (config.trackingStatusOnStart && config.trackingStatusField) { + try { + const { apiClient } = await import("@/lib/api/client"); + const statusTableName = config.trackingStatusTableName || context.tableName; + const keyField = config.trackingStatusKeyField || "user_id"; + const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); + + if (keyValue) { + await apiClient.put(`/dynamic-form/update-field`, { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: config.trackingStatusField, + updateValue: config.trackingStatusOnStart, + }); + console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart); + } + } catch (statusError) { + console.warn("⚠️ 상태 변경 실패:", statusError); + } + } + + // 첫 번째 위치 저장 + await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId); + + // 주기적 위치 저장 시작 + const interval = config.trackingInterval || 10000; // 기본 10초 + this.trackingIntervalId = setInterval(async () => { + await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId); + }, interval); + + toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`); + + // 추적 시작 이벤트 발생 (UI 업데이트용) + window.dispatchEvent(new CustomEvent("trackingStarted", { + detail: { tripId, interval } + })); + + return true; + } catch (error: any) { + console.error("❌ 위치 추적 시작 실패:", error); + toast.error(config.errorMessage || "위치 추적 시작 중 오류가 발생했습니다."); + return false; + } + } + + /** + * 연속 위치 추적 종료 + */ + private static async handleTrackingStop(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context }); + + // 추적 중인지 확인 + if (!this.trackingIntervalId) { + toast.warning("진행 중인 위치 추적이 없습니다."); + return false; + } + + // 타이머 정리 + clearInterval(this.trackingIntervalId); + this.trackingIntervalId = null; + + const tripId = this.currentTripId; + + // 마지막 위치 저장 (trip_status를 completed로) + const departure = this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; + const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; + const departureName = this.trackingContext?.formData?.["departure_name"] || null; + const destinationName = this.trackingContext?.formData?.["destination_name"] || null; + const vehicleId = this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; + + await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed"); + + // 상태 변경 (vehicles 테이블 등) + const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig; + const effectiveContext = context.userId ? context : this.trackingContext; + + if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) { + try { + const { apiClient } = await import("@/lib/api/client"); + const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName; + const keyField = effectiveConfig.trackingStatusKeyField || "user_id"; + const keyValue = resolveSpecialKeyword(effectiveConfig.trackingStatusKeySourceField || "__userId__", effectiveContext); + + if (keyValue) { + await apiClient.put(`/dynamic-form/update-field`, { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: effectiveConfig.trackingStatusField, + updateValue: effectiveConfig.trackingStatusOnStop, + }); + console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop); + } + } catch (statusError) { + console.warn("⚠️ 상태 변경 실패:", statusError); + } + } + + // 컨텍스트 정리 + this.currentTripId = null; + this.trackingContext = null; + this.trackingConfig = null; + + toast.success(config.successMessage || "위치 추적이 종료되었습니다."); + + // 추적 종료 이벤트 발생 (UI 업데이트용) + window.dispatchEvent(new CustomEvent("trackingStopped", { + detail: { tripId } + })); + + // 화면 새로고침 + context.onRefresh?.(); + + return true; + } catch (error: any) { + console.error("❌ 위치 추적 종료 실패:", error); + toast.error(config.errorMessage || "위치 추적 종료 중 오류가 발생했습니다."); + return false; + } + } + + /** + * 위치 이력 테이블에 저장 (내부 헬퍼) + */ + private static async saveLocationToHistory( + tripId: string | null, + departure: string | null, + arrival: string | null, + departureName: string | null, + destinationName: string | null, + vehicleId: number | null, + tripStatus: string = "active" + ): Promise { + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + async (position) => { + try { + const { apiClient } = await import("@/lib/api/client"); + + const locationData = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude, + speed: position.coords.speed, + heading: position.coords.heading, + tripId, + tripStatus, + departure, + arrival, + departureName, + destinationName, + recordedAt: new Date(position.timestamp).toISOString(), + vehicleId, + }; + + console.log("📍 [saveLocationToHistory] 위치 저장:", locationData); + + const response = await apiClient.post(`/dynamic-form/location-history`, locationData); + + if (response.data?.success) { + console.log("✅ 위치 이력 저장 성공:", response.data.data); + } else { + console.warn("⚠️ 위치 이력 저장 실패:", response.data); + } + + resolve(); + } catch (error) { + console.error("❌ 위치 이력 저장 오류:", error); + reject(error); + } + }, + (error) => { + console.error("❌ 위치 획득 실패:", error.message); + reject(error); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + } + ); + }); + } + + /** + * 현재 추적 상태 확인 (외부에서 호출 가능) + */ + static isTracking(): boolean { + return this.trackingIntervalId !== null; + } + + /** + * 현재 Trip ID 가져오기 (외부에서 호출 가능) + */ + static getCurrentTripId(): string | null { + return this.currentTripId; + } + /** * 데이터 전달 액션 처리 (분할 패널에서 좌측 → 우측 데이터 전달) */ @@ -3399,18 +3675,13 @@ export class ButtonActionExecutor { /** * 위치정보 가져오기 액션 처리 + * - 1회성 위치 수집 + * - 폼 필드 업데이트 + * - 자동 저장 옵션 시 DB UPDATE */ private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); - console.log("📍 [디버그] 추가 필드 설정값:", { - geolocationUpdateField: config.geolocationUpdateField, - geolocationExtraField: config.geolocationExtraField, - geolocationExtraValue: config.geolocationExtraValue, - geolocationExtraTableName: config.geolocationExtraTableName, - geolocationExtraKeyField: config.geolocationExtraKeyField, - geolocationExtraKeySourceField: config.geolocationExtraKeySourceField, - }); // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { @@ -3419,41 +3690,30 @@ export class ButtonActionExecutor { } // 위도/경도 저장 필드 확인 - const latField = config.geolocationLatField; - const lngField = config.geolocationLngField; - - if (!latField || !lngField) { - toast.error("위도/경도 저장 필드가 설정되지 않았습니다."); - return false; - } + const latField = config.geolocationLatField || "latitude"; + const lngField = config.geolocationLngField || "longitude"; // 로딩 토스트 표시 const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); // Geolocation 옵션 설정 const options: PositionOptions = { - enableHighAccuracy: config.geolocationHighAccuracy !== false, // 기본 true - timeout: config.geolocationTimeout || 10000, // 기본 10초 - maximumAge: config.geolocationMaxAge || 0, // 기본 0 (항상 새로운 위치) + enableHighAccuracy: config.geolocationHighAccuracy !== false, + timeout: config.geolocationTimeout || 10000, + maximumAge: config.geolocationMaxAge || 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 { latitude, longitude, accuracy } = position.coords; const timestamp = new Date(position.timestamp); - console.log("📍 위치정보 획득 성공:", { - latitude, - longitude, - accuracy, - timestamp: timestamp.toISOString(), - }); + console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy }); // 폼 데이터 업데이트 const updates: Record = { @@ -3461,7 +3721,6 @@ export class ButtonActionExecutor { [lngField]: longitude, }; - // 선택적 필드들 if (config.geolocationAccuracyField && accuracy !== null) { updates[config.geolocationAccuracyField] = accuracy; } @@ -3469,289 +3728,71 @@ export class ButtonActionExecutor { updates[config.geolocationTimestampField] = timestamp.toISOString(); } - // 🆕 추가 필드 변경 (위치정보 + 상태변경) - let extraTableUpdated = false; - let secondTableUpdated = false; - - if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) { - const extraTableName = config.geolocationExtraTableName || context.tableName; - const currentTableName = config.geolocationTableName || context.tableName; - const keySourceField = config.geolocationExtraKeySourceField; - - // 🆕 특수 키워드가 설정되어 있으면 바로 DB UPDATE (같은 테이블이어도) - const hasSpecialKeyword = keySourceField?.startsWith("__") && keySourceField?.endsWith("__"); - const isDifferentTable = extraTableName && extraTableName !== currentTableName; - - // 다른 테이블이거나 특수 키워드가 설정된 경우 → 바로 DB UPDATE - if (isDifferentTable || hasSpecialKeyword) { - console.log("📍 DB 직접 UPDATE:", { - targetTable: extraTableName, - field: config.geolocationExtraField, - value: config.geolocationExtraValue, - keyField: config.geolocationExtraKeyField, - keySourceField: keySourceField, - hasSpecialKeyword, - isDifferentTable, - }); - - // 키 값 가져오기 (특수 키워드 지원) - const keyValue = resolveSpecialKeyword(keySourceField, context); - - if (keyValue && config.geolocationExtraKeyField) { - try { - // DB 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("✅ DB UPDATE 성공:", response.data); - } else { - console.error("❌ DB UPDATE 실패:", response.data); - toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`); - } - } catch (apiError) { - console.error("❌ DB UPDATE API 오류:", apiError); - toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`); - } - } else { - console.warn("⚠️ 키 값이 없어서 DB UPDATE를 건너뜁니다:", { - keySourceField: keySourceField, - keyValue, - }); - } - } else { - // 같은 테이블이고 특수 키워드가 없는 경우 (현재 폼 데이터에 추가) - updates[config.geolocationExtraField] = config.geolocationExtraValue; - console.log("📍 같은 테이블 추가 필드 변경 (폼 데이터):", { - field: config.geolocationExtraField, - value: config.geolocationExtraValue, - }); - } - } - - // 🆕 두 번째 테이블 INSERT 또는 UPDATE - if (config.geolocationSecondTableEnabled && - config.geolocationSecondTableName) { - - const secondMode = config.geolocationSecondMode || "update"; - - console.log("📍 두 번째 테이블 작업:", { - mode: secondMode, - targetTable: config.geolocationSecondTableName, - field: config.geolocationSecondField, - value: config.geolocationSecondValue, - keyField: config.geolocationSecondKeyField, - keySourceField: config.geolocationSecondKeySourceField, - }); - - try { - const { apiClient } = await import("@/lib/api/client"); - - if (secondMode === "insert") { - // INSERT 모드: 새 레코드 생성 - const insertData: Record = { - // 위치정보 포함 (선택적) - ...(config.geolocationSecondInsertFields || {}), - }; - - // 기본 필드 추가 - if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) { - insertData[config.geolocationSecondField] = config.geolocationSecondValue; - } - - // 위치정보도 두 번째 테이블에 저장하려면 추가 - // (선택적으로 위도/경도도 저장) - if (config.geolocationSecondInsertFields?.includeLocation) { - insertData[latField] = latitude; - insertData[lngField] = longitude; - if (config.geolocationAccuracyField) { - insertData[config.geolocationAccuracyField] = accuracy; - } - if (config.geolocationTimestampField) { - insertData[config.geolocationTimestampField] = timestamp.toISOString(); - } - } - - // 현재 폼에서 키 값 가져와서 연결 (외래키) - 특수 키워드 지원 - if (config.geolocationSecondKeySourceField && config.geolocationSecondKeyField) { - const keyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context); - if (keyValue) { - insertData[config.geolocationSecondKeyField] = keyValue; - } - } - - console.log("📍 두 번째 테이블 INSERT 데이터:", insertData); - - const response = await apiClient.post(`/dynamic-form/save`, { - tableName: config.geolocationSecondTableName, - data: insertData, - }); - - if (response.data?.success) { - secondTableUpdated = true; - console.log("✅ 두 번째 테이블 INSERT 성공:", response.data); - } else { - console.error("❌ 두 번째 테이블 INSERT 실패:", response.data); - toast.error(`${config.geolocationSecondTableName} 테이블 저장에 실패했습니다.`); - } - } else { - // UPDATE 모드: 기존 레코드 수정 - if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) { - // 특수 키워드 지원 - const secondKeyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context); - - if (secondKeyValue && config.geolocationSecondKeyField) { - const response = await apiClient.put(`/dynamic-form/update-field`, { - tableName: config.geolocationSecondTableName, - keyField: config.geolocationSecondKeyField, - keyValue: secondKeyValue, - updateField: config.geolocationSecondField, - updateValue: config.geolocationSecondValue, - }); - - if (response.data?.success) { - secondTableUpdated = true; - console.log("✅ 두 번째 테이블 UPDATE 성공:", response.data); - } else { - console.error("❌ 두 번째 테이블 UPDATE 실패:", response.data); - toast.error(`${config.geolocationSecondTableName} 테이블 업데이트에 실패했습니다.`); - } - } else { - console.warn("⚠️ 두 번째 테이블 키 값이 없어서 UPDATE를 건너뜁니다:", { - keySourceField: config.geolocationSecondKeySourceField, - keyValue: secondKeyValue, - }); - } - } - } - } catch (apiError) { - console.error("❌ 두 번째 테이블 API 오류:", apiError); - toast.error(`${config.geolocationSecondTableName} 테이블 작업 중 오류가 발생했습니다.`); - } - } - - // formData 업데이트 + // onFormDataChange로 폼 업데이트 if (context.onFormDataChange) { Object.entries(updates).forEach(([field, value]) => { - context.onFormDataChange?.(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}`; - } - } - - // 두 번째 테이블 변경이 있으면 메시지에 포함 - if (secondTableUpdated && config.geolocationSecondTableName) { - successMsg += `\n[${config.geolocationSecondTableName}] ${config.geolocationSecondField}: ${config.geolocationSecondValue}`; - } - - // 성공 메시지 표시 - toast.success(successMsg); - - // 자동 저장 옵션이 활성화된 경우 + // 🆕 자동 저장 옵션이 활성화된 경우 DB UPDATE if (config.geolocationAutoSave) { - console.log("📍 위치정보 자동 저장 실행"); - - // onSave 콜백이 있으면 사용 - if (context.onSave) { + const keyField = config.geolocationKeyField || "user_id"; + const keySourceField = config.geolocationKeySourceField || "__userId__"; + const keyValue = resolveSpecialKeyword(keySourceField, context); + const targetTableName = config.geolocationTableName || context.tableName; + + if (keyValue && targetTableName) { try { - await context.onSave(); - toast.success("위치 정보가 저장되었습니다."); + const { apiClient } = await import("@/lib/api/client"); + + // 위치 정보 필드들 업데이트 (위도, 경도, 정확도, 타임스탬프) + const fieldsToUpdate = { ...updates }; + + // formData에서 departure, arrival만 포함 (테이블에 있을 가능성 높은 필드만) + if (context.formData?.departure) fieldsToUpdate.departure = context.formData.departure; + if (context.formData?.arrival) fieldsToUpdate.arrival = context.formData.arrival; + + // 추가 필드 변경 (status 등) + if (config.geolocationExtraField && config.geolocationExtraValue !== undefined) { + fieldsToUpdate[config.geolocationExtraField] = config.geolocationExtraValue; + } + + console.log("📍 DB UPDATE 시작:", { targetTableName, keyField, keyValue, fieldsToUpdate }); + + // 각 필드를 개별적으로 UPDATE (에러 무시) + let successCount = 0; + for (const [field, value] of Object.entries(fieldsToUpdate)) { + try { + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: targetTableName, + keyField, + keyValue, + updateField: field, + updateValue: value, + }); + if (response.data?.success) { + successCount++; + } + } catch { + // 컬럼이 없으면 조용히 무시 (에러 로그 안 찍음) + } + } + console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`); + + toast.success(config.successMessage || "위치 정보가 저장되었습니다."); } catch (saveError) { console.error("❌ 위치정보 자동 저장 실패:", saveError); toast.error("위치 정보 저장에 실패했습니다."); + return false; } - } else if (context.tableName && context.formData) { - // onSave가 없으면 직접 API 호출 - // 키 필드 설정이 있으면 update-field API 사용 (더 안전) - const keyField = config.geolocationExtraKeyField; - const keySourceField = config.geolocationExtraKeySourceField; - - if (keyField && keySourceField) { - try { - const { apiClient } = await import("@/lib/api/client"); - const keyValue = resolveSpecialKeyword(keySourceField, context); - - if (keyValue) { - // formData에서 저장할 필드들 추출 (위치정보 + 출발지/도착지 등) - const fieldsToSave = { ...updates }; - - // formData에서 추가로 저장할 필드들 (테이블에 존재할 가능성이 높은 필드만) - // departure, arrival은 location-swap-selector에서 설정한 필드명 사용 - const additionalFields = ['departure', 'arrival']; - additionalFields.forEach(field => { - if (context.formData?.[field] !== undefined && context.formData[field] !== '') { - fieldsToSave[field] = context.formData[field]; - } - }); - - console.log("📍 개별 필드 UPDATE:", { - tableName: context.tableName, - keyField, - keyValue, - fieldsToSave, - }); - - // 각 필드를 개별적으로 UPDATE (에러가 나도 다른 필드 계속 저장) - let successCount = 0; - let failCount = 0; - - for (const [field, value] of Object.entries(fieldsToSave)) { - try { - console.log(`🔄 UPDATE: ${context.tableName}.${field} = ${value}`); - - const response = await apiClient.put(`/dynamic-form/update-field`, { - tableName: context.tableName, - keyField: keyField, - keyValue: keyValue, - updateField: field, - updateValue: value, - }); - - if (response.data?.success) { - successCount++; - console.log(`✅ ${field} 업데이트 성공`); - } else { - failCount++; - console.warn(`⚠️ ${field} 업데이트 실패:`, response.data); - } - } catch (fieldError) { - failCount++; - console.warn(`⚠️ ${field} 업데이트 오류 (컬럼이 없을 수 있음):`, fieldError); - } - } - - console.log(`✅ 필드 저장 완료: 성공 ${successCount}개, 실패 ${failCount}개`); - } - } catch (saveError) { - console.error("❌ 위치정보 자동 저장 실패:", saveError); - toast.error("위치 정보 저장에 실패했습니다."); - } - } else { - console.warn("⚠️ 키 필드가 설정되지 않아 자동 저장을 건너뜁니다."); - } + } else { + console.warn("⚠️ 키 값 또는 테이블명이 없어서 자동 저장을 건너뜁니다:", { keyValue, targetTableName }); + toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`); } + } else { + // 자동 저장 없이 성공 메시지만 + toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`); } return true; @@ -3838,11 +3879,32 @@ export class ButtonActionExecutor { /** * 필드 값 변경 액션 처리 (예: status를 active로 변경) * 🆕 위치정보 수집 기능 추가 + * 🆕 연속 위치 추적 기능 추가 */ private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("🔄 필드 값 변경 액션 실행:", { config, context }); + // 🆕 연속 위치 추적 모드 처리 + if (config.updateWithTracking) { + const trackingConfig: ButtonActionConfig = { + ...config, + trackingInterval: config.updateTrackingInterval || config.trackingInterval || 10000, + trackingStatusField: config.updateTargetField, + trackingStatusTableName: config.updateTableName || context.tableName, + trackingStatusKeyField: config.updateKeyField, + trackingStatusKeySourceField: config.updateKeySourceField, + }; + + if (config.updateTrackingMode === "start") { + trackingConfig.trackingStatusOnStart = config.updateTargetValue as string; + return await this.handleTrackingStart(trackingConfig, context); + } else if (config.updateTrackingMode === "stop") { + trackingConfig.trackingStatusOnStop = config.updateTargetValue as string; + return await this.handleTrackingStop(trackingConfig, context); + } + } + const { formData, tableName, onFormDataChange, onSave } = context; // 변경할 필드 확인 @@ -4153,6 +4215,12 @@ export const DEFAULT_BUTTON_ACTIONS: Record Date: Mon, 1 Dec 2025 17:04:59 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EA=B3=B5=EC=B0=A8=20=EB=93=B1=EB=A1=9D,=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=EC=B6=94=EC=A0=81=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config-panels/ButtonConfigPanel.tsx | 14 ++--- frontend/lib/utils/buttonActions.ts | 62 +++++++++++-------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 1b7699c4..1ed37a49 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -503,8 +503,8 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 업로드 바코드 스캔 코드 병합 - 위치정보 가져오기 - 필드 값 변경 + 공차등록 + 운행알림 및 종료
@@ -1665,9 +1665,9 @@ export const ButtonConfigPanel: React.FC = ({ )} {/* 위치정보 가져오기 설정 */} - {(component.componentConfig?.action?.type || "save") === "geolocation" && ( + {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
-

📍 위치정보 설정

+

🚛 공차등록 설정

{/* 테이블 선택 */}
@@ -1849,10 +1849,10 @@ export const ButtonConfigPanel: React.FC = ({
)} - {/* 필드 값 변경 설정 */} - {(component.componentConfig?.action?.type || "save") === "update_field" && ( + {/* 운행알림 및 종료 설정 */} + {(component.componentConfig?.action?.type || "save") === "operation_control" && (
-

📝 필드 값 변경 설정

+

🚗 운행알림 및 종료 설정

)}
); @@ -495,16 +462,8 @@ const SelectBasicComponent: React.FC = ({
- {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? ( @@ -520,8 +479,7 @@ const SelectBasicComponent: React.FC = ({ ) : (
옵션이 없습니다
)} -
, - document.body +
)}
); @@ -586,13 +544,9 @@ const SelectBasicComponent: React.FC = ({ value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); - updateDropdownPosition(); - setIsOpen(true); - }} - onFocus={() => { - updateDropdownPosition(); setIsOpen(true); }} + onFocus={() => setIsOpen(true)} placeholder={placeholder} className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", @@ -601,16 +555,8 @@ const SelectBasicComponent: React.FC = ({ )} readOnly={isDesignMode} /> - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && filteredOptions.length > 0 && ( +
{filteredOptions.map((option, index) => (
= ({ {option.label}
))} -
, - document.body +
)}
); @@ -659,16 +604,8 @@ const SelectBasicComponent: React.FC = ({
- {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
= ({
))}
- , - document.body + )} ); @@ -711,12 +647,7 @@ const SelectBasicComponent: React.FC = ({ !isDesignMode && "hover:border-orange-400", isSelected && "ring-2 ring-orange-500", )} - onClick={() => { - if (!isDesignMode) { - updateDropdownPosition(); - setIsOpen(true); - } - }} + onClick={() => !isDesignMode && setIsOpen(true)} style={{ pointerEvents: isDesignMode ? "none" : "auto", height: "100%" @@ -749,30 +680,22 @@ const SelectBasicComponent: React.FC = ({ {placeholder} )} - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
{(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => { - const isOptionSelected = selectedValues.includes(option.value); + const isSelected = selectedValues.includes(option.value); return (
{ - const newVals = isOptionSelected + const newVals = isSelected ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value]; setSelectedValues(newVals); @@ -785,7 +708,7 @@ const SelectBasicComponent: React.FC = ({
{}} className="h-4 w-4" /> @@ -797,8 +720,7 @@ const SelectBasicComponent: React.FC = ({ ) : (
옵션이 없습니다
)} -
, - document.body +
)}
); @@ -827,16 +749,8 @@ const SelectBasicComponent: React.FC = ({
- {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? ( @@ -852,8 +766,7 @@ const SelectBasicComponent: React.FC = ({ ) : (
옵션이 없습니다
)} -
, - document.body +
)} );