Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-12-01 10:21:34 +09:00
commit 142fb15dc0
13 changed files with 1985 additions and 291 deletions

View File

@ -419,3 +419,66 @@ export const getTableColumns = async (
});
}
};
// 특정 필드만 업데이트 (다른 테이블 지원)
export const updateFieldValue = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
console.log("🔄 [updateFieldValue] 요청:", {
tableName,
keyField,
keyValue,
updateField,
updateValue,
userId,
companyCode,
});
// 필수 필드 검증
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
});
}
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
});
}
// 업데이트 쿼리 실행
const result = await dynamicFormService.updateFieldValue(
tableName,
keyField,
keyValue,
updateField,
updateValue,
companyCode,
userId
);
console.log("✅ [updateFieldValue] 성공:", result);
res.json({
success: true,
data: result,
message: "필드 값이 업데이트되었습니다.",
});
} catch (error: any) {
console.error("❌ [updateFieldValue] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "필드 업데이트에 실패했습니다.",
});
}
};

View File

@ -5,6 +5,7 @@ import {
saveFormDataEnhanced,
updateFormData,
updateFormDataPartial,
updateFieldValue,
deleteFormData,
getFormData,
getFormDataList,
@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
router.delete("/:id", deleteFormData);
router.get("/:id", getFormData);

View File

@ -299,6 +299,8 @@ export class DashboardService {
/**
*
* - company_code가
* - company_code가 '*'
*/
static async getDashboardById(
dashboardId: string,
@ -310,44 +312,43 @@ export class DashboardService {
let dashboardQuery: string;
let dashboardParams: any[];
if (userId) {
if (companyCode) {
if (companyCode) {
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
if (companyCode === '*') {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.company_code = $2
AND (d.created_by = $3 OR d.is_public = true)
`;
dashboardParams = [dashboardId, companyCode, userId];
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND (d.created_by = $2 OR d.is_public = true)
`;
dashboardParams = [dashboardId, userId];
}
} else {
if (companyCode) {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.company_code = $2
AND d.is_public = true
`;
dashboardParams = [dashboardId, companyCode];
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.is_public = true
`;
dashboardParams = [dashboardId];
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.company_code = $2
`;
dashboardParams = [dashboardId, companyCode];
}
} else if (userId) {
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND (d.created_by = $2 OR d.is_public = true)
`;
dashboardParams = [dashboardId, userId];
} else {
// 비로그인 사용자는 공개 대시보드만
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.is_public = true
`;
dashboardParams = [dashboardId];
}
const dashboardResult = await PostgreSQLService.query(

View File

@ -1,4 +1,4 @@
import { query, queryOne, transaction } from "../database/db";
import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
@ -1635,6 +1635,69 @@ export class DynamicFormService {
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
}
}
/**
*
* ( )
*/
async updateFieldValue(
tableName: string,
keyField: string,
keyValue: any,
updateField: string,
updateValue: any,
companyCode: string,
userId: string
): Promise<{ affectedRows: number }> {
const pool = getPool();
const client = await pool.connect();
try {
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
tableName,
keyField,
keyValue,
updateField,
updateValue,
companyCode,
});
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
let whereClause = `"${keyField}" = $1`;
const params: any[] = [keyValue, updateValue, userId];
let paramIndex = 4;
if (companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
const sqlQuery = `
UPDATE "${tableName}"
SET "${updateField}" = $2,
updated_by = $3,
updated_at = NOW()
WHERE ${whereClause}
`;
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
console.log("🔍 [updateFieldValue] 파라미터:", params);
const result = await client.query(sqlQuery, params);
console.log("✅ [updateFieldValue] 결과:", {
affectedRows: result.rowCount,
});
return { affectedRows: result.rowCount || 0 };
} catch (error) {
console.error("❌ [updateFieldValue] 오류:", error);
throw error;
} finally {
client.release();
}
}
}
// 싱글톤 인스턴스 생성 및 export

View File

@ -995,6 +995,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// console.log("🔧 기본 해상도 적용:", defaultResolution);
}
// 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인
const buttonComponents = layoutWithDefaultGrid.components.filter(
(c: any) => c.componentType?.startsWith("button")
);
console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({
id: c.id,
type: c.componentType,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,
})));
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
@ -1452,7 +1463,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
};
// 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary",
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
);
console.log("💾 저장 시작:", {
screenId: selectedScreen.screenId,
@ -1462,6 +1473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
buttonComponents: buttonComponents.map((c: any) => ({
id: c.id,
type: c.type,
componentType: c.componentType,
text: c.componentConfig?.text,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,

View File

@ -503,6 +503,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="geolocation"> </SelectItem>
<SelectItem value="update_field"> </SelectItem>
</SelectContent>
</Select>
</div>
@ -1662,6 +1664,255 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 위치정보 가져오기 설정 */}
{(component.componentConfig?.action?.type || "save") === "geolocation" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📍 </h4>
{/* 테이블 선택 */}
<div>
<Label htmlFor="geolocation-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.geolocationTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.geolocationTableName", value);
onUpdateProperty("componentConfig.action.geolocationLatField", "");
onUpdateProperty("componentConfig.action.geolocationLngField", "");
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-lat-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lat-field"
placeholder="예: latitude"
value={config.action?.geolocationLatField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-lng-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lng-field"
placeholder="예: longitude"
value={config.action?.geolocationLngField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-accuracy-field"> ()</Label>
<Input
id="geolocation-accuracy-field"
placeholder="예: accuracy"
value={config.action?.geolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-timestamp-field"> ()</Label>
<Input
id="geolocation-timestamp-field"
placeholder="예: location_time"
value={config.action?.geolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-high-accuracy"> </Label>
<p className="text-xs text-muted-foreground">GPS를 ( )</p>
</div>
<Switch
id="geolocation-high-accuracy"
checked={config.action?.geolocationHighAccuracy !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-auto-save"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="geolocation-auto-save"
checked={config.action?.geolocationAutoSave === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
/>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2. GPS
<br />
3. /
<br />
<br />
<strong>:</strong> HTTPS .
</p>
</div>
</div>
)}
{/* 필드 값 변경 설정 */}
{(component.componentConfig?.action?.type || "save") === "update_field" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📝 </h4>
<div>
<Label htmlFor="update-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.updateTableName", value);
onUpdateProperty("componentConfig.action.updateTargetField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-target-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-field"
placeholder="예: status"
value={config.action?.updateTargetField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-target-value">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-value"
placeholder="예: active"
value={config.action?.updateTargetValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> (, )</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label>
<p className="text-xs text-muted-foreground"> DB에 </p>
</div>
<Switch
id="update-auto-save"
checked={config.action?.updateAutoSave !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateAutoSave", checked)}
/>
</div>
<div>
<Label htmlFor="update-confirm-message"> ()</Label>
<Input
id="update-confirm-message"
placeholder="예: 운행을 시작하시겠습니까?"
value={config.action?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-success-message"> ()</Label>
<Input
id="update-success-message"
placeholder="예: 운행이 시작되었습니다."
value={config.action?.successMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.successMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="update-error-message"> ()</Label>
<Input
id="update-error-message"
placeholder="예: 운행 시작에 실패했습니다."
value={config.action?.errorMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.errorMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
- 버튼: status &quot;active&quot;
<br />
- 버튼: approval_status &quot;approved&quot;
<br />
- 버튼: is_completed &quot;Y&quot;
</p>
</div>
</div>
)}
{/* 데이터 전달 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "transferData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">

View File

@ -157,7 +157,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} | null>(null);
// 토스트 정리를 위한 ref
const currentLoadingToastRef = useRef<string | number | undefined>();
const currentLoadingToastRef = useRef<string | number | undefined>(undefined);
// 컴포넌트 언마운트 시 토스트 정리
useEffect(() => {
@ -201,9 +201,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
// 컴포넌트 설정
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
const componentConfig = {
...config,
...component.config,
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
} as ButtonPrimaryConfig;
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
@ -238,13 +240,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 스타일 계산
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
height: "100%",
};
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)

View File

@ -64,6 +64,12 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
// 🆕 탭 컴포넌트
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
// 🆕 반복 화면 모달 컴포넌트
import "./repeat-screen-modal/RepeatScreenModalRenderer";
// 🆕 출발지/도착지 선택 컴포넌트
import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)

View File

@ -0,0 +1,432 @@
"use client";
import React, { useState, useEffect } from "react";
import { ArrowLeftRight, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface LocationOption {
value: string;
label: string;
}
interface DataSourceConfig {
type: "table" | "code" | "static";
tableName?: string;
valueField?: string;
labelField?: string;
codeCategory?: string;
staticOptions?: LocationOption[];
}
export interface LocationSwapSelectorProps {
// 기본 props
id?: string;
style?: React.CSSProperties;
isDesignMode?: boolean;
// 데이터 소스 설정
dataSource?: DataSourceConfig;
// 필드 매핑
departureField?: string;
destinationField?: string;
departureLabelField?: string;
destinationLabelField?: string;
// UI 설정
departureLabel?: string;
destinationLabel?: string;
showSwapButton?: boolean;
swapButtonPosition?: "center" | "right";
variant?: "card" | "inline" | "minimal";
// 폼 데이터
formData?: Record<string, any>;
onFormDataChange?: (field: string, value: any) => void;
// componentConfig (화면 디자이너에서 전달)
componentConfig?: {
dataSource?: DataSourceConfig;
departureField?: string;
destinationField?: string;
departureLabelField?: string;
destinationLabelField?: string;
departureLabel?: string;
destinationLabel?: string;
showSwapButton?: boolean;
swapButtonPosition?: "center" | "right";
variant?: "card" | "inline" | "minimal";
};
}
/**
* LocationSwapSelector
* /
*/
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
const {
id,
style,
isDesignMode = false,
formData = {},
onFormDataChange,
componentConfig,
} = props;
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
const config = componentConfig || {};
const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] };
const departureField = config.departureField || props.departureField || "departure";
const destinationField = config.destinationField || props.destinationField || "destination";
const departureLabelField = config.departureLabelField || props.departureLabelField;
const destinationLabelField = config.destinationLabelField || props.destinationLabelField;
const departureLabel = config.departureLabel || props.departureLabel || "출발지";
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
const variant = config.variant || props.variant || "card";
// 상태
const [options, setOptions] = useState<LocationOption[]>([]);
const [loading, setLoading] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
// 현재 선택된 값
const departureValue = formData[departureField] || "";
const destinationValue = formData[destinationField] || "";
// 옵션 로드
useEffect(() => {
const loadOptions = async () => {
if (dataSource.type === "static") {
setOptions(dataSource.staticOptions || []);
return;
}
if (dataSource.type === "code" && dataSource.codeCategory) {
// 코드 관리에서 가져오기
setLoading(true);
try {
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`);
if (response.data.success && response.data.data) {
const codeOptions = response.data.data.map((code: any) => ({
value: code.code_value || code.codeValue,
label: code.code_name || code.codeName,
}));
setOptions(codeOptions);
}
} catch (error) {
console.error("코드 로드 실패:", error);
} finally {
setLoading(false);
}
return;
}
if (dataSource.type === "table" && dataSource.tableName) {
// 테이블에서 가져오기
setLoading(true);
try {
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, {
params: { pageSize: 1000 },
});
if (response.data.success && response.data.data) {
const tableOptions = response.data.data.map((row: any) => ({
value: row[dataSource.valueField || "id"],
label: row[dataSource.labelField || "name"],
}));
setOptions(tableOptions);
}
} catch (error) {
console.error("테이블 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
}
};
if (!isDesignMode) {
loadOptions();
} else {
// 디자인 모드에서는 샘플 데이터
setOptions([
{ value: "seoul", label: "서울" },
{ value: "busan", label: "부산" },
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
]);
}
}, [dataSource, isDesignMode]);
// 출발지 변경
const handleDepartureChange = (value: string) => {
if (onFormDataChange) {
onFormDataChange(departureField, value);
// 라벨 필드도 업데이트
if (departureLabelField) {
const selectedOption = options.find((opt) => opt.value === value);
onFormDataChange(departureLabelField, selectedOption?.label || "");
}
}
};
// 도착지 변경
const handleDestinationChange = (value: string) => {
if (onFormDataChange) {
onFormDataChange(destinationField, value);
// 라벨 필드도 업데이트
if (destinationLabelField) {
const selectedOption = options.find((opt) => opt.value === value);
onFormDataChange(destinationLabelField, selectedOption?.label || "");
}
}
};
// 출발지/도착지 교환
const handleSwap = () => {
if (!onFormDataChange) return;
setIsSwapping(true);
// 값 교환
const tempDeparture = departureValue;
const tempDestination = destinationValue;
onFormDataChange(departureField, tempDestination);
onFormDataChange(destinationField, tempDeparture);
// 라벨도 교환
if (departureLabelField && destinationLabelField) {
const tempDepartureLabel = formData[departureLabelField];
const tempDestinationLabel = formData[destinationLabelField];
onFormDataChange(departureLabelField, tempDestinationLabel);
onFormDataChange(destinationLabelField, tempDepartureLabel);
}
// 애니메이션 효과
setTimeout(() => setIsSwapping(false), 300);
};
// 선택된 라벨 가져오기
const getDepartureLabel = () => {
const option = options.find((opt) => opt.value === departureValue);
return option?.label || "선택";
};
const getDestinationLabel = () => {
const option = options.find((opt) => opt.value === destinationValue);
return option?.label || "선택";
};
// 스타일에서 width, height 추출
const { width, height, ...restStyle } = style || {};
// Card 스타일 (이미지 참고)
if (variant === "card") {
return (
<div
id={id}
className="h-full w-full"
style={restStyle}
>
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
{/* 출발지 */}
<div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
<Select
value={departureValue}
onValueChange={handleDepartureChange}
disabled={loading || isDesignMode}
>
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
<SelectValue placeholder="선택">
<span className={cn(isSwapping && "animate-pulse")}>
{getDepartureLabel()}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 교환 버튼 */}
{showSwapButton && (
<Button
type="button"
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"
)}
>
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* 도착지 */}
<div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
<Select
value={destinationValue}
onValueChange={handleDestinationChange}
disabled={loading || isDesignMode}
>
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
<SelectValue placeholder="선택">
<span className={cn(isSwapping && "animate-pulse")}>
{getDestinationLabel()}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
// Inline 스타일
if (variant === "inline") {
return (
<div
id={id}
className="flex h-full w-full items-center gap-2"
style={restStyle}
>
<div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
<Select
value={departureValue}
onValueChange={handleDepartureChange}
disabled={loading || isDesignMode}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showSwapButton && (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleSwap}
disabled={isDesignMode}
className="mt-5 h-10 w-10"
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
)}
<div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
<Select
value={destinationValue}
onValueChange={handleDestinationChange}
disabled={loading || isDesignMode}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
// Minimal 스타일
return (
<div
id={id}
className="flex h-full w-full items-center gap-1"
style={restStyle}
>
<Select
value={departureValue}
onValueChange={handleDepartureChange}
disabled={loading || isDesignMode}
>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={departureLabel} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showSwapButton && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleSwap}
disabled={isDesignMode}
className="h-8 w-8 p-0"
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
)}
<Select
value={destinationValue}
onValueChange={handleDestinationChange}
disabled={loading || isDesignMode}
>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={destinationLabel} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -0,0 +1,415 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { apiClient } from "@/lib/api/client";
interface LocationSwapSelectorConfigPanelProps {
config: any;
onChange: (config: any) => void;
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
screenTableName?: string;
}
/**
* LocationSwapSelector
*/
export function LocationSwapSelectorConfigPanel({
config,
onChange,
tableColumns = [],
screenTableName,
}: LocationSwapSelectorConfigPanelProps) {
const [tables, setTables] = useState<Array<{ name: string; label: string }>>([]);
const [columns, setColumns] = useState<Array<{ name: string; label: string }>>([]);
const [codeCategories, setCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
try {
const response = await apiClient.get("/table-management/tables");
if (response.data.success && response.data.data) {
setTables(
response.data.data.map((t: any) => ({
name: t.tableName || t.table_name,
label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
const tableName = config?.dataSource?.tableName;
if (!tableName) {
setColumns([]);
return;
}
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data.success) {
// API 응답 구조 처리: data가 배열이거나 data.columns가 배열일 수 있음
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) {
columnData = columnData.columns;
}
if (Array.isArray(columnData)) {
setColumns(
columnData.map((c: any) => ({
name: c.columnName || c.column_name || c.name,
label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name,
}))
);
}
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
}
};
if (config?.dataSource?.type === "table") {
loadColumns();
}
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
// 코드 카테고리 로드
useEffect(() => {
const loadCodeCategories = async () => {
try {
const response = await apiClient.get("/code-management/categories");
if (response.data.success && response.data.data) {
setCodeCategories(
response.data.data.map((c: any) => ({
value: c.category_code || c.categoryCode || c.code,
label: c.category_name || c.categoryName || c.name,
}))
);
}
} catch (error) {
console.error("코드 카테고리 로드 실패:", error);
}
};
loadCodeCategories();
}, []);
const handleChange = (path: string, value: any) => {
const keys = path.split(".");
const newConfig = { ...config };
let current: any = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
onChange(newConfig);
};
return (
<div className="space-y-4">
{/* 데이터 소스 타입 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={config?.dataSource?.type || "static"}
onValueChange={(value) => handleChange("dataSource.type", value)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> ()</SelectItem>
<SelectItem value="table"></SelectItem>
<SelectItem value="code"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 테이블 선택 (type이 table일 때) */}
{config?.dataSource?.type === "table" && (
<>
<div className="space-y-2">
<Label></Label>
<Select
value={config?.dataSource?.tableName || ""}
onValueChange={(value) => handleChange("dataSource.tableName", value)}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> </Label>
<Select
value={config?.dataSource?.valueField || ""}
onValueChange={(value) => handleChange("dataSource.valueField", value)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={config?.dataSource?.labelField || ""}
onValueChange={(value) => handleChange("dataSource.labelField", value)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
)}
{/* 코드 카테고리 선택 (type이 code일 때) */}
{config?.dataSource?.type === "code" && (
<div className="space-y-2">
<Label> </Label>
<Select
value={config?.dataSource?.codeCategory || ""}
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{codeCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 필드 매핑 */}
<div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium"> ( )</h4>
{screenTableName && (
<p className="text-xs text-muted-foreground">
: <strong>{screenTableName}</strong>
</p>
)}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> </Label>
{tableColumns.length > 0 ? (
<Select
value={config?.departureField || ""}
onValueChange={(value) => handleChange("departureField", value)}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.departureField || "departure"}
onChange={(e) => handleChange("departureField", e.target.value)}
placeholder="departure"
/>
)}
</div>
<div className="space-y-2">
<Label> </Label>
{tableColumns.length > 0 ? (
<Select
value={config?.destinationField || ""}
onValueChange={(value) => handleChange("destinationField", value)}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.destinationField || "destination"}
onChange={(e) => handleChange("destinationField", e.target.value)}
placeholder="destination"
/>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> ()</Label>
{tableColumns.length > 0 ? (
<Select
value={config?.departureLabelField || ""}
onValueChange={(value) => handleChange("departureLabelField", value)}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.departureLabelField || ""}
onChange={(e) => handleChange("departureLabelField", e.target.value)}
placeholder="departure_name"
/>
)}
</div>
<div className="space-y-2">
<Label> ()</Label>
{tableColumns.length > 0 ? (
<Select
value={config?.destinationLabelField || ""}
onValueChange={(value) => handleChange("destinationLabelField", value)}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config?.destinationLabelField || ""}
onChange={(e) => handleChange("destinationLabelField", e.target.value)}
placeholder="destination_name"
/>
)}
</div>
</div>
</div>
{/* UI 설정 */}
<div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">UI </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> </Label>
<Input
value={config?.departureLabel || "출발지"}
onChange={(e) => handleChange("departureLabel", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={config?.destinationLabel || "도착지"}
onChange={(e) => handleChange("destinationLabel", e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={config?.variant || "card"}
onValueChange={(value) => handleChange("variant", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="card"> ( )</SelectItem>
<SelectItem value="inline"></SelectItem>
<SelectItem value="minimal"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config?.showSwapButton !== false}
onCheckedChange={(checked) => handleChange("showSwapButton", checked)}
/>
</div>
</div>
{/* 안내 */}
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2. /
<br />
3.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { LocationSwapSelectorDefinition } from "./index";
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
/**
* LocationSwapSelector
*/
export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = LocationSwapSelectorDefinition;
render(): React.ReactElement {
return <LocationSwapSelectorComponent {...this.props} />;
}
}
// 자동 등록 실행
LocationSwapSelectorRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
LocationSwapSelectorRenderer.enableHotReload();
}

View File

@ -0,0 +1,54 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel";
/**
* LocationSwapSelector
* /
*/
export const LocationSwapSelectorDefinition = createComponentDefinition({
id: "location-swap-selector",
name: "출발지/도착지 선택",
nameEng: "Location Swap Selector",
description: "출발지와 도착지를 선택하고 교환할 수 있는 컴포넌트 (모바일 최적화)",
category: ComponentCategory.INPUT,
webType: "form",
component: LocationSwapSelectorComponent,
defaultConfig: {
// 데이터 소스 설정
dataSource: {
type: "table", // "table" | "code" | "static"
tableName: "", // 장소 테이블명
valueField: "location_code", // 값 필드
labelField: "location_name", // 표시 필드
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
staticOptions: [], // 정적 옵션 (type이 "static"일 때)
},
// 필드 매핑
departureField: "departure", // 출발지 저장 필드
destinationField: "destination", // 도착지 저장 필드
departureLabelField: "departure_name", // 출발지명 저장 필드 (선택)
destinationLabelField: "destination_name", // 도착지명 저장 필드 (선택)
// UI 설정
departureLabel: "출발지",
destinationLabel: "도착지",
showSwapButton: true,
swapButtonPosition: "center", // "center" | "right"
// 스타일
variant: "card", // "card" | "inline" | "minimal"
},
defaultSize: { width: 400, height: 100 },
configPanel: LocationSwapSelectorConfigPanel,
icon: "ArrowLeftRight",
tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"],
version: "1.0.0",
author: "개발팀",
});
// 컴포넌트 내보내기
export { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
export { LocationSwapSelectorRenderer } from "./LocationSwapSelectorRenderer";

View File

@ -24,6 +24,9 @@ export type ButtonActionType =
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합
| "geolocation" // 위치정보 가져오기
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "update_field" // 특정 필드 값 변경 (예: status를 active로)
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
/**
@ -91,6 +94,34 @@ export interface ButtonActionConfig {
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
// 위치정보 관련
geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블)
geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude")
geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude")
geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy")
geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time")
geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true)
geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000)
geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0)
geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false)
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
// 필드 값 교환 관련 (출발지 ↔ 목적지)
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination")
swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도)
// 필드 값 변경 관련 (특정 필드를 특정 값으로 변경)
updateTargetField?: string; // 변경할 필드명 (예: "status")
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
@ -100,37 +131,37 @@ export interface ButtonActionConfig {
// 데이터 전달 관련 (transferData 액션용)
dataTransfer?: {
// 소스 설정
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
// 타겟 설정
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
// 타겟이 컴포넌트인 경우
targetComponentId?: string; // 타겟 컴포넌트 ID
targetComponentId?: string; // 타겟 컴포넌트 ID
// 타겟이 화면인 경우
targetScreenId?: number; // 타겟 화면 ID
targetScreenId?: number; // 타겟 화면 ID
// 데이터 매핑 규칙
mappingRules: Array<{
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
defaultValue?: any; // 기본값
defaultValue?: any; // 기본값
}>;
// 전달 옵션
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
// 검증
validation?: {
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
};
};
}
@ -240,6 +271,12 @@ export class ButtonActionExecutor {
case "transferData":
return await this.handleTransferData(config, context);
case "geolocation":
return await this.handleGeolocation(config, context);
case "update_field":
return await this.handleUpdateField(config, context);
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
@ -275,14 +312,16 @@ export class ButtonActionExecutor {
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
window.dispatchEvent(new CustomEvent("beforeFormSave", {
detail: {
formData: context.formData
}
}));
window.dispatchEvent(
new CustomEvent("beforeFormSave", {
detail: {
formData: context.formData,
},
}),
);
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
@ -294,33 +333,41 @@ export class ButtonActionExecutor {
key,
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem: Array.isArray(value) && value.length > 0 ? {
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
keys: Object.keys(value[0] || {})
} : null
}))
firstItem:
Array.isArray(value) && value.length > 0
? {
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
keys: Object.keys(value[0] || {}),
}
: null,
})),
});
// 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정)
if (Array.isArray(context.formData)) {
console.log("⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀");
console.log(
"⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀",
);
console.log("⚠️ [handleSave] formData 배열:", context.formData);
// ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀
return true; // 성공으로 반환
}
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
const selectedItemsKeys = Object.keys(context.formData).filter((key) => {
const value = context.formData[key];
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem: Array.isArray(value) && value.length > 0 ? {
keys: Object.keys(value[0] || {}),
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
actualValue: value[0],
} : null
firstItem:
Array.isArray(value) && value.length > 0
? {
keys: Object.keys(value[0] || {}),
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
actualValue: value[0],
}
: null,
});
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
});
@ -633,7 +680,7 @@ export class ButtonActionExecutor {
private static async handleBatchSave(
config: ButtonActionConfig,
context: ButtonActionContext,
selectedItemsKeys: string[]
selectedItemsKeys: string[],
): Promise<boolean> {
const { formData, tableName, screenId, selectedRowsData, originalData } = context;
@ -661,10 +708,10 @@ export class ButtonActionExecutor {
// 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기
// (여러 단계 모달에서 전달된 데이터 접근용)
let modalDataStoreRegistry: Record<string, any[]> = {};
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
try {
// Zustand store에서 데이터 가져오기
const { useModalDataStore } = await import('@/stores/modalDataStore');
const { useModalDataStore } = await import("@/stores/modalDataStore");
modalDataStoreRegistry = useModalDataStore.getState().dataRegistry;
} catch (error) {
console.warn("⚠️ modalDataStore 로드 실패:", error);
@ -676,11 +723,10 @@ export class ButtonActionExecutor {
Object.entries(modalDataStoreRegistry).forEach(([key, items]) => {
if (Array.isArray(items) && items.length > 0) {
// ModalDataItem[] → originalData 추출
modalDataStore[key] = items.map(item => item.originalData || item);
modalDataStore[key] = items.map((item) => item.originalData || item);
}
});
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
for (const key of selectedItemsKeys) {
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
@ -699,26 +745,24 @@ export class ButtonActionExecutor {
const groupKeys = Object.keys(item.fieldGroups);
// 각 그룹의 항목 배열 가져오기
const groupArrays = groupKeys.map(groupKey => ({
const groupArrays = groupKeys.map((groupKey) => ({
groupKey,
entries: item.fieldGroups[groupKey] || []
entries: item.fieldGroups[groupKey] || [],
}));
// 카티션 곱 계산 함수
const cartesianProduct = (arrays: any[][]): any[][] => {
if (arrays.length === 0) return [[]];
if (arrays.length === 1) return arrays[0].map(item => [item]);
if (arrays.length === 1) return arrays[0].map((item) => [item]);
const [first, ...rest] = arrays;
const restProduct = cartesianProduct(rest);
return first.flatMap(item =>
restProduct.map(combination => [item, ...combination])
);
return first.flatMap((item) => restProduct.map((combination) => [item, ...combination]));
};
// 모든 그룹의 카티션 곱 생성
const entryArrays = groupArrays.map(g => g.entries);
const entryArrays = groupArrays.map((g) => g.entries);
const combinations = cartesianProduct(entryArrays);
// 각 조합을 개별 레코드로 저장
@ -1107,7 +1151,7 @@ export class ButtonActionExecutor {
if (!dataSourceId && context.allComponents) {
// TableList 우선 감지
const tableListComponent = context.allComponents.find(
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName,
);
if (tableListComponent) {
@ -1119,7 +1163,7 @@ export class ButtonActionExecutor {
} else {
// TableList가 없으면 SplitPanelLayout의 좌측 패널 감지
const splitPanelComponent = context.allComponents.find(
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName,
);
if (splitPanelComponent) {
@ -1477,7 +1521,7 @@ export class ButtonActionExecutor {
comp.type === "screen-split-panel" ||
comp.componentType === "screen-split-panel" ||
comp.type === "split-panel-layout" ||
comp.componentType === "split-panel-layout"
comp.componentType === "split-panel-layout",
);
}
console.log("🔍 [openEditModal] 분할 패널 확인:", {
@ -1632,7 +1676,8 @@ export class ButtonActionExecutor {
if (copiedData[field] !== undefined) {
const originalValue = copiedData[field];
const ruleIdKey = `${field}_numberingRuleId`;
const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
const hasNumberingRule =
rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
// 품목코드를 무조건 공백으로 초기화
copiedData[field] = "";
@ -2637,7 +2682,7 @@ export class ButtonActionExecutor {
if (Array.isArray(response)) {
// 배열로 직접 반환된 경우
dataToExport = response;
} else if (response && 'data' in response) {
} else if (response && "data" in response) {
// EntityJoinResponse 객체인 경우
dataToExport = response.data;
} else {
@ -2691,102 +2736,99 @@ export class ButtonActionExecutor {
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
let visibleColumns: string[] | undefined = undefined;
let columnLabels: Record<string, string> | undefined = undefined;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
let visibleColumns: string[] | undefined = undefined;
let columnLabels: Record<string, string> | undefined = undefined;
try {
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
const { apiClient } = await import("@/lib/api/client");
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
if (layoutResponse.data?.success && layoutResponse.data?.data) {
let layoutData = layoutResponse.data.data;
// components가 문자열이면 파싱
if (typeof layoutData.components === "string") {
layoutData.components = JSON.parse(layoutData.components);
}
// 테이블 리스트 컴포넌트 찾기
const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null;
for (const comp of components) {
// componentType이 'table-list'인지 확인
const isTableList = comp.componentType === "table-list";
// componentConfig 안에서 테이블명 확인
const matchesTable =
comp.componentConfig?.selectedTable === context.tableName ||
comp.componentConfig?.tableName === context.tableName;
if (isTableList && matchesTable) {
return comp;
}
if (comp.children && comp.children.length > 0) {
const found = findTableListComponent(comp.children);
if (found) return found;
}
}
return null;
};
const tableListComponent = findTableListComponent(layoutData.components || []);
if (tableListComponent && tableListComponent.componentConfig?.columns) {
const columns = tableListComponent.componentConfig.columns;
// visible이 true인 컬럼만 추출
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
try {
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
const { apiClient } = await import("@/lib/api/client");
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 },
});
if (layoutResponse.data?.success && layoutResponse.data?.data) {
let layoutData = layoutResponse.data.data;
if (columnsResponse.data?.success && columnsResponse.data?.data) {
let columnData = columnsResponse.data.data;
// components가 문자열이면 파싱
if (typeof layoutData.components === 'string') {
layoutData.components = JSON.parse(layoutData.components);
// data가 객체이고 columns 필드가 있으면 추출
if (columnData.columns && Array.isArray(columnData.columns)) {
columnData = columnData.columns;
}
// 테이블 리스트 컴포넌트 찾기
const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null;
if (Array.isArray(columnData)) {
columnLabels = {};
for (const comp of components) {
// componentType이 'table-list'인지 확인
const isTableList = comp.componentType === 'table-list';
// componentConfig 안에서 테이블명 확인
const matchesTable =
comp.componentConfig?.selectedTable === context.tableName ||
comp.componentConfig?.tableName === context.tableName;
if (isTableList && matchesTable) {
return comp;
// API에서 가져온 라벨로 매핑
columnData.forEach((colData: any) => {
const colName = colData.column_name || colData.columnName;
// 우선순위: column_label > label > displayName > columnName
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
if (colName && labelValue) {
columnLabels![colName] = labelValue;
}
if (comp.children && comp.children.length > 0) {
const found = findTableListComponent(comp.children);
if (found) return found;
}
}
return null;
};
const tableListComponent = findTableListComponent(layoutData.components || []);
if (tableListComponent && tableListComponent.componentConfig?.columns) {
const columns = tableListComponent.componentConfig.columns;
// visible이 true인 컬럼만 추출
visibleColumns = columns
.filter((col: any) => col.visible !== false)
.map((col: any) => col.columnName);
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 }
});
if (columnsResponse.data?.success && columnsResponse.data?.data) {
let columnData = columnsResponse.data.data;
// data가 객체이고 columns 필드가 있으면 추출
if (columnData.columns && Array.isArray(columnData.columns)) {
columnData = columnData.columns;
}
if (Array.isArray(columnData)) {
columnLabels = {};
// API에서 가져온 라벨로 매핑
columnData.forEach((colData: any) => {
const colName = colData.column_name || colData.columnName;
// 우선순위: column_label > label > displayName > columnName
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
if (colName && labelValue) {
columnLabels![colName] = labelValue;
}
});
}
}
} catch (error) {
// 실패 시 컴포넌트 설정의 displayName 사용
columnLabels = {};
columns.forEach((col: any) => {
if (col.columnName) {
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
}
});
}
} else {
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
});
}
}
} catch (error) {
console.error("❌ 화면 레이아웃 조회 실패:", error);
// 실패 시 컴포넌트 설정의 displayName 사용
columnLabels = {};
columns.forEach((col: any) => {
if (col.columnName) {
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
}
});
}
} else {
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
}
}
} catch (error) {
console.error("❌ 화면 레이아웃 조회 실패:", error);
}
// 🎨 카테고리 값들 조회 (한 번만)
const categoryMap: Record<string, Record<string, string>> = {};
@ -2801,9 +2843,9 @@ export class ButtonActionExecutor {
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
// 백엔드에서 정의된 카테고리 컬럼들
categoryColumns = categoryColumnsResponse.data.map((col: any) =>
col.column_name || col.columnName || col.name
).filter(Boolean); // undefined 제거
categoryColumns = categoryColumnsResponse.data
.map((col: any) => col.column_name || col.columnName || col.name)
.filter(Boolean); // undefined 제거
// 각 카테고리 컬럼의 값들 조회
for (const columnName of categoryColumns) {
@ -2820,7 +2862,6 @@ export class ButtonActionExecutor {
categoryMap[columnName][code] = label;
}
});
}
} catch (error) {
console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error);
@ -2849,15 +2890,15 @@ export class ButtonActionExecutor {
let value = row[columnName];
// writer → writer_name 사용
if (columnName === 'writer' && row['writer_name']) {
value = row['writer_name'];
if (columnName === "writer" && row["writer_name"]) {
value = row["writer_name"];
}
// 다른 엔티티 필드들도 _name 우선 사용
else if (row[`${columnName}_name`]) {
value = row[`${columnName}_name`];
}
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) {
else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) {
value = categoryMap[columnName][value];
}
@ -2867,7 +2908,6 @@ export class ButtonActionExecutor {
return filteredRow;
});
}
// 최대 행 수 제한
@ -3142,12 +3182,12 @@ export class ButtonActionExecutor {
const confirmMerge = confirm(
`⚠️ 코드 병합 확인\n\n` +
`${oldValue}${newValue}\n\n` +
`영향받는 데이터:\n` +
`- 테이블 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
`계속하시겠습니까?`
`${oldValue}${newValue}\n\n` +
`영향받는 데이터:\n` +
`- 테이블 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
`계속하시겠습니까?`,
);
if (!confirmMerge) {
@ -3172,8 +3212,7 @@ export class ButtonActionExecutor {
if (response.data.success) {
const data = response.data.data;
toast.success(
`코드 병합 완료!\n` +
`${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`
`코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`,
);
// 화면 새로고침
@ -3289,6 +3328,318 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
return false;
}
// 위도/경도 저장 필드 확인
const latField = config.geolocationLatField;
const lngField = config.geolocationLngField;
if (!latField || !lngField) {
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
return false;
}
// 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
// Geolocation 옵션 설정
const options: PositionOptions = {
enableHighAccuracy: config.geolocationHighAccuracy !== false, // 기본 true
timeout: config.geolocationTimeout || 10000, // 기본 10초
maximumAge: config.geolocationMaxAge || 0, // 기본 0 (항상 새로운 위치)
};
// 위치 정보 가져오기 (Promise로 래핑)
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options);
});
// 로딩 토스트 제거
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy, altitude, heading, speed } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득 성공:", {
latitude,
longitude,
accuracy,
timestamp: timestamp.toISOString(),
});
// 폼 데이터 업데이트
const updates: Record<string, any> = {
[latField]: latitude,
[lngField]: longitude,
};
// 선택적 필드들
if (config.geolocationAccuracyField && accuracy !== null) {
updates[config.geolocationAccuracyField] = accuracy;
}
if (config.geolocationTimestampField) {
updates[config.geolocationTimestampField] = timestamp.toISOString();
}
// 🆕 추가 필드 변경 (위치정보 + 상태변경)
let extraTableUpdated = false;
if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
const extraTableName = config.geolocationExtraTableName;
const currentTableName = config.geolocationTableName || context.tableName;
// 다른 테이블에 UPDATE하는 경우
if (extraTableName && extraTableName !== currentTableName) {
console.log("📍 다른 테이블 필드 변경:", {
targetTable: extraTableName,
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
keyField: config.geolocationExtraKeyField,
keySourceField: config.geolocationExtraKeySourceField,
});
// 키 값 가져오기
const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""];
if (keyValue && config.geolocationExtraKeyField) {
try {
// 다른 테이블 UPDATE API 호출
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: extraTableName,
keyField: config.geolocationExtraKeyField,
keyValue: keyValue,
updateField: config.geolocationExtraField,
updateValue: config.geolocationExtraValue,
});
if (response.data?.success) {
extraTableUpdated = true;
console.log("✅ 다른 테이블 UPDATE 성공:", response.data);
} else {
console.error("❌ 다른 테이블 UPDATE 실패:", response.data);
toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`);
}
} catch (apiError) {
console.error("❌ 다른 테이블 UPDATE API 오류:", apiError);
toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`);
}
} else {
console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", {
keySourceField: config.geolocationExtraKeySourceField,
keyValue,
});
}
} else {
// 같은 테이블 (현재 폼 데이터에 추가)
updates[config.geolocationExtraField] = config.geolocationExtraValue;
console.log("📍 같은 테이블 추가 필드 변경:", {
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
});
}
}
// formData 업데이트
if (context.onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => {
context.onFormDataChange?.(field, value);
});
}
// 성공 메시지 생성
let successMsg =
config.successMessage ||
`위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`;
// 추가 필드 변경이 있으면 메시지에 포함
if (config.geolocationUpdateField && config.geolocationExtraField) {
if (extraTableUpdated) {
successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
} else if (
!config.geolocationExtraTableName ||
config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)
) {
successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
}
}
// 성공 메시지 표시
toast.success(successMsg);
// 자동 저장 옵션이 활성화된 경우
if (config.geolocationAutoSave && context.onSave) {
console.log("📍 위치정보 자동 저장 실행");
try {
await context.onSave();
toast.success("위치 정보가 저장되었습니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
}
}
return true;
} catch (error: any) {
console.error("❌ 위치정보 가져오기 실패:", error);
toast.dismiss();
// GeolocationPositionError 처리
if (error.code) {
switch (error.code) {
case 1: // PERMISSION_DENIED
toast.error("위치 정보 접근이 거부되었습니다.\n브라우저 설정에서 위치 권한을 허용해주세요.");
break;
case 2: // POSITION_UNAVAILABLE
toast.error("위치 정보를 사용할 수 없습니다.\nGPS 신호를 확인해주세요.");
break;
case 3: // TIMEOUT
toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요.");
break;
default:
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다.");
}
} else {
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다.");
}
return false;
}
}
/**
* (: status를 active로 )
*/
private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
const { formData, tableName, onFormDataChange, onSave } = context;
// 변경할 필드 확인
const targetField = config.updateTargetField;
const targetValue = config.updateTargetValue;
const multipleFields = config.updateMultipleFields || [];
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
if (!targetField && multipleFields.length === 0) {
toast.error("변경할 필드가 설정되지 않았습니다.");
return false;
}
// 확인 메시지 표시 (설정된 경우)
if (config.confirmMessage) {
const confirmed = window.confirm(config.confirmMessage);
if (!confirmed) {
console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)");
return false;
}
}
// 변경할 필드 목록 구성
const updates: Record<string, any> = {};
// 단일 필드 변경
if (targetField && targetValue !== undefined) {
updates[targetField] = targetValue;
}
// 다중 필드 변경
multipleFields.forEach(({ field, value }) => {
updates[field] = value;
});
console.log("🔄 변경할 필드들:", updates);
// formData 업데이트
if (onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => {
onFormDataChange(field, value);
});
}
// 자동 저장 (기본값: true)
const autoSave = config.updateAutoSave !== false;
if (autoSave) {
// onSave 콜백이 있으면 사용
if (onSave) {
console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)");
try {
await onSave();
toast.success(config.successMessage || "상태가 변경되었습니다.");
return true;
} catch (saveError) {
console.error("❌ 필드 값 변경 저장 실패:", saveError);
toast.error(config.errorMessage || "상태 변경 저장에 실패했습니다.");
return false;
}
}
// API를 통한 직접 저장
if (tableName && formData) {
console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)");
try {
// PK 필드 찾기 (id 또는 테이블명_id)
const pkField = formData.id !== undefined ? "id" : `${tableName}_id`;
const pkValue = formData[pkField] || formData.id;
if (!pkValue) {
toast.error("레코드 ID를 찾을 수 없습니다.");
return false;
}
// 업데이트할 데이터 구성 (변경할 필드들만)
const updateData = {
...updates,
[pkField]: pkValue, // PK 포함
};
const response = await DynamicFormApi.updateData(tableName, updateData);
if (response.success) {
toast.success(config.successMessage || "상태가 변경되었습니다.");
// 테이블 새로고침 이벤트 발생
window.dispatchEvent(
new CustomEvent("refreshTableData", {
detail: { tableName },
}),
);
return true;
} else {
toast.error(response.message || config.errorMessage || "상태 변경에 실패했습니다.");
return false;
}
} catch (apiError) {
console.error("❌ 필드 값 변경 API 호출 실패:", apiError);
toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다.");
return false;
}
}
}
// 자동 저장이 비활성화된 경우 폼 데이터만 변경
toast.success(config.successMessage || "필드 값이 변경되었습니다. 저장 버튼을 눌러 저장하세요.");
return true;
} catch (error) {
console.error("❌ 필드 값 변경 실패:", error);
toast.error(config.errorMessage || "필드 값 변경 중 오류가 발생했습니다.");
return false;
}
}
/**
*
*/
@ -3397,4 +3748,21 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "데이터가 전달되었습니다.",
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
},
geolocation: {
type: "geolocation",
geolocationHighAccuracy: true,
geolocationTimeout: 10000,
geolocationMaxAge: 0,
geolocationAutoSave: false,
confirmMessage: "현재 위치 정보를 가져오시겠습니까?",
successMessage: "위치 정보를 가져왔습니다.",
errorMessage: "위치 정보를 가져오는 중 오류가 발생했습니다.",
},
update_field: {
type: "update_field",
updateAutoSave: true,
confirmMessage: "상태를 변경하시겠습니까?",
successMessage: "상태가 변경되었습니다.",
errorMessage: "상태 변경 중 오류가 발생했습니다.",
},
};