버튼 액션중 위치정보 가져오기, 필드값 변경 추가
This commit is contained in:
parent
f1ff835a45
commit
67e6a8008d
|
|
@ -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,
|
saveFormDataEnhanced,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
updateFormDataPartial,
|
updateFormDataPartial,
|
||||||
|
updateFieldValue,
|
||||||
deleteFormData,
|
deleteFormData,
|
||||||
getFormData,
|
getFormData,
|
||||||
getFormDataList,
|
getFormDataList,
|
||||||
|
|
@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||||
router.put("/:id", updateFormData);
|
router.put("/:id", updateFormData);
|
||||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||||
|
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
|
||||||
router.delete("/:id", deleteFormData);
|
router.delete("/:id", deleteFormData);
|
||||||
router.get("/:id", getFormData);
|
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 { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
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
|
// 싱글톤 인스턴스 생성 및 export
|
||||||
|
|
|
||||||
|
|
@ -996,6 +996,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// console.log("🔧 기본 해상도 적용:", defaultResolution);
|
// 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);
|
setLayout(layoutWithDefaultGrid);
|
||||||
setHistory([layoutWithDefaultGrid]);
|
setHistory([layoutWithDefaultGrid]);
|
||||||
setHistoryIndex(0);
|
setHistoryIndex(0);
|
||||||
|
|
@ -1453,7 +1464,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||||
const buttonComponents = layoutWithResolution.components.filter(
|
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("💾 저장 시작:", {
|
console.log("💾 저장 시작:", {
|
||||||
screenId: selectedScreen.screenId,
|
screenId: selectedScreen.screenId,
|
||||||
|
|
@ -1463,6 +1474,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
buttonComponents: buttonComponents.map((c: any) => ({
|
buttonComponents: buttonComponents.map((c: any) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
|
componentType: c.componentType,
|
||||||
text: c.componentConfig?.text,
|
text: c.componentConfig?.text,
|
||||||
actionType: c.componentConfig?.action?.type,
|
actionType: c.componentConfig?.action?.type,
|
||||||
fullAction: c.componentConfig?.action,
|
fullAction: c.componentConfig?.action,
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,40 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
fetchTableColumns();
|
fetchTableColumns();
|
||||||
}, [config.action?.type, config.action?.historyTableName, currentTableName]);
|
}, [config.action?.type, config.action?.historyTableName, currentTableName]);
|
||||||
|
|
||||||
|
// 🆕 geolocation/update_field 테이블 컬럼 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const actionType = config.action?.type;
|
||||||
|
|
||||||
|
// geolocation 액션일 때
|
||||||
|
if (actionType === "geolocation") {
|
||||||
|
// 위치정보 저장 테이블 컬럼 로드
|
||||||
|
const tableName = config.action?.geolocationTableName || currentTableName;
|
||||||
|
if (tableName) {
|
||||||
|
loadTableColumns(tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 추가 필드 변경용 대상 테이블 컬럼도 로드
|
||||||
|
const extraTableName = config.action?.geolocationExtraTableName;
|
||||||
|
if (extraTableName) {
|
||||||
|
loadTableColumns(extraTableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update_field 액션일 때
|
||||||
|
if (actionType === "update_field") {
|
||||||
|
const tableName = config.action?.updateTableName || currentTableName;
|
||||||
|
if (tableName) {
|
||||||
|
loadTableColumns(tableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
config.action?.type,
|
||||||
|
config.action?.geolocationTableName,
|
||||||
|
config.action?.geolocationExtraTableName, // 🔥 추가
|
||||||
|
config.action?.updateTableName,
|
||||||
|
currentTableName,
|
||||||
|
]);
|
||||||
|
|
||||||
// 검색 필터링 함수
|
// 검색 필터링 함수
|
||||||
const filterScreens = (searchTerm: string) => {
|
const filterScreens = (searchTerm: string) => {
|
||||||
if (!searchTerm.trim()) return screens;
|
if (!searchTerm.trim()) return screens;
|
||||||
|
|
@ -442,6 +476,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||||
|
<SelectItem value="geolocation">위치정보 가져오기</SelectItem>
|
||||||
|
<SelectItem value="update_field">필드 값 변경</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -508,8 +544,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={modalScreenOpen}
|
aria-expanded={modalScreenOpen}
|
||||||
className="h-6 w-full justify-between px-2 py-0"
|
className="h-6 w-full justify-between px-2 py-0 text-xs"
|
||||||
className="text-xs"
|
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
|
|
@ -899,8 +934,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={modalScreenOpen}
|
aria-expanded={modalScreenOpen}
|
||||||
className="h-6 w-full justify-between px-2 py-0"
|
className="h-6 w-full justify-between px-2 py-0 text-xs"
|
||||||
className="text-xs"
|
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
|
|
@ -977,8 +1011,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={modalScreenOpen}
|
aria-expanded={modalScreenOpen}
|
||||||
className="h-6 w-full justify-between px-2 py-0"
|
className="h-6 w-full justify-between px-2 py-0 text-xs"
|
||||||
className="text-xs"
|
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
|
|
@ -1131,8 +1164,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={modalScreenOpen}
|
aria-expanded={modalScreenOpen}
|
||||||
className="h-6 w-full justify-between px-2 py-0"
|
className="h-6 w-full justify-between px-2 py-0 text-xs"
|
||||||
className="text-xs"
|
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
|
|
@ -1347,8 +1379,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={navScreenOpen}
|
aria-expanded={navScreenOpen}
|
||||||
className="h-6 w-full justify-between px-2 py-0"
|
className="h-6 w-full justify-between px-2 py-0 text-xs"
|
||||||
className="text-xs"
|
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
|
|
@ -1601,6 +1632,489 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</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>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationLatField || ""}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationLatField", value)}
|
||||||
|
disabled={!config.action?.geolocationTableName && !currentTableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-lng-field">
|
||||||
|
경도 저장 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationLngField || ""}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationLngField", value)}
|
||||||
|
disabled={!config.action?.geolocationTableName && !currentTableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-accuracy-field">정확도 저장 필드 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationAccuracyField || "__NONE__"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", value === "__NONE__" ? "" : value)}
|
||||||
|
disabled={!config.action?.geolocationTableName && !currentTableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__NONE__" className="text-xs text-muted-foreground">선택 안함</SelectItem>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-timestamp-field">타임스탬프 저장 필드 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationTimestampField || "__NONE__"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationTimestampField", value === "__NONE__" ? "" : value)}
|
||||||
|
disabled={!config.action?.geolocationTableName && !currentTableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__NONE__" className="text-xs text-muted-foreground">선택 안함</SelectItem>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-timeout">타임아웃 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
id="geolocation-timeout"
|
||||||
|
type="number"
|
||||||
|
placeholder="10000"
|
||||||
|
value={config.action?.geolocationTimeout || 10000}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimeout", parseInt(e.target.value) || 10000)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-max-age">캐시 최대 수명 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
id="geolocation-max-age"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={config.action?.geolocationMaxAge || 0}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationMaxAge", parseInt(e.target.value) || 0)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">0 = 항상 새로운 위치</p>
|
||||||
|
</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="border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="geolocation-update-field">추가 필드 값 변경</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">위치정보와 함께 다른 테이블/필드 값도 변경합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="geolocation-update-field"
|
||||||
|
checked={config.action?.geolocationUpdateField === true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationUpdateField", checked);
|
||||||
|
if (!checked) {
|
||||||
|
// 비활성화 시 관련 필드 초기화
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraTableName", "");
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraField", "");
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraValue", "");
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraKeyField", "");
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.geolocationUpdateField && (
|
||||||
|
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
||||||
|
{/* 대상 테이블 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-extra-table" className="text-xs">
|
||||||
|
대상 테이블 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationExtraTableName || currentTableName || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraTableName", value);
|
||||||
|
// 테이블 변경 시 필드 초기화
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraField", "");
|
||||||
|
onUpdateProperty("componentConfig.action.geolocationExtraKeyField", "");
|
||||||
|
// 해당 테이블 컬럼 로드
|
||||||
|
loadTableColumns(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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-[10px] text-amber-700 dark:text-amber-300">
|
||||||
|
다른 테이블 선택 시 해당 테이블의 레코드를 UPDATE합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다른 테이블 선택 시 연결 키 설정 */}
|
||||||
|
{config.action?.geolocationExtraTableName &&
|
||||||
|
config.action?.geolocationExtraTableName !== (config.action?.geolocationTableName || currentTableName) && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded border border-amber-300 bg-amber-100/50 p-2 dark:border-amber-700 dark:bg-amber-900/30">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-extra-key-source" className="text-xs">
|
||||||
|
현재 테이블 키 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationExtraKeySourceField || ""}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="키 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-0.5 text-[10px] text-amber-600 dark:text-amber-400">예: vehicle_id</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-extra-key-field" className="text-xs">
|
||||||
|
대상 테이블 키 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationExtraKeyField || ""}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraKeyField", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="키 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationExtraTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-0.5 text-[10px] text-amber-600 dark:text-amber-400">예: vehicle_id</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 변경할 필드와 값 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-extra-field" className="text-xs">
|
||||||
|
변경할 필드 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.geolocationExtraField || ""}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraField", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableColumnsMap[config.action?.geolocationExtraTableName || config.action?.geolocationTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="geolocation-extra-value" className="text-xs">
|
||||||
|
변경할 값 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="geolocation-extra-value"
|
||||||
|
placeholder="예: active, Y, 1"
|
||||||
|
value={config.action?.geolocationExtraValue || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-amber-800 dark:text-amber-200">
|
||||||
|
{config.action?.geolocationExtraTableName &&
|
||||||
|
config.action?.geolocationExtraTableName !== (config.action?.geolocationTableName || currentTableName)
|
||||||
|
? `다른 테이블(${config.action?.geolocationExtraTableName})의 레코드를 UPDATE합니다`
|
||||||
|
: "예: status 필드를 \"active\"로 변경하여 운행 시작 상태로 표시"}
|
||||||
|
</p>
|
||||||
|
</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. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다
|
||||||
|
<br />
|
||||||
|
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
|
||||||
|
{config.action?.geolocationUpdateField && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
4. 추가로 지정한 필드 값도 함께 변경됩니다
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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", "");
|
||||||
|
// 테이블 컬럼 로드
|
||||||
|
loadTableColumns(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<Select
|
||||||
|
value={config.action?.updateTargetField || ""}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateTargetField", value)}
|
||||||
|
disabled={!config.action?.updateTableName && !currentTableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(tableColumnsMap[config.action?.updateTableName || currentTableName || ""] || []).map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||||
|
{col.label || col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 제어 기능 섹션 */}
|
{/* 제어 기능 섹션 */}
|
||||||
<div className="mt-8 border-t border-border pt-6">
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 토스트 정리를 위한 ref
|
// 토스트 정리를 위한 ref
|
||||||
const currentLoadingToastRef = useRef<string | number | undefined>();
|
const currentLoadingToastRef = useRef<string | number | undefined>(undefined);
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 토스트 정리
|
// 컴포넌트 언마운트 시 토스트 정리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -190,9 +190,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
|
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
|
||||||
|
|
||||||
// 컴포넌트 설정
|
// 컴포넌트 설정
|
||||||
|
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
...component.config,
|
...component.config,
|
||||||
|
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
||||||
} as ButtonPrimaryConfig;
|
} as ButtonPrimaryConfig;
|
||||||
|
|
||||||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||||
|
|
@ -227,13 +229,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
// 스타일 계산
|
// 스타일 계산
|
||||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||||
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
|
||||||
// 🆕 탭 컴포넌트
|
// 🆕 탭 컴포넌트
|
||||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||||
|
|
||||||
|
// 🆕 반복 화면 모달 컴포넌트
|
||||||
|
import "./repeat-screen-modal/RepeatScreenModalRenderer";
|
||||||
|
|
||||||
|
// 🆕 출발지/도착지 선택 컴포넌트
|
||||||
|
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface ContentRow {
|
||||||
|
label?: string;
|
||||||
|
field?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataSource {
|
||||||
|
filterField?: string;
|
||||||
|
sourceTable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Grouping {
|
||||||
|
enabled?: boolean;
|
||||||
|
aggregations?: any[];
|
||||||
|
groupField?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableLayout {
|
||||||
|
headerRows?: any[];
|
||||||
|
tableColumns?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardLayoutItem {
|
||||||
|
field?: string;
|
||||||
|
label?: string;
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepeatScreenModalProps {
|
||||||
|
// 기본 props
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
// 컴포넌트 설정
|
||||||
|
cardMode?: "simple" | "detailed";
|
||||||
|
cardSpacing?: string;
|
||||||
|
cardTitle?: string;
|
||||||
|
contentRows?: ContentRow[];
|
||||||
|
dataSource?: DataSource;
|
||||||
|
grouping?: Grouping;
|
||||||
|
saveMode?: "all" | "single";
|
||||||
|
showCardBorder?: boolean;
|
||||||
|
showCardTitle?: boolean;
|
||||||
|
tableLayout?: TableLayout;
|
||||||
|
cardLayout?: CardLayoutItem[];
|
||||||
|
|
||||||
|
// 컴포넌트 config (componentConfig에서 전달됨)
|
||||||
|
componentConfig?: {
|
||||||
|
type?: string;
|
||||||
|
webType?: string;
|
||||||
|
cardMode?: string;
|
||||||
|
cardSpacing?: string;
|
||||||
|
cardTitle?: string;
|
||||||
|
contentRows?: ContentRow[];
|
||||||
|
dataSource?: DataSource;
|
||||||
|
grouping?: Grouping;
|
||||||
|
saveMode?: string;
|
||||||
|
showCardBorder?: boolean;
|
||||||
|
showCardTitle?: boolean;
|
||||||
|
tableLayout?: TableLayout;
|
||||||
|
cardLayout?: CardLayoutItem[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepeatScreenModal 컴포넌트
|
||||||
|
* 카드/테이블 형태로 데이터를 반복 표시하고 편집할 수 있는 모달
|
||||||
|
*/
|
||||||
|
export function RepeatScreenModalComponent(props: RepeatScreenModalProps) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
label = "반복 화면 모달",
|
||||||
|
style,
|
||||||
|
componentConfig,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// componentConfig에서 설정 가져오기
|
||||||
|
const config = componentConfig || {};
|
||||||
|
const {
|
||||||
|
cardMode = "simple",
|
||||||
|
cardSpacing = "24px",
|
||||||
|
cardTitle = "",
|
||||||
|
contentRows = [],
|
||||||
|
dataSource,
|
||||||
|
grouping,
|
||||||
|
saveMode = "all",
|
||||||
|
showCardBorder = true,
|
||||||
|
showCardTitle = true,
|
||||||
|
tableLayout,
|
||||||
|
cardLayout = [],
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// 스타일에서 width, height 추출
|
||||||
|
const { width, height, ...restStyle } = style || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className="flex h-full w-full flex-col overflow-auto"
|
||||||
|
style={restStyle}
|
||||||
|
>
|
||||||
|
<Card className={`h-full w-full ${showCardBorder ? "" : "border-0 shadow-none"}`}>
|
||||||
|
{showCardTitle && (
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg">{label}</CardTitle>
|
||||||
|
{cardTitle && (
|
||||||
|
<p className="text-sm text-muted-foreground">{cardTitle}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className="flex-1 overflow-auto">
|
||||||
|
{/* 데이터 소스 정보 표시 */}
|
||||||
|
{dataSource?.sourceTable && (
|
||||||
|
<div className="mb-4 rounded-md bg-muted p-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">데이터 소스:</span>{" "}
|
||||||
|
{dataSource.sourceTable}
|
||||||
|
</p>
|
||||||
|
{dataSource.filterField && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">필터 필드:</span>{" "}
|
||||||
|
{dataSource.filterField}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 레이아웃 표시 */}
|
||||||
|
{cardLayout && cardLayout.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="grid gap-4"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, minmax(250px, 1fr))`,
|
||||||
|
gap: cardSpacing,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardLayout.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-md border bg-card p-3"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.label || item.field}</p>
|
||||||
|
<p className="text-sm font-medium">{item.field || "-"}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컨텐츠 행 표시 */}
|
||||||
|
{contentRows && contentRows.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{contentRows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between rounded-md border p-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{row.label || row.field || `Row ${index + 1}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{row.field || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 레이아웃이 있으면 테이블 형태로 표시 */}
|
||||||
|
{tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && (
|
||||||
|
<div className="mt-4 overflow-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted">
|
||||||
|
{tableLayout.tableColumns.map((col: any, index: number) => (
|
||||||
|
<th
|
||||||
|
key={index}
|
||||||
|
className="border px-3 py-2 text-left text-sm font-medium"
|
||||||
|
>
|
||||||
|
{col.label || col.field || `Column ${index + 1}`}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={tableLayout.tableColumns.length}
|
||||||
|
className="border px-3 py-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
데이터가 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 빈 상태 표시 */}
|
||||||
|
{(!contentRows || contentRows.length === 0) &&
|
||||||
|
(!cardLayout || cardLayout.length === 0) &&
|
||||||
|
(!tableLayout?.tableColumns || tableLayout.tableColumns.length === 0) && (
|
||||||
|
<div className="flex h-48 items-center justify-center rounded-md border-2 border-dashed">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
반복 화면 모달
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
컴포넌트 설정을 구성해주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹핑 정보 */}
|
||||||
|
{grouping?.enabled && (
|
||||||
|
<div className="mt-4 rounded-md bg-blue-50 p-2 text-xs text-blue-700">
|
||||||
|
그룹핑 활성화됨 {grouping.groupField && `(${grouping.groupField})`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 저장 모드 표시 */}
|
||||||
|
<div className="mt-4 text-xs text-muted-foreground">
|
||||||
|
저장 모드: {saveMode === "all" ? "전체 저장" : "개별 저장"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface RepeatScreenModalConfigPanelProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (config: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepeatScreenModal 설정 패널
|
||||||
|
*/
|
||||||
|
export function RepeatScreenModalConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: RepeatScreenModalConfigPanelProps) {
|
||||||
|
const handleChange = (key: string, value: any) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config?.cardMode || "simple"}
|
||||||
|
onValueChange={(value) => handleChange("cardMode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="카드 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="simple">심플</SelectItem>
|
||||||
|
<SelectItem value="detailed">상세</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드 간격</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.cardSpacing || "24px"}
|
||||||
|
onChange={(e) => handleChange("cardSpacing", e.target.value)}
|
||||||
|
placeholder="예: 24px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카드 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.cardTitle || ""}
|
||||||
|
onChange={(e) => handleChange("cardTitle", e.target.value)}
|
||||||
|
placeholder="카드 제목 (변수 사용 가능: {field_name})"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>저장 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config?.saveMode || "all"}
|
||||||
|
onValueChange={(value) => handleChange("saveMode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="저장 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 저장</SelectItem>
|
||||||
|
<SelectItem value="single">개별 저장</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>카드 테두리 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config?.showCardBorder !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showCardBorder", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>카드 제목 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config?.showCardTitle !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showCardTitle", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>그룹핑 활성화</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config?.grouping?.enabled || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange("grouping", { ...config?.grouping, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium">데이터 소스 설정</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
소스 테이블: {config?.dataSource?.sourceTable || "미설정"}
|
||||||
|
</p>
|
||||||
|
<p>필터 필드: {config?.dataSource?.filterField || "미설정"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { RepeatScreenModalDefinition } from "./index";
|
||||||
|
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepeatScreenModal 렌더러
|
||||||
|
*/
|
||||||
|
export class RepeatScreenModalRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = RepeatScreenModalDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <RepeatScreenModalComponent {...this.props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
RepeatScreenModalRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
RepeatScreenModalRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
|
||||||
|
import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepeatScreenModal 컴포넌트 정의
|
||||||
|
* 반복 화면 모달 - 카드/테이블 형태로 데이터를 표시하고 편집할 수 있는 모달
|
||||||
|
*/
|
||||||
|
export const RepeatScreenModalDefinition = createComponentDefinition({
|
||||||
|
id: "repeat-screen-modal",
|
||||||
|
name: "반복 화면 모달",
|
||||||
|
nameEng: "Repeat Screen Modal",
|
||||||
|
description: "카드/테이블 형태로 데이터를 반복 표시하고 편집할 수 있는 모달 컴포넌트",
|
||||||
|
category: ComponentCategory.DATA,
|
||||||
|
webType: "form",
|
||||||
|
component: RepeatScreenModalComponent,
|
||||||
|
defaultConfig: {
|
||||||
|
cardMode: "simple",
|
||||||
|
cardSpacing: "24px",
|
||||||
|
cardTitle: "",
|
||||||
|
contentRows: [],
|
||||||
|
dataSource: {
|
||||||
|
filterField: "",
|
||||||
|
sourceTable: "",
|
||||||
|
},
|
||||||
|
grouping: {
|
||||||
|
enabled: false,
|
||||||
|
aggregations: [],
|
||||||
|
},
|
||||||
|
saveMode: "all",
|
||||||
|
showCardBorder: true,
|
||||||
|
showCardTitle: true,
|
||||||
|
tableLayout: {
|
||||||
|
headerRows: [],
|
||||||
|
tableColumns: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultSize: { width: 1000, height: 800 },
|
||||||
|
configPanel: RepeatScreenModalConfigPanel,
|
||||||
|
icon: "LayoutGrid",
|
||||||
|
tags: ["모달", "반복", "카드", "테이블", "폼"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트 내보내기
|
||||||
|
export { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
|
||||||
|
export { RepeatScreenModalRenderer } from "./RepeatScreenModalRenderer";
|
||||||
|
|
||||||
|
|
@ -23,7 +23,10 @@ export type ButtonActionType =
|
||||||
| "excel_download" // 엑셀 다운로드
|
| "excel_download" // 엑셀 다운로드
|
||||||
| "excel_upload" // 엑셀 업로드
|
| "excel_upload" // 엑셀 업로드
|
||||||
| "barcode_scan" // 바코드 스캔
|
| "barcode_scan" // 바코드 스캔
|
||||||
| "code_merge"; // 코드 병합
|
| "code_merge" // 코드 병합
|
||||||
|
| "geolocation" // 위치정보 가져오기
|
||||||
|
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||||
|
| "update_field"; // 특정 필드 값 변경 (예: status를 active로)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -90,6 +93,34 @@ export interface ButtonActionConfig {
|
||||||
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||||
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
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"; // 편집 모드
|
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
||||||
editModalTitle?: string; // 편집 모달 제목
|
editModalTitle?: string; // 편집 모달 제목
|
||||||
|
|
@ -199,6 +230,12 @@ export class ButtonActionExecutor {
|
||||||
case "code_merge":
|
case "code_merge":
|
||||||
return await this.handleCodeMerge(config, context);
|
return await this.handleCodeMerge(config, context);
|
||||||
|
|
||||||
|
case "geolocation":
|
||||||
|
return await this.handleGeolocation(config, context);
|
||||||
|
|
||||||
|
case "update_field":
|
||||||
|
return await this.handleUpdateField(config, context);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -3041,6 +3078,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 유효성 검사
|
* 폼 데이터 유효성 검사
|
||||||
*/
|
*/
|
||||||
|
|
@ -3144,4 +3487,21 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
||||||
successMessage: "코드 병합이 완료되었습니다.",
|
successMessage: "코드 병합이 완료되었습니다.",
|
||||||
errorMessage: "코드 병합 중 오류가 발생했습니다.",
|
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