버튼 액션중 위치정보 가져오기, 필드값 변경 추가

This commit is contained in:
leeheejin 2025-11-28 18:35:07 +09:00
parent f1ff835a45
commit 67e6a8008d
16 changed files with 2401 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -375,6 +375,40 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
fetchTableColumns();
}, [config.action?.type, config.action?.historyTableName, currentTableName]);
// 🆕 geolocation/update_field 테이블 컬럼 자동 로드
useEffect(() => {
const actionType = config.action?.type;
// geolocation 액션일 때
if (actionType === "geolocation") {
// 위치정보 저장 테이블 컬럼 로드
const tableName = config.action?.geolocationTableName || currentTableName;
if (tableName) {
loadTableColumns(tableName);
}
// 🔥 추가 필드 변경용 대상 테이블 컬럼도 로드
const extraTableName = config.action?.geolocationExtraTableName;
if (extraTableName) {
loadTableColumns(extraTableName);
}
}
// update_field 액션일 때
if (actionType === "update_field") {
const tableName = config.action?.updateTableName || currentTableName;
if (tableName) {
loadTableColumns(tableName);
}
}
}, [
config.action?.type,
config.action?.geolocationTableName,
config.action?.geolocationExtraTableName, // 🔥 추가
config.action?.updateTableName,
currentTableName,
]);
// 검색 필터링 함수
const filterScreens = (searchTerm: string) => {
if (!searchTerm.trim()) return screens;
@ -442,6 +476,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="geolocation"> </SelectItem>
<SelectItem value="update_field"> </SelectItem>
</SelectContent>
</Select>
</div>
@ -508,8 +544,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
@ -899,8 +934,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
@ -977,8 +1011,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
@ -1131,8 +1164,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
@ -1347,8 +1379,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
variant="outline"
role="combobox"
aria-expanded={navScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
className="text-xs"
className="h-6 w-full justify-between px-2 py-0 text-xs"
disabled={screensLoading}
>
{config.action?.targetScreenId
@ -1601,6 +1632,489 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 위치정보 가져오기 설정 */}
{(component.componentConfig?.action?.type || "save") === "geolocation" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📍 </h4>
{/* 테이블 선택 */}
<div>
<Label htmlFor="geolocation-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.geolocationTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.geolocationTableName", value);
// 테이블 변경 시 컬럼 초기화
onUpdateProperty("componentConfig.action.geolocationLatField", "");
onUpdateProperty("componentConfig.action.geolocationLngField", "");
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-lat-field">
<span className="text-destructive">*</span>
</Label>
<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 &quot;active&quot;
<br />
- 버튼: approval_status &quot;approved&quot;
<br />
- 버튼: is_completed &quot;Y&quot;
</p>
</div>
</div>
)}
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

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

View File

@ -62,6 +62,12 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
// 🆕 탭 컴포넌트
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
// 🆕 반복 화면 모달 컴포넌트
import "./repeat-screen-modal/RepeatScreenModalRenderer";
// 🆕 출발지/도착지 선택 컴포넌트
import "./location-swap-selector/LocationSwapSelectorRenderer";
/**
*
*/

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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();
}

View File

@ -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";

View File

@ -23,7 +23,10 @@ export type ButtonActionType =
| "excel_download" // 엑셀 다운로드
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge"; // 코드 병합
| "code_merge" // 코드 병합
| "geolocation" // 위치정보 가져오기
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "update_field"; // 특정 필드 값 변경 (예: status를 active로)
/**
*
@ -90,6 +93,34 @@ export interface ButtonActionConfig {
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
// 위치정보 관련
geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블)
geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude")
geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude")
geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy")
geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time")
geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true)
geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000)
geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0)
geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false)
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
// 필드 값 교환 관련 (출발지 ↔ 목적지)
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination")
swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도)
// 필드 값 변경 관련 (특정 필드를 특정 값으로 변경)
updateTargetField?: string; // 변경할 필드명 (예: "status")
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
@ -199,6 +230,12 @@ export class ButtonActionExecutor {
case "code_merge":
return await this.handleCodeMerge(config, context);
case "geolocation":
return await this.handleGeolocation(config, context);
case "update_field":
return await this.handleUpdateField(config, context);
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
@ -3041,6 +3078,312 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<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: "코드 병합이 완료되었습니다.",
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: "상태 변경 중 오류가 발생했습니다.",
},
};