버튼 액션중 위치정보 가져오기, 필드값 변경 추가 #228
|
|
@ -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 || "필드 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 필드를 "active"로 변경
|
||||
<br />
|
||||
- 승인 버튼: approval_status 필드를 "approved"로 변경
|
||||
<br />
|
||||
- 완료 버튼: is_completed 필드를 "Y"로 변경
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전달 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "transferData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
|
|
|
|||
|
|
@ -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 속성 분리하여 충돌 방지)
|
||||
|
|
|
|||
|
|
@ -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"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -24,6 +24,9 @@ export type ButtonActionType =
|
|||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan" // 바코드 스캔
|
||||
| "code_merge" // 코드 병합
|
||||
| "geolocation" // 위치정보 가져오기
|
||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "update_field" // 특정 필드 값 변경 (예: status를 active로)
|
||||
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
|
||||
/**
|
||||
|
|
@ -91,6 +94,34 @@ export interface ButtonActionConfig {
|
|||
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
||||
|
||||
// 위치정보 관련
|
||||
geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블)
|
||||
geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude")
|
||||
geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude")
|
||||
geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy")
|
||||
geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time")
|
||||
geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true)
|
||||
geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000)
|
||||
geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0)
|
||||
geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false)
|
||||
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
|
||||
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
|
||||
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
|
||||
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
|
||||
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
|
||||
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
|
||||
|
||||
// 필드 값 교환 관련 (출발지 ↔ 목적지)
|
||||
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
|
||||
swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination")
|
||||
swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도)
|
||||
|
||||
// 필드 값 변경 관련 (특정 필드를 특정 값으로 변경)
|
||||
updateTargetField?: string; // 변경할 필드명 (예: "status")
|
||||
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
|
||||
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
|
||||
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
|
||||
|
||||
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
|
||||
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
||||
editModalTitle?: string; // 편집 모달 제목
|
||||
|
|
@ -237,6 +268,12 @@ export class ButtonActionExecutor {
|
|||
case "code_merge":
|
||||
return await this.handleCodeMerge(config, context);
|
||||
|
||||
case "geolocation":
|
||||
return await this.handleGeolocation(config, context);
|
||||
|
||||
case "update_field":
|
||||
return await this.handleUpdateField(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -3190,6 +3227,312 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치정보 가져오기 액션 처리
|
||||
*/
|
||||
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 유효성 검사
|
||||
*/
|
||||
|
|
@ -3293,4 +3636,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: "상태 변경 중 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue