lhj #232

Merged
hjlee merged 7 commits from lhj into main 2025-12-01 15:46:14 +09:00
9 changed files with 1206 additions and 150 deletions

View File

@ -22,9 +22,9 @@ router.use(authenticateToken);
// 폼 데이터 CRUD
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
router.delete("/:id", deleteFormData);
router.get("/:id", getFormData);

View File

@ -1662,12 +1662,47 @@ export class DynamicFormService {
companyCode,
});
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
let whereClause = `"${keyField}" = $1`;
const params: any[] = [keyValue, updateValue, userId];
let paramIndex = 4;
// 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인)
const columnQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
`;
const columnResult = await client.query(columnQuery, [tableName]);
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
const hasUpdatedBy = existingColumns.includes('updated_by');
const hasUpdatedAt = existingColumns.includes('updated_at');
const hasCompanyCode = existingColumns.includes('company_code');
if (companyCode && companyCode !== "*") {
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
hasUpdatedBy,
hasUpdatedAt,
hasCompanyCode,
});
// 동적 SET 절 구성
let setClause = `"${updateField}" = $1`;
const params: any[] = [updateValue];
let paramIndex = 2;
if (hasUpdatedBy) {
setClause += `, updated_by = $${paramIndex}`;
params.push(userId);
paramIndex++;
}
if (hasUpdatedAt) {
setClause += `, updated_at = NOW()`;
}
// WHERE 절 구성
let whereClause = `"${keyField}" = $${paramIndex}`;
params.push(keyValue);
paramIndex++;
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만)
if (hasCompanyCode && companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
@ -1675,9 +1710,7 @@ export class DynamicFormService {
const sqlQuery = `
UPDATE "${tableName}"
SET "${updateField}" = $2,
updated_by = $3,
updated_at = NOW()
SET ${setClause}
WHERE ${whereClause}
`;

View File

@ -1774,6 +1774,255 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
</div>
{/* 첫 번째 추가 테이블 설정 (위치정보와 함께 상태 변경) */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-update-field"> ( 1)</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)}
/>
</div>
{config.action?.geolocationUpdateField && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div>
<Label> </Label>
<Select
value={config.action?.geolocationExtraTableName || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraTableName", 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>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Input
placeholder="예: status"
value={config.action?.geolocationExtraField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="예: active"
value={config.action?.geolocationExtraValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ( )</Label>
<Input
placeholder="예: id"
value={config.action?.geolocationExtraKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationExtraKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
{/* 두 번째 추가 테이블 설정 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-second-table"> ( 2)</Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="geolocation-second-table"
checked={config.action?.geolocationSecondTableEnabled === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationSecondTableEnabled", checked)}
/>
</div>
{config.action?.geolocationSecondTableEnabled && (
<div className="mt-3 space-y-3 rounded-md bg-green-50 p-3 dark:bg-green-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> </Label>
<Select
value={config.action?.geolocationSecondTableName || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondTableName", 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>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationSecondMode || "update"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondMode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="update" className="text-xs">UPDATE ( )</SelectItem>
<SelectItem value="insert" className="text-xs">INSERT ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{config.action?.geolocationSecondMode === "insert" ? "저장할 필드" : "변경할 필드"}</Label>
<Input
placeholder="예: status"
value={config.action?.geolocationSecondField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label>{config.action?.geolocationSecondMode === "insert" ? "저장할 값" : "변경할 값"}</Label>
<Input
placeholder="예: inactive"
value={config.action?.geolocationSecondValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>
{config.action?.geolocationSecondMode === "insert"
? "연결 필드 (대상 테이블)"
: "키 필드 (대상 테이블)"}
</Label>
<Input
placeholder={config.action?.geolocationSecondMode === "insert" ? "예: vehicle_id" : "예: id"}
value={config.action?.geolocationSecondKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationSecondKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationSecondKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationSecondKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{config.action?.geolocationSecondMode === "insert" && (
<div className="flex items-center justify-between rounded bg-green-100 p-2 dark:bg-green-900">
<div className="space-y-0.5">
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">/ </p>
</div>
<Switch
checked={config.action?.geolocationSecondInsertFields?.includeLocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationSecondInsertFields", {
...config.action?.geolocationSecondInsertFields,
includeLocation: checked
})}
/>
</div>
)}
<p className="text-[10px] text-green-700 dark:text-green-300">
{config.action?.geolocationSecondMode === "insert"
? "새 레코드를 생성합니다. 연결 필드로 현재 폼 데이터와 연결됩니다."
: "기존 레코드를 수정합니다. 키 필드로 레코드를 찾아 값을 변경합니다."}
</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>
@ -1784,6 +2033,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<br />
3. /
<br />
4.
<br />
<br />
<strong>:</strong> + vehicles.status를 inactive로
<br />
<br />
<strong>:</strong> HTTPS .
</p>
@ -1852,6 +2106,62 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
</div>
{/* 🆕 키 필드 설정 (레코드 식별용) */}
<div className="mt-4 border-t pt-4">
<h5 className="mb-3 text-xs font-medium text-muted-foreground"> </h5>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-key-field">
(DB ) <span className="text-destructive">*</span>
</Label>
<Input
id="update-key-field"
placeholder="예: user_id"
value={config.action?.updateKeyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-key-source">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateKeySourceField || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.updateKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="키 값 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span> ID
</span>
</SelectItem>
<SelectItem value="__userName__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs">
<span className="flex items-center gap-1">
<span className="text-amber-500">🔑</span>
</span>
</SelectItem>
{tableColumns.map((column) => (
<SelectItem key={column} value={column} className="text-xs">
{column}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label>
@ -1899,15 +2209,78 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
</div>
{/* 위치정보 수집 옵션 */}
<div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-with-geolocation"> </Label>
<p className="text-xs text-muted-foreground"> GPS </p>
</div>
<Switch
id="update-with-geolocation"
checked={config.action?.updateWithGeolocation === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)}
/>
</div>
{config.action?.updateWithGeolocation && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: latitude"
value={config.action?.updateGeolocationLatField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> <span className="text-destructive">*</span></Label>
<Input
placeholder="예: longitude"
value={config.action?.updateGeolocationLngField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="예: accuracy"
value={config.action?.updateGeolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> ()</Label>
<Input
placeholder="예: location_time"
value={config.action?.updateGeolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
GPS .
</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 />
- 버튼: status &quot;active&quot;
- 버튼: status&quot;active&quot; +
<br />
- 버튼: approval_status &quot;approved&quot;
- 버튼: status를 &quot;inactive&quot; +
<br />
- 버튼: is_completed &quot;Y&quot;
- 버튼: is_completed&quot;Y&quot;
</p>
</div>
</div>

View File

@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
};
// DOM에 전달하면 안 되는 React-specific props 필터링
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {
selectedScreen,
onZoneComponentDrop,
@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
// 추가된 props 필터링
webType: _webType,
autoGeneration: _autoGeneration,
isInteractive: _isInteractive,
formData: _formData,
onFormDataChange: _onFormDataChange,
menuId: _menuId,
menuObjid: _menuObjid,
onSave: _onSave,
userId: _userId,
userName: _userName,
companyCode: _companyCode,
isInModal: _isInModal,
readonly: _readonly,
originalData: _originalData,
allComponents: _allComponents,
onUpdateLayout: _onUpdateLayout,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
onSelectedRowsChange: _onSelectedRowsChange,
sortBy: _sortBy,
sortOrder: _sortOrder,
tableDisplayData: _tableDisplayData,
flowSelectedData: _flowSelectedData,
flowSelectedStepId: _flowSelectedStepId,
onFlowSelectedDataChange: _onFlowSelectedDataChange,
onConfigChange: _onConfigChange,
refreshKey: _refreshKey,
flowRefreshKey: _flowRefreshKey,
onFlowRefresh: _onFlowRefresh,
isPreview: _isPreview,
groupedData: _groupedData,
...domProps
} = props;
} = props as any;
return (
<div style={componentStyle} className={className} {...domProps}>

View File

@ -94,20 +94,51 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
const variant = config.variant || props.variant || "card";
// 기본 옵션 (포항/광양)
const DEFAULT_OPTIONS: LocationOption[] = [
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
];
// 상태
const [options, setOptions] = useState<LocationOption[]>([]);
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
const [loading, setLoading] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
// 현재 선택된 값
const departureValue = formData[departureField] || "";
const destinationValue = formData[destinationField] || "";
// 로컬 선택 상태 (Select 컴포넌트용)
const [localDeparture, setLocalDeparture] = useState<string>("");
const [localDestination, setLocalDestination] = useState<string>("");
// 옵션 로드
useEffect(() => {
const loadOptions = async () => {
if (dataSource.type === "static") {
setOptions(dataSource.staticOptions || []);
console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode });
// 정적 옵션 처리 (기본값)
// type이 없거나 static이거나, table인데 tableName이 없는 경우
const shouldUseStatic =
!dataSource.type ||
dataSource.type === "static" ||
(dataSource.type === "table" && !dataSource.tableName) ||
(dataSource.type === "code" && !dataSource.codeCategory);
if (shouldUseStatic) {
const staticOpts = dataSource.staticOptions || [];
// 정적 옵션이 설정되어 있고, value가 유효한 경우 사용
// (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용)
const isValidOptions = staticOpts.length > 0 &&
staticOpts[0]?.value &&
staticOpts[0].value !== departureField &&
staticOpts[0].value !== destinationField;
if (isValidOptions) {
console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts);
setOptions(staticOpts);
} else {
// 기본값 (포항/광양)
console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS });
setOptions(DEFAULT_OPTIONS);
}
return;
}
@ -115,11 +146,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
// 코드 관리에서 가져오기
setLoading(true);
try {
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`);
const response = await apiClient.get(`/code-management/codes`, {
params: { categoryCode: dataSource.codeCategory },
});
if (response.data.success && response.data.data) {
const codeOptions = response.data.data.map((code: any) => ({
value: code.code_value || code.codeValue,
label: code.code_name || code.codeName,
value: code.code_value || code.codeValue || code.code,
label: code.code_name || code.codeName || code.name,
}));
setOptions(codeOptions);
}
@ -135,13 +168,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
// 테이블에서 가져오기
setLoading(true);
try {
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, {
params: { pageSize: 1000 },
const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, {
params: { page: 1, pageSize: 1000 },
});
if (response.data.success && response.data.data) {
const tableOptions = response.data.data.map((row: any) => ({
value: row[dataSource.valueField || "id"],
label: row[dataSource.labelField || "name"],
// data가 배열인지 또는 data.rows인지 확인
const rows = Array.isArray(response.data.data)
? response.data.data
: response.data.data.rows || [];
const tableOptions = rows.map((row: any) => ({
value: String(row[dataSource.valueField || "id"] || ""),
label: String(row[dataSource.labelField || "name"] || ""),
}));
setOptions(tableOptions);
}
@ -153,82 +190,130 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
}
};
if (!isDesignMode) {
loadOptions();
} else {
// 디자인 모드에서는 샘플 데이터
setOptions([
{ value: "seoul", label: "서울" },
{ value: "busan", label: "부산" },
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
]);
}
loadOptions();
}, [dataSource, isDesignMode]);
// formData에서 초기값 동기화
useEffect(() => {
const depVal = formData[departureField];
const destVal = formData[destinationField];
if (depVal && options.some(o => o.value === depVal)) {
setLocalDeparture(depVal);
}
if (destVal && options.some(o => o.value === destVal)) {
setLocalDestination(destVal);
}
}, [formData, departureField, destinationField, options]);
// 출발지 변경
const handleDepartureChange = (value: string) => {
const handleDepartureChange = (selectedValue: string) => {
console.log("[LocationSwapSelector] 출발지 변경:", {
selectedValue,
departureField,
hasOnFormDataChange: !!onFormDataChange,
options
});
// 로컬 상태 업데이트
setLocalDeparture(selectedValue);
// 부모에게 전달
if (onFormDataChange) {
onFormDataChange(departureField, value);
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`);
onFormDataChange(departureField, selectedValue);
// 라벨 필드도 업데이트
if (departureLabelField) {
const selectedOption = options.find((opt) => opt.value === value);
onFormDataChange(departureLabelField, selectedOption?.label || "");
const selectedOption = options.find((opt) => opt.value === selectedValue);
if (selectedOption) {
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`);
onFormDataChange(departureLabelField, selectedOption.label);
}
}
} else {
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
}
};
// 도착지 변경
const handleDestinationChange = (value: string) => {
const handleDestinationChange = (selectedValue: string) => {
console.log("[LocationSwapSelector] 도착지 변경:", {
selectedValue,
destinationField,
hasOnFormDataChange: !!onFormDataChange,
options
});
// 로컬 상태 업데이트
setLocalDestination(selectedValue);
// 부모에게 전달
if (onFormDataChange) {
onFormDataChange(destinationField, value);
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`);
onFormDataChange(destinationField, selectedValue);
// 라벨 필드도 업데이트
if (destinationLabelField) {
const selectedOption = options.find((opt) => opt.value === value);
onFormDataChange(destinationLabelField, selectedOption?.label || "");
const selectedOption = options.find((opt) => opt.value === selectedValue);
if (selectedOption) {
console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`);
onFormDataChange(destinationLabelField, selectedOption.label);
}
}
} else {
console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!");
}
};
// 출발지/도착지 교환
const handleSwap = () => {
if (!onFormDataChange) return;
setIsSwapping(true);
// 값 교환
const tempDeparture = departureValue;
const tempDestination = destinationValue;
// 로컬 상태 교환
const tempDeparture = localDeparture;
const tempDestination = localDestination;
setLocalDeparture(tempDestination);
setLocalDestination(tempDeparture);
onFormDataChange(departureField, tempDestination);
onFormDataChange(destinationField, tempDeparture);
// 부모에게 전달
if (onFormDataChange) {
onFormDataChange(departureField, tempDestination);
onFormDataChange(destinationField, tempDeparture);
// 라벨도 교환
if (departureLabelField && destinationLabelField) {
const tempDepartureLabel = formData[departureLabelField];
const tempDestinationLabel = formData[destinationLabelField];
onFormDataChange(departureLabelField, tempDestinationLabel);
onFormDataChange(destinationLabelField, tempDepartureLabel);
// 라벨도 교환
if (departureLabelField && destinationLabelField) {
const depOption = options.find(o => o.value === tempDestination);
const destOption = options.find(o => o.value === tempDeparture);
onFormDataChange(departureLabelField, depOption?.label || "");
onFormDataChange(destinationLabelField, destOption?.label || "");
}
}
// 애니메이션 효과
setTimeout(() => setIsSwapping(false), 300);
};
// 선택된 라벨 가져오기
const getDepartureLabel = () => {
const option = options.find((opt) => opt.value === departureValue);
return option?.label || "선택";
};
const getDestinationLabel = () => {
const option = options.find((opt) => opt.value === destinationValue);
return option?.label || "선택";
};
// 스타일에서 width, height 추출
const { width, height, ...restStyle } = style || {};
// 선택된 라벨 가져오기
const getDepartureLabel = () => {
const opt = options.find(o => o.value === localDeparture);
return opt?.label || "";
};
const getDestinationLabel = () => {
const opt = options.find(o => o.value === localDestination);
return opt?.label || "";
};
// 디버그 로그
console.log("[LocationSwapSelector] 렌더:", {
localDeparture,
localDestination,
options: options.map(o => `${o.value}:${o.label}`),
});
// Card 스타일 (이미지 참고)
if (variant === "card") {
return (
@ -242,18 +327,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
<Select
value={departureValue}
value={localDeparture || undefined}
onValueChange={handleDepartureChange}
disabled={loading || isDesignMode}
disabled={loading}
>
<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 className={cn(
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
isSwapping && "animate-pulse"
)}>
{localDeparture ? (
<span>{getDepartureLabel()}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" sideOffset={4}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -270,7 +358,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="ghost"
size="icon"
onClick={handleSwap}
disabled={isDesignMode || !departureValue || !destinationValue}
className={cn(
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
isSwapping && "rotate-180"
@ -284,18 +371,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex flex-1 flex-col items-center">
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
<Select
value={destinationValue}
value={localDestination || undefined}
onValueChange={handleDestinationChange}
disabled={loading || isDesignMode}
disabled={loading}
>
<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 className={cn(
"h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0",
isSwapping && "animate-pulse"
)}>
{localDestination ? (
<span>{getDestinationLabel()}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" sideOffset={4}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -320,14 +410,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
<Select
value={departureValue}
value={localDeparture || undefined}
onValueChange={handleDepartureChange}
disabled={loading || isDesignMode}
disabled={loading}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="선택" />
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground"></span>}
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" sideOffset={4}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -343,7 +433,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="outline"
size="icon"
onClick={handleSwap}
disabled={isDesignMode}
className="mt-5 h-10 w-10"
>
<ArrowLeftRight className="h-4 w-4" />
@ -353,14 +442,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
<div className="flex-1">
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
<Select
value={destinationValue}
value={localDestination || undefined}
onValueChange={handleDestinationChange}
disabled={loading || isDesignMode}
disabled={loading}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="선택" />
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground"></span>}
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" sideOffset={4}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -381,14 +470,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
style={restStyle}
>
<Select
value={departureValue}
value={localDeparture || undefined}
onValueChange={handleDepartureChange}
disabled={loading || isDesignMode}
disabled={loading}
>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={departureLabel} />
{localDeparture ? getDepartureLabel() : <span className="text-muted-foreground">{departureLabel}</span>}
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" sideOffset={4}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -403,7 +492,6 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
variant="ghost"
size="sm"
onClick={handleSwap}
disabled={isDesignMode}
className="h-8 w-8 p-0"
>
<ArrowLeftRight className="h-4 w-4" />
@ -411,14 +499,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
)}
<Select
value={destinationValue}
value={localDestination || undefined}
onValueChange={handleDestinationChange}
disabled={loading || isDesignMode}
disabled={loading}
>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder={destinationLabel} />
{localDestination ? getDestinationLabel() : <span className="text-muted-foreground">{destinationLabel}</span>}
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" sideOffset={4}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}

View File

@ -90,7 +90,7 @@ export function LocationSwapSelectorConfigPanel({
}
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
// 코드 카테고리 로드
// 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시)
useEffect(() => {
const loadCodeCategories = async () => {
try {
@ -103,8 +103,11 @@ export function LocationSwapSelectorConfigPanel({
}))
);
}
} catch (error) {
console.error("코드 카테고리 로드 실패:", error);
} catch (error: any) {
// 404는 API가 없는 것이므로 무시
if (error?.response?.status !== 404) {
console.error("코드 카테고리 로드 실패:", error);
}
}
};
loadCodeCategories();
@ -139,13 +142,83 @@ export function LocationSwapSelectorConfigPanel({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> ()</SelectItem>
<SelectItem value="table"></SelectItem>
<SelectItem value="code"> </SelectItem>
<SelectItem value="static"> (/ )</SelectItem>
<SelectItem value="table"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 고정 옵션 설정 (type이 static일 때) */}
{(!config?.dataSource?.type || config?.dataSource?.type === "static") && (
<div className="space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> 1 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: pohang"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label> 1 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[0]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[0] = { ...newOptions[0], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: 포항"
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label> 2 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.value || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], value: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: gwangyang"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label> 2 ()</Label>
<Input
value={config?.dataSource?.staticOptions?.[1]?.label || ""}
onChange={(e) => {
const options = config?.dataSource?.staticOptions || [];
const newOptions = [...options];
newOptions[1] = { ...newOptions[1], label: e.target.value };
handleChange("dataSource.staticOptions", newOptions);
}}
placeholder="예: 광양"
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300">
2 . (: 포항 )
</p>
</div>
)}
{/* 테이블 선택 (type이 table일 때) */}
{config?.dataSource?.type === "table" && (
<>
@ -298,14 +371,14 @@ export function LocationSwapSelectorConfigPanel({
<Label> ()</Label>
{tableColumns.length > 0 ? (
<Select
value={config?.departureLabelField || ""}
onValueChange={(value) => handleChange("departureLabelField", value)}
value={config?.departureLabelField || "__none__"}
onValueChange={(value) => handleChange("departureLabelField", value === "__none__" ? "" : value)}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
@ -325,14 +398,14 @@ export function LocationSwapSelectorConfigPanel({
<Label> ()</Label>
{tableColumns.length > 0 ? (
<Select
value={config?.destinationLabelField || ""}
onValueChange={(value) => handleChange("destinationLabelField", value)}
value={config?.destinationLabelField || "__none__"}
onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}

View File

@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender
static componentDefinition = LocationSwapSelectorDefinition;
render(): React.ReactElement {
return <LocationSwapSelectorComponent {...this.props} />;
const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props;
// component.componentConfig에서 설정 가져오기
const componentConfig = component?.componentConfig || {};
console.log("[LocationSwapSelectorRenderer] render:", {
componentConfig,
formData,
isDesignMode
});
return (
<LocationSwapSelectorComponent
id={component?.id}
style={style}
isDesignMode={isDesignMode}
formData={formData}
onFormDataChange={onFormDataChange}
componentConfig={componentConfig}
{...restProps}
/>
);
}
}

View File

@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
defaultConfig: {
// 데이터 소스 설정
dataSource: {
type: "table", // "table" | "code" | "static"
type: "static", // "table" | "code" | "static"
tableName: "", // 장소 테이블명
valueField: "location_code", // 값 필드
labelField: "location_name", // 표시 필드
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
staticOptions: [], // 정적 옵션 (type이 "static"일 때)
staticOptions: [
{ value: "pohang", label: "포항" },
{ value: "gwangyang", label: "광양" },
], // 정적 옵션 (type이 "static"일 때)
},
// 필드 매핑
departureField: "departure", // 출발지 저장 필드

View File

@ -110,6 +110,16 @@ export interface ButtonActionConfig {
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
// 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에)
geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부
geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles")
geolocationSecondMode?: "update" | "insert"; // 작업 모드 (기본: update)
geolocationSecondField?: string; // 두 번째 테이블에서 변경할 필드명 (예: "status")
geolocationSecondValue?: string | number | boolean; // 두 번째 테이블에서 변경할 값 (예: "inactive")
geolocationSecondKeyField?: string; // 두 번째 테이블의 키 필드 (예: "id") - UPDATE 모드에서만 사용
geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용
geolocationSecondInsertFields?: Record<string, any>; // INSERT 모드에서 추가로 넣을 필드들
// 필드 값 교환 관련 (출발지 ↔ 목적지)
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
@ -121,6 +131,13 @@ export interface ButtonActionConfig {
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
// 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용)
updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부
updateGeolocationLatField?: string; // 위도 저장 필드
updateGeolocationLngField?: string; // 경도 저장 필드
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
@ -217,6 +234,44 @@ export interface ButtonActionContext {
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
}
/**
* 🆕
* :
* - __userId__ : 로그인한 ID
* - __userName__ : 로그인한
* - __companyCode__ : 로그인한
* - __screenId__ : 현재 ID
* - __tableName__ : 현재
*/
export function resolveSpecialKeyword(
sourceField: string | undefined,
context: ButtonActionContext
): any {
if (!sourceField) return undefined;
// 특수 키워드 처리
switch (sourceField) {
case "__userId__":
console.log("🔑 특수 키워드 변환: __userId__ →", context.userId);
return context.userId;
case "__userName__":
console.log("🔑 특수 키워드 변환: __userName__ →", context.userName);
return context.userName;
case "__companyCode__":
console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode);
return context.companyCode;
case "__screenId__":
console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId);
return context.screenId;
case "__tableName__":
console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName);
return context.tableName;
default:
// 일반 폼 데이터에서 가져오기
return context.formData?.[sourceField];
}
}
/**
*
*/
@ -274,6 +329,9 @@ export class ButtonActionExecutor {
case "geolocation":
return await this.handleGeolocation(config, context);
case "swap_fields":
return await this.handleSwapFields(config, context);
case "update_field":
return await this.handleUpdateField(config, context);
@ -3345,6 +3403,14 @@ export class ButtonActionExecutor {
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
console.log("📍 [디버그] 추가 필드 설정값:", {
geolocationUpdateField: config.geolocationUpdateField,
geolocationExtraField: config.geolocationExtraField,
geolocationExtraValue: config.geolocationExtraValue,
geolocationExtraTableName: config.geolocationExtraTableName,
geolocationExtraKeyField: config.geolocationExtraKeyField,
geolocationExtraKeySourceField: config.geolocationExtraKeySourceField,
});
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
@ -3405,26 +3471,35 @@ export class ButtonActionExecutor {
// 🆕 추가 필드 변경 (위치정보 + 상태변경)
let extraTableUpdated = false;
let secondTableUpdated = false;
if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
const extraTableName = config.geolocationExtraTableName;
const extraTableName = config.geolocationExtraTableName || context.tableName;
const currentTableName = config.geolocationTableName || context.tableName;
// 다른 테이블에 UPDATE하는 경우
if (extraTableName && extraTableName !== currentTableName) {
console.log("📍 다른 테이블 필드 변경:", {
const keySourceField = config.geolocationExtraKeySourceField;
// 🆕 특수 키워드가 설정되어 있으면 바로 DB UPDATE (같은 테이블이어도)
const hasSpecialKeyword = keySourceField?.startsWith("__") && keySourceField?.endsWith("__");
const isDifferentTable = extraTableName && extraTableName !== currentTableName;
// 다른 테이블이거나 특수 키워드가 설정된 경우 → 바로 DB UPDATE
if (isDifferentTable || hasSpecialKeyword) {
console.log("📍 DB 직접 UPDATE:", {
targetTable: extraTableName,
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
keyField: config.geolocationExtraKeyField,
keySourceField: config.geolocationExtraKeySourceField,
keySourceField: keySourceField,
hasSpecialKeyword,
isDifferentTable,
});
// 키 값 가져오기
const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""];
// 키 값 가져오기 (특수 키워드 지원)
const keyValue = resolveSpecialKeyword(keySourceField, context);
if (keyValue && config.geolocationExtraKeyField) {
try {
// 다른 테이블 UPDATE API 호출
// DB UPDATE API 호출
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: extraTableName,
@ -3436,30 +3511,131 @@ export class ButtonActionExecutor {
if (response.data?.success) {
extraTableUpdated = true;
console.log("✅ 다른 테이블 UPDATE 성공:", response.data);
console.log("✅ DB UPDATE 성공:", response.data);
} else {
console.error("❌ 다른 테이블 UPDATE 실패:", response.data);
console.error("❌ DB UPDATE 실패:", response.data);
toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`);
}
} catch (apiError) {
console.error("❌ 다른 테이블 UPDATE API 오류:", apiError);
console.error("❌ DB UPDATE API 오류:", apiError);
toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`);
}
} else {
console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", {
keySourceField: config.geolocationExtraKeySourceField,
console.warn("⚠️ 키 값이 없어서 DB UPDATE를 건너뜁니다:", {
keySourceField: keySourceField,
keyValue,
});
}
} else {
// 같은 테이블 (현재 폼 데이터에 추가)
// 같은 테이블이고 특수 키워드가 없는 경우 (현재 폼 데이터에 추가)
updates[config.geolocationExtraField] = config.geolocationExtraValue;
console.log("📍 같은 테이블 추가 필드 변경:", {
console.log("📍 같은 테이블 추가 필드 변경 (폼 데이터):", {
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
});
}
}
// 🆕 두 번째 테이블 INSERT 또는 UPDATE
if (config.geolocationSecondTableEnabled &&
config.geolocationSecondTableName) {
const secondMode = config.geolocationSecondMode || "update";
console.log("📍 두 번째 테이블 작업:", {
mode: secondMode,
targetTable: config.geolocationSecondTableName,
field: config.geolocationSecondField,
value: config.geolocationSecondValue,
keyField: config.geolocationSecondKeyField,
keySourceField: config.geolocationSecondKeySourceField,
});
try {
const { apiClient } = await import("@/lib/api/client");
if (secondMode === "insert") {
// INSERT 모드: 새 레코드 생성
const insertData: Record<string, any> = {
// 위치정보 포함 (선택적)
...(config.geolocationSecondInsertFields || {}),
};
// 기본 필드 추가
if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) {
insertData[config.geolocationSecondField] = config.geolocationSecondValue;
}
// 위치정보도 두 번째 테이블에 저장하려면 추가
// (선택적으로 위도/경도도 저장)
if (config.geolocationSecondInsertFields?.includeLocation) {
insertData[latField] = latitude;
insertData[lngField] = longitude;
if (config.geolocationAccuracyField) {
insertData[config.geolocationAccuracyField] = accuracy;
}
if (config.geolocationTimestampField) {
insertData[config.geolocationTimestampField] = timestamp.toISOString();
}
}
// 현재 폼에서 키 값 가져와서 연결 (외래키) - 특수 키워드 지원
if (config.geolocationSecondKeySourceField && config.geolocationSecondKeyField) {
const keyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context);
if (keyValue) {
insertData[config.geolocationSecondKeyField] = keyValue;
}
}
console.log("📍 두 번째 테이블 INSERT 데이터:", insertData);
const response = await apiClient.post(`/dynamic-form/save`, {
tableName: config.geolocationSecondTableName,
data: insertData,
});
if (response.data?.success) {
secondTableUpdated = true;
console.log("✅ 두 번째 테이블 INSERT 성공:", response.data);
} else {
console.error("❌ 두 번째 테이블 INSERT 실패:", response.data);
toast.error(`${config.geolocationSecondTableName} 테이블 저장에 실패했습니다.`);
}
} else {
// UPDATE 모드: 기존 레코드 수정
if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) {
// 특수 키워드 지원
const secondKeyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context);
if (secondKeyValue && config.geolocationSecondKeyField) {
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: config.geolocationSecondTableName,
keyField: config.geolocationSecondKeyField,
keyValue: secondKeyValue,
updateField: config.geolocationSecondField,
updateValue: config.geolocationSecondValue,
});
if (response.data?.success) {
secondTableUpdated = true;
console.log("✅ 두 번째 테이블 UPDATE 성공:", response.data);
} else {
console.error("❌ 두 번째 테이블 UPDATE 실패:", response.data);
toast.error(`${config.geolocationSecondTableName} 테이블 업데이트에 실패했습니다.`);
}
} else {
console.warn("⚠️ 두 번째 테이블 키 값이 없어서 UPDATE를 건너뜁니다:", {
keySourceField: config.geolocationSecondKeySourceField,
keyValue: secondKeyValue,
});
}
}
}
} catch (apiError) {
console.error("❌ 두 번째 테이블 API 오류:", apiError);
toast.error(`${config.geolocationSecondTableName} 테이블 작업 중 오류가 발생했습니다.`);
}
}
// formData 업데이트
if (context.onFormDataChange) {
@ -3484,19 +3660,97 @@ export class ButtonActionExecutor {
successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
}
}
// 두 번째 테이블 변경이 있으면 메시지에 포함
if (secondTableUpdated && config.geolocationSecondTableName) {
successMsg += `\n[${config.geolocationSecondTableName}] ${config.geolocationSecondField}: ${config.geolocationSecondValue}`;
}
// 성공 메시지 표시
toast.success(successMsg);
// 자동 저장 옵션이 활성화된 경우
if (config.geolocationAutoSave && context.onSave) {
if (config.geolocationAutoSave) {
console.log("📍 위치정보 자동 저장 실행");
try {
await context.onSave();
toast.success("위치 정보가 저장되었습니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
// onSave 콜백이 있으면 사용
if (context.onSave) {
try {
await context.onSave();
toast.success("위치 정보가 저장되었습니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
}
} else if (context.tableName && context.formData) {
// onSave가 없으면 직접 API 호출
// 키 필드 설정이 있으면 update-field API 사용 (더 안전)
const keyField = config.geolocationExtraKeyField;
const keySourceField = config.geolocationExtraKeySourceField;
if (keyField && keySourceField) {
try {
const { apiClient } = await import("@/lib/api/client");
const keyValue = resolveSpecialKeyword(keySourceField, context);
if (keyValue) {
// formData에서 저장할 필드들 추출 (위치정보 + 출발지/도착지 등)
const fieldsToSave = { ...updates };
// formData에서 추가로 저장할 필드들 (테이블에 존재할 가능성이 높은 필드만)
// departure, arrival은 location-swap-selector에서 설정한 필드명 사용
const additionalFields = ['departure', 'arrival'];
additionalFields.forEach(field => {
if (context.formData?.[field] !== undefined && context.formData[field] !== '') {
fieldsToSave[field] = context.formData[field];
}
});
console.log("📍 개별 필드 UPDATE:", {
tableName: context.tableName,
keyField,
keyValue,
fieldsToSave,
});
// 각 필드를 개별적으로 UPDATE (에러가 나도 다른 필드 계속 저장)
let successCount = 0;
let failCount = 0;
for (const [field, value] of Object.entries(fieldsToSave)) {
try {
console.log(`🔄 UPDATE: ${context.tableName}.${field} = ${value}`);
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: context.tableName,
keyField: keyField,
keyValue: keyValue,
updateField: field,
updateValue: value,
});
if (response.data?.success) {
successCount++;
console.log(`${field} 업데이트 성공`);
} else {
failCount++;
console.warn(`⚠️ ${field} 업데이트 실패:`, response.data);
}
} catch (fieldError) {
failCount++;
console.warn(`⚠️ ${field} 업데이트 오류 (컬럼이 없을 수 있음):`, fieldError);
}
}
console.log(`✅ 필드 저장 완료: 성공 ${successCount}개, 실패 ${failCount}`);
}
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
}
} else {
console.warn("⚠️ 키 필드가 설정되지 않아 자동 저장을 건너뜁니다.");
}
}
}
@ -3528,8 +3782,62 @@ export class ButtonActionExecutor {
}
}
/**
* (: 출발지 )
*/
private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 필드 값 교환 액션 실행:", { config, context });
const { formData, onFormDataChange } = context;
// 교환할 필드 확인
const fieldA = config.swapFieldA;
const fieldB = config.swapFieldB;
if (!fieldA || !fieldB) {
toast.error("교환할 필드가 설정되지 않았습니다.");
return false;
}
// 현재 값 가져오기
const valueA = formData?.[fieldA];
const valueB = formData?.[fieldB];
console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB });
// 값 교환
if (onFormDataChange) {
onFormDataChange(fieldA, valueB);
onFormDataChange(fieldB, valueA);
}
// 관련 필드도 함께 교환 (예: 위도/경도)
if (config.swapRelatedFields && config.swapRelatedFields.length > 0) {
for (const related of config.swapRelatedFields) {
const relatedValueA = formData?.[related.fieldA];
const relatedValueB = formData?.[related.fieldB];
if (onFormDataChange) {
onFormDataChange(related.fieldA, relatedValueB);
onFormDataChange(related.fieldB, relatedValueA);
}
}
}
console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA });
toast.success(config.successMessage || "값이 교환되었습니다.");
return true;
} catch (error) {
console.error("❌ 필드 값 교환 오류:", error);
toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다.");
return false;
}
}
/**
* (: status를 active로 )
* 🆕
*/
private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
@ -3543,7 +3851,7 @@ export class ButtonActionExecutor {
const multipleFields = config.updateMultipleFields || [];
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
if (!targetField && multipleFields.length === 0) {
if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) {
toast.error("변경할 필드가 설정되지 않았습니다.");
return false;
}
@ -3570,6 +3878,69 @@ export class ButtonActionExecutor {
updates[field] = value;
});
// 🆕 위치정보 수집 (updateWithGeolocation이 true인 경우)
if (config.updateWithGeolocation) {
const latField = config.updateGeolocationLatField;
const lngField = config.updateGeolocationLngField;
if (!latField || !lngField) {
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
return false;
}
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
return false;
}
// 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
try {
// 위치 정보 가져오기
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
});
});
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득:", { latitude, longitude, accuracy });
// 위치정보를 updates에 추가
updates[latField] = latitude;
updates[lngField] = longitude;
if (config.updateGeolocationAccuracyField && accuracy !== null) {
updates[config.updateGeolocationAccuracyField] = accuracy;
}
if (config.updateGeolocationTimestampField) {
updates[config.updateGeolocationTimestampField] = timestamp.toISOString();
}
} catch (geoError: any) {
toast.dismiss(loadingToastId);
// GeolocationPositionError 처리
if (geoError.code === 1) {
toast.error("위치 정보 접근이 거부되었습니다.");
} else if (geoError.code === 2) {
toast.error("위치 정보를 사용할 수 없습니다.");
} else if (geoError.code === 3) {
toast.error("위치 정보 요청 시간이 초과되었습니다.");
} else {
toast.error("위치 정보를 가져오는 중 오류가 발생했습니다.");
}
return false;
}
}
console.log("🔄 변경할 필드들:", updates);
// formData 업데이트
@ -3583,6 +3954,67 @@ export class ButtonActionExecutor {
const autoSave = config.updateAutoSave !== false;
if (autoSave) {
// 🆕 키 필드 설정이 있는 경우 (특수 키워드 지원) - 직접 DB UPDATE
const keyField = config.updateKeyField;
const keySourceField = config.updateKeySourceField;
const targetTableName = config.updateTableName || tableName;
if (keyField && keySourceField) {
// 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID)
const keyValue = resolveSpecialKeyword(keySourceField, context);
console.log("🔄 필드 값 변경 - 키 필드 사용:", {
targetTable: targetTableName,
keyField,
keySourceField,
keyValue,
updates,
});
if (!keyValue) {
console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue });
toast.error("레코드를 식별할 키 값이 없습니다.");
return false;
}
try {
// 각 필드에 대해 개별 UPDATE 호출
const { apiClient } = await import("@/lib/api/client");
for (const [field, value] of Object.entries(updates)) {
console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`);
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: targetTableName,
keyField: keyField,
keyValue: keyValue,
updateField: field,
updateValue: value,
});
if (!response.data?.success) {
console.error(`${field} 업데이트 실패:`, response.data);
toast.error(`${field} 업데이트에 실패했습니다.`);
return false;
}
}
console.log("✅ 모든 필드 업데이트 성공");
toast.success(config.successMessage || "상태가 변경되었습니다.");
// 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTableData", {
detail: { tableName: targetTableName }
}));
return true;
} catch (apiError) {
console.error("❌ 필드 값 변경 API 호출 실패:", apiError);
toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다.");
return false;
}
}
// onSave 콜백이 있으면 사용
if (onSave) {
console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)");
@ -3597,7 +4029,7 @@ export class ButtonActionExecutor {
}
}
// API를 통한 직접 저장
// API를 통한 직접 저장 (기존 방식: formData에 PK가 있는 경우)
if (tableName && formData) {
console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)");
try {
@ -3606,7 +4038,7 @@ export class ButtonActionExecutor {
const pkValue = formData[pkField] || formData.id;
if (!pkValue) {
toast.error("레코드 ID를 찾을 수 없습니다.");
toast.error("레코드 ID를 찾을 수 없습니다. 키 필드를 설정해주세요.");
return false;
}