Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
142fb15dc0
|
|
@ -419,3 +419,66 @@ export const getTableColumns = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
export const updateFieldValue = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
||||
|
||||
console.log("🔄 [updateFieldValue] 요청:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 업데이트 쿼리 실행
|
||||
const result = await dynamicFormService.updateFieldValue(
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log("✅ [updateFieldValue] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "필드 값이 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [updateFieldValue] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "필드 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
saveFormDataEnhanced,
|
||||
updateFormData,
|
||||
updateFormDataPartial,
|
||||
updateFieldValue,
|
||||
deleteFormData,
|
||||
getFormData,
|
||||
getFormDataList,
|
||||
|
|
@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
|||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||
router.put("/:id", updateFormData);
|
||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
router.delete("/:id", deleteFormData);
|
||||
router.get("/:id", getFormData);
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,8 @@ export class DashboardService {
|
|||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
* - company_code가 '*'인 경우 최고 관리자만 조회 가능
|
||||
*/
|
||||
static async getDashboardById(
|
||||
dashboardId: string,
|
||||
|
|
@ -310,44 +312,43 @@ export class DashboardService {
|
|||
let dashboardQuery: string;
|
||||
let dashboardParams: any[];
|
||||
|
||||
if (userId) {
|
||||
if (companyCode) {
|
||||
if (companyCode) {
|
||||
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
|
||||
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
|
||||
if (companyCode === '*') {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND (d.created_by = $3 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode, userId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
}
|
||||
} else {
|
||||
if (companyCode) {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
}
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
}
|
||||
|
||||
const dashboardResult = await PostgreSQLService.query(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
|
||||
|
|
@ -1635,6 +1635,69 @@ export class DynamicFormService {
|
|||
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 필드 값만 업데이트
|
||||
* (다른 테이블의 레코드 업데이트 지원)
|
||||
*/
|
||||
async updateFieldValue(
|
||||
tableName: string,
|
||||
keyField: string,
|
||||
keyValue: any,
|
||||
updateField: string,
|
||||
updateValue: any,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<{ affectedRows: number }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
|
||||
let whereClause = `"${keyField}" = $1`;
|
||||
const params: any[] = [keyValue, updateValue, userId];
|
||||
let paramIndex = 4;
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const sqlQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET "${updateField}" = $2,
|
||||
updated_by = $3,
|
||||
updated_at = NOW()
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
|
||||
console.log("🔍 [updateFieldValue] 파라미터:", params);
|
||||
|
||||
const result = await client.query(sqlQuery, params);
|
||||
|
||||
console.log("✅ [updateFieldValue] 결과:", {
|
||||
affectedRows: result.rowCount,
|
||||
});
|
||||
|
||||
return { affectedRows: result.rowCount || 0 };
|
||||
} catch (error) {
|
||||
console.error("❌ [updateFieldValue] 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
|
|
|
|||
|
|
@ -995,6 +995,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// console.log("🔧 기본 해상도 적용:", defaultResolution);
|
||||
}
|
||||
|
||||
// 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인
|
||||
const buttonComponents = layoutWithDefaultGrid.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button")
|
||||
);
|
||||
console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: c.componentType,
|
||||
actionType: c.componentConfig?.action?.type,
|
||||
fullAction: c.componentConfig?.action,
|
||||
})));
|
||||
|
||||
setLayout(layoutWithDefaultGrid);
|
||||
setHistory([layoutWithDefaultGrid]);
|
||||
setHistoryIndex(0);
|
||||
|
|
@ -1452,7 +1463,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary",
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
console.log("💾 저장 시작:", {
|
||||
screenId: selectedScreen.screenId,
|
||||
|
|
@ -1462,6 +1473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
buttonComponents: buttonComponents.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
componentType: c.componentType,
|
||||
text: c.componentConfig?.text,
|
||||
actionType: c.componentConfig?.action?.type,
|
||||
fullAction: c.componentConfig?.action,
|
||||
|
|
|
|||
|
|
@ -503,6 +503,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||
<SelectItem value="geolocation">위치정보 가져오기</SelectItem>
|
||||
<SelectItem value="update_field">필드 값 변경</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -1662,6 +1664,255 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 위치정보 가져오기 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "geolocation" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📍 위치정보 설정</h4>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="geolocation-table">
|
||||
저장할 테이블 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.geolocationTableName || currentTableName || ""}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.geolocationTableName", value);
|
||||
onUpdateProperty("componentConfig.action.geolocationLatField", "");
|
||||
onUpdateProperty("componentConfig.action.geolocationLngField", "");
|
||||
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
|
||||
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
위치 정보를 저장할 테이블 (기본: 현재 화면 테이블)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geolocation-lat-field">
|
||||
위도 저장 필드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="geolocation-lat-field"
|
||||
placeholder="예: latitude"
|
||||
value={config.action?.geolocationLatField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geolocation-lng-field">
|
||||
경도 저장 필드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="geolocation-lng-field"
|
||||
placeholder="예: longitude"
|
||||
value={config.action?.geolocationLngField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geolocation-accuracy-field">정확도 저장 필드 (선택)</Label>
|
||||
<Input
|
||||
id="geolocation-accuracy-field"
|
||||
placeholder="예: accuracy"
|
||||
value={config.action?.geolocationAccuracyField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geolocation-timestamp-field">타임스탬프 저장 필드 (선택)</Label>
|
||||
<Input
|
||||
id="geolocation-timestamp-field"
|
||||
placeholder="예: location_time"
|
||||
value={config.action?.geolocationTimestampField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="geolocation-high-accuracy">고정밀 모드</Label>
|
||||
<p className="text-xs text-muted-foreground">GPS를 사용하여 더 정확한 위치 (배터리 소모 증가)</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="geolocation-high-accuracy"
|
||||
checked={config.action?.geolocationHighAccuracy !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="geolocation-auto-save">위치 가져온 후 자동 저장</Label>
|
||||
<p className="text-xs text-muted-foreground">위치 정보를 가져온 후 자동으로 폼을 저장합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="geolocation-auto-save"
|
||||
checked={config.action?.geolocationAutoSave === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 버튼을 클릭하면 브라우저가 위치 권한을 요청합니다
|
||||
<br />
|
||||
2. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다
|
||||
<br />
|
||||
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
|
||||
<br />
|
||||
<br />
|
||||
<strong>참고:</strong> HTTPS 환경에서만 위치정보가 작동합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 값 변경 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "update_field" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📝 필드 값 변경 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="update-table">
|
||||
대상 테이블 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.updateTableName || currentTableName || ""}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.updateTableName", value);
|
||||
onUpdateProperty("componentConfig.action.updateTargetField", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
필드 값을 변경할 테이블 (기본: 현재 화면 테이블)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="update-target-field">
|
||||
변경할 필드명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="update-target-field"
|
||||
placeholder="예: status"
|
||||
value={config.action?.updateTargetField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">변경할 DB 컬럼</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="update-target-value">
|
||||
변경할 값 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="update-target-value"
|
||||
placeholder="예: active"
|
||||
value={config.action?.updateTargetValue || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">변경할 값 (문자열, 숫자)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="update-auto-save">변경 후 자동 저장</Label>
|
||||
<p className="text-xs text-muted-foreground">버튼 클릭 시 즉시 DB에 저장</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="update-auto-save"
|
||||
checked={config.action?.updateAutoSave !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateAutoSave", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="update-confirm-message">확인 메시지 (선택)</Label>
|
||||
<Input
|
||||
id="update-confirm-message"
|
||||
placeholder="예: 운행을 시작하시겠습니까?"
|
||||
value={config.action?.confirmMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">입력하면 변경 전 확인 창이 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="update-success-message">성공 메시지 (선택)</Label>
|
||||
<Input
|
||||
id="update-success-message"
|
||||
placeholder="예: 운행이 시작되었습니다."
|
||||
value={config.action?.successMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.successMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="update-error-message">오류 메시지 (선택)</Label>
|
||||
<Input
|
||||
id="update-error-message"
|
||||
placeholder="예: 운행 시작에 실패했습니다."
|
||||
value={config.action?.errorMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.errorMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 예시:</strong>
|
||||
<br />
|
||||
- 운행알림 버튼: status 필드를 "active"로 변경
|
||||
<br />
|
||||
- 승인 버튼: approval_status 필드를 "approved"로 변경
|
||||
<br />
|
||||
- 완료 버튼: is_completed 필드를 "Y"로 변경
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전달 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "transferData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
} | null>(null);
|
||||
|
||||
// 토스트 정리를 위한 ref
|
||||
const currentLoadingToastRef = useRef<string | number | undefined>();
|
||||
const currentLoadingToastRef = useRef<string | number | undefined>(undefined);
|
||||
|
||||
// 컴포넌트 언마운트 시 토스트 정리
|
||||
useEffect(() => {
|
||||
|
|
@ -201,9 +201,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
|
||||
|
||||
// 컴포넌트 설정
|
||||
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
||||
} as ButtonPrimaryConfig;
|
||||
|
||||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||
|
|
@ -238,13 +240,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 스타일 계산
|
||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
|
|||
// 🆕 탭 컴포넌트
|
||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
||||
// 🆕 반복 화면 모달 컴포넌트
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer";
|
||||
|
||||
// 🆕 출발지/도착지 선택 컴포넌트
|
||||
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||
|
||||
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
||||
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DataSourceConfig {
|
||||
type: "table" | "code" | "static";
|
||||
tableName?: string;
|
||||
valueField?: string;
|
||||
labelField?: string;
|
||||
codeCategory?: string;
|
||||
staticOptions?: LocationOption[];
|
||||
}
|
||||
|
||||
export interface LocationSwapSelectorProps {
|
||||
// 기본 props
|
||||
id?: string;
|
||||
style?: React.CSSProperties;
|
||||
isDesignMode?: boolean;
|
||||
|
||||
// 데이터 소스 설정
|
||||
dataSource?: DataSourceConfig;
|
||||
|
||||
// 필드 매핑
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
|
||||
// UI 설정
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
|
||||
// 폼 데이터
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
|
||||
// componentConfig (화면 디자이너에서 전달)
|
||||
componentConfig?: {
|
||||
dataSource?: DataSourceConfig;
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트
|
||||
* 출발지/도착지 선택 및 교환 기능
|
||||
*/
|
||||
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
|
||||
const {
|
||||
id,
|
||||
style,
|
||||
isDesignMode = false,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
} = props;
|
||||
|
||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||
const config = componentConfig || {};
|
||||
const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] };
|
||||
const departureField = config.departureField || props.departureField || "departure";
|
||||
const destinationField = config.destinationField || props.destinationField || "destination";
|
||||
const departureLabelField = config.departureLabelField || props.departureLabelField;
|
||||
const destinationLabelField = config.destinationLabelField || props.destinationLabelField;
|
||||
const departureLabel = config.departureLabel || props.departureLabel || "출발지";
|
||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
const variant = config.variant || props.variant || "card";
|
||||
|
||||
// 상태
|
||||
const [options, setOptions] = useState<LocationOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
|
||||
// 현재 선택된 값
|
||||
const departureValue = formData[departureField] || "";
|
||||
const destinationValue = formData[destinationField] || "";
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
if (dataSource.type === "static") {
|
||||
setOptions(dataSource.staticOptions || []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "code" && dataSource.codeCategory) {
|
||||
// 코드 관리에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const codeOptions = response.data.data.map((code: any) => ({
|
||||
value: code.code_value || code.codeValue,
|
||||
label: code.code_name || code.codeName,
|
||||
}));
|
||||
setOptions(codeOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "table" && dataSource.tableName) {
|
||||
// 테이블에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, {
|
||||
params: { pageSize: 1000 },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
const tableOptions = response.data.data.map((row: any) => ({
|
||||
value: row[dataSource.valueField || "id"],
|
||||
label: row[dataSource.labelField || "name"],
|
||||
}));
|
||||
setOptions(tableOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isDesignMode) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 디자인 모드에서는 샘플 데이터
|
||||
setOptions([
|
||||
{ value: "seoul", label: "서울" },
|
||||
{ value: "busan", label: "부산" },
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
]);
|
||||
}
|
||||
}, [dataSource, isDesignMode]);
|
||||
|
||||
// 출발지 변경
|
||||
const handleDepartureChange = (value: string) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(departureField, value);
|
||||
// 라벨 필드도 업데이트
|
||||
if (departureLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
onFormDataChange(departureLabelField, selectedOption?.label || "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 도착지 변경
|
||||
const handleDestinationChange = (value: string) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(destinationField, value);
|
||||
// 라벨 필드도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
onFormDataChange(destinationLabelField, selectedOption?.label || "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 출발지/도착지 교환
|
||||
const handleSwap = () => {
|
||||
if (!onFormDataChange) return;
|
||||
|
||||
setIsSwapping(true);
|
||||
|
||||
// 값 교환
|
||||
const tempDeparture = departureValue;
|
||||
const tempDestination = destinationValue;
|
||||
|
||||
onFormDataChange(departureField, tempDestination);
|
||||
onFormDataChange(destinationField, tempDeparture);
|
||||
|
||||
// 라벨도 교환
|
||||
if (departureLabelField && destinationLabelField) {
|
||||
const tempDepartureLabel = formData[departureLabelField];
|
||||
const tempDestinationLabel = formData[destinationLabelField];
|
||||
onFormDataChange(departureLabelField, tempDestinationLabel);
|
||||
onFormDataChange(destinationLabelField, tempDepartureLabel);
|
||||
}
|
||||
|
||||
// 애니메이션 효과
|
||||
setTimeout(() => setIsSwapping(false), 300);
|
||||
};
|
||||
|
||||
// 선택된 라벨 가져오기
|
||||
const getDepartureLabel = () => {
|
||||
const option = options.find((opt) => opt.value === departureValue);
|
||||
return option?.label || "선택";
|
||||
};
|
||||
|
||||
const getDestinationLabel = () => {
|
||||
const option = options.find((opt) => opt.value === destinationValue);
|
||||
return option?.label || "선택";
|
||||
};
|
||||
|
||||
// 스타일에서 width, height 추출
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
|
||||
// Card 스타일 (이미지 참고)
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="h-full w-full"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||
{/* 출발지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
|
||||
<Select
|
||||
value={departureValue}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
||||
<SelectValue placeholder="선택">
|
||||
<span className={cn(isSwapping && "animate-pulse")}>
|
||||
{getDepartureLabel()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 교환 버튼 */}
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode || !departureValue || !destinationValue}
|
||||
className={cn(
|
||||
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
||||
isSwapping && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
|
||||
<Select
|
||||
value={destinationValue}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
||||
<SelectValue placeholder="선택">
|
||||
<span className={cn(isSwapping && "animate-pulse")}>
|
||||
{getDestinationLabel()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline 스타일
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-2"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
|
||||
<Select
|
||||
value={departureValue}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode}
|
||||
className="mt-5 h-10 w-10"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
|
||||
<Select
|
||||
value={destinationValue}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal 스타일
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-1"
|
||||
style={restStyle}
|
||||
>
|
||||
<Select
|
||||
value={departureValue}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
<SelectValue placeholder={departureLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={destinationValue}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
<SelectValue placeholder={destinationLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationSwapSelectorConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 설정 패널
|
||||
*/
|
||||
export function LocationSwapSelectorConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
screenTableName,
|
||||
}: LocationSwapSelectorConfigPanelProps) {
|
||||
const [tables, setTables] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [codeCategories, setCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data.success && response.data.data) {
|
||||
setTables(
|
||||
response.data.data.map((t: any) => ({
|
||||
name: t.tableName || t.table_name,
|
||||
label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
const tableName = config?.dataSource?.tableName;
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data.success) {
|
||||
// API 응답 구조 처리: data가 배열이거나 data.columns가 배열일 수 있음
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
setColumns(
|
||||
columnData.map((c: any) => ({
|
||||
name: c.columnName || c.column_name || c.name,
|
||||
label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (config?.dataSource?.type === "table") {
|
||||
loadColumns();
|
||||
}
|
||||
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
|
||||
|
||||
// 코드 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCodeCategories = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/code-management/categories");
|
||||
if (response.data.success && response.data.data) {
|
||||
setCodeCategories(
|
||||
response.data.data.map((c: any) => ({
|
||||
value: c.category_code || c.categoryCode || c.code,
|
||||
label: c.category_name || c.categoryName || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadCodeCategories();
|
||||
}, []);
|
||||
|
||||
const handleChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current: any = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.type || "static"}
|
||||
onValueChange={(value) => handleChange("dataSource.type", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 옵션 (하드코딩)</SelectItem>
|
||||
<SelectItem value="table">테이블</SelectItem>
|
||||
<SelectItem value="code">코드 관리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 (type이 table일 때) */}
|
||||
{config?.dataSource?.type === "table" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>테이블</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.tableName || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.tableName", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>값 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.valueField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.valueField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>표시 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.labelField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.labelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 코드 카테고리 선택 (type이 code일 때) */}
|
||||
{config?.dataSource?.type === "code" && (
|
||||
<div className="space-y-2">
|
||||
<Label>코드 카테고리</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.codeCategory || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">필드 매핑 (저장 위치)</h4>
|
||||
{screenTableName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 화면 테이블: <strong>{screenTableName}</strong>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureField || ""}
|
||||
onValueChange={(value) => handleChange("departureField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureField || "departure"}
|
||||
onChange={(e) => handleChange("departureField", e.target.value)}
|
||||
placeholder="departure"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationField || ""}
|
||||
onValueChange={(value) => handleChange("destinationField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationField || "destination"}
|
||||
onChange={(e) => handleChange("destinationField", e.target.value)}
|
||||
placeholder="destination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureLabelField || ""}
|
||||
onValueChange={(value) => handleChange("departureLabelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureLabelField || ""}
|
||||
onChange={(e) => handleChange("departureLabelField", e.target.value)}
|
||||
placeholder="departure_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationLabelField || ""}
|
||||
onValueChange={(value) => handleChange("destinationLabelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationLabelField || ""}
|
||||
onChange={(e) => handleChange("destinationLabelField", e.target.value)}
|
||||
placeholder="destination_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 라벨</Label>
|
||||
<Input
|
||||
value={config?.departureLabel || "출발지"}
|
||||
onChange={(e) => handleChange("departureLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 라벨</Label>
|
||||
<Input
|
||||
value={config?.destinationLabel || "도착지"}
|
||||
onChange={(e) => handleChange("destinationLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>스타일</Label>
|
||||
<Select
|
||||
value={config?.variant || "card"}
|
||||
onValueChange={(value) => handleChange("variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="card">카드 (이미지 참고)</SelectItem>
|
||||
<SelectItem value="inline">인라인</SelectItem>
|
||||
<SelectItem value="minimal">미니멀</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>교환 버튼 표시</Label>
|
||||
<Switch
|
||||
checked={config?.showSwapButton !== false}
|
||||
onCheckedChange={(checked) => handleChange("showSwapButton", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 데이터 소스에서 장소 목록을 가져올 위치를 선택합니다
|
||||
<br />
|
||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||
<br />
|
||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { LocationSwapSelectorDefinition } from "./index";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 렌더러
|
||||
*/
|
||||
export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = LocationSwapSelectorDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <LocationSwapSelectorComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
LocationSwapSelectorRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
LocationSwapSelectorRenderer.enableHotReload();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트 정의
|
||||
* 출발지/도착지 선택 및 교환 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
export const LocationSwapSelectorDefinition = createComponentDefinition({
|
||||
id: "location-swap-selector",
|
||||
name: "출발지/도착지 선택",
|
||||
nameEng: "Location Swap Selector",
|
||||
description: "출발지와 도착지를 선택하고 교환할 수 있는 컴포넌트 (모바일 최적화)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "form",
|
||||
component: LocationSwapSelectorComponent,
|
||||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "table", // "table" | "code" | "static"
|
||||
tableName: "", // 장소 테이블명
|
||||
valueField: "location_code", // 값 필드
|
||||
labelField: "location_name", // 표시 필드
|
||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||
staticOptions: [], // 정적 옵션 (type이 "static"일 때)
|
||||
},
|
||||
// 필드 매핑
|
||||
departureField: "departure", // 출발지 저장 필드
|
||||
destinationField: "destination", // 도착지 저장 필드
|
||||
departureLabelField: "departure_name", // 출발지명 저장 필드 (선택)
|
||||
destinationLabelField: "destination_name", // 도착지명 저장 필드 (선택)
|
||||
// UI 설정
|
||||
departureLabel: "출발지",
|
||||
destinationLabel: "도착지",
|
||||
showSwapButton: true,
|
||||
swapButtonPosition: "center", // "center" | "right"
|
||||
// 스타일
|
||||
variant: "card", // "card" | "inline" | "minimal"
|
||||
},
|
||||
defaultSize: { width: 400, height: 100 },
|
||||
configPanel: LocationSwapSelectorConfigPanel,
|
||||
icon: "ArrowLeftRight",
|
||||
tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
export { LocationSwapSelectorRenderer } from "./LocationSwapSelectorRenderer";
|
||||
|
||||
|
|
@ -24,6 +24,9 @@ export type ButtonActionType =
|
|||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan" // 바코드 스캔
|
||||
| "code_merge" // 코드 병합
|
||||
| "geolocation" // 위치정보 가져오기
|
||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "update_field" // 특정 필드 값 변경 (예: status를 active로)
|
||||
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
|
||||
/**
|
||||
|
|
@ -91,6 +94,34 @@ export interface ButtonActionConfig {
|
|||
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
||||
|
||||
// 위치정보 관련
|
||||
geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블)
|
||||
geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude")
|
||||
geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude")
|
||||
geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy")
|
||||
geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time")
|
||||
geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true)
|
||||
geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000)
|
||||
geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0)
|
||||
geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false)
|
||||
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
|
||||
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
|
||||
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
|
||||
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
|
||||
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
|
||||
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
|
||||
|
||||
// 필드 값 교환 관련 (출발지 ↔ 목적지)
|
||||
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
|
||||
swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination")
|
||||
swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도)
|
||||
|
||||
// 필드 값 변경 관련 (특정 필드를 특정 값으로 변경)
|
||||
updateTargetField?: string; // 변경할 필드명 (예: "status")
|
||||
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
|
||||
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
|
||||
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
|
||||
|
||||
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
|
||||
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
||||
editModalTitle?: string; // 편집 모달 제목
|
||||
|
|
@ -100,37 +131,37 @@ export interface ButtonActionConfig {
|
|||
// 데이터 전달 관련 (transferData 액션용)
|
||||
dataTransfer?: {
|
||||
// 소스 설정
|
||||
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
|
||||
sourceComponentType?: string; // 소스 컴포넌트 타입
|
||||
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
|
||||
sourceComponentType?: string; // 소스 컴포넌트 타입
|
||||
|
||||
// 타겟 설정
|
||||
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
|
||||
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
|
||||
|
||||
// 타겟이 컴포넌트인 경우
|
||||
targetComponentId?: string; // 타겟 컴포넌트 ID
|
||||
targetComponentId?: string; // 타겟 컴포넌트 ID
|
||||
|
||||
// 타겟이 화면인 경우
|
||||
targetScreenId?: number; // 타겟 화면 ID
|
||||
targetScreenId?: number; // 타겟 화면 ID
|
||||
|
||||
// 데이터 매핑 규칙
|
||||
mappingRules: Array<{
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
sourceField: string; // 소스 필드명
|
||||
targetField: string; // 타겟 필드명
|
||||
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
|
||||
defaultValue?: any; // 기본값
|
||||
defaultValue?: any; // 기본값
|
||||
}>;
|
||||
|
||||
// 전달 옵션
|
||||
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
|
||||
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
|
||||
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
|
||||
confirmMessage?: string; // 확인 메시지 내용
|
||||
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
|
||||
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
|
||||
confirmMessage?: string; // 확인 메시지 내용
|
||||
|
||||
// 검증
|
||||
validation?: {
|
||||
requireSelection?: boolean; // 선택 필수 (기본: true)
|
||||
minSelection?: number; // 최소 선택 개수
|
||||
maxSelection?: number; // 최대 선택 개수
|
||||
requireSelection?: boolean; // 선택 필수 (기본: true)
|
||||
minSelection?: number; // 최소 선택 개수
|
||||
maxSelection?: number; // 최대 선택 개수
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -240,6 +271,12 @@ export class ButtonActionExecutor {
|
|||
case "transferData":
|
||||
return await this.handleTransferData(config, context);
|
||||
|
||||
case "geolocation":
|
||||
return await this.handleGeolocation(config, context);
|
||||
|
||||
case "update_field":
|
||||
return await this.handleUpdateField(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -275,14 +312,16 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave", {
|
||||
detail: {
|
||||
formData: context.formData
|
||||
}
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("beforeFormSave", {
|
||||
detail: {
|
||||
formData: context.formData,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
|
||||
|
||||
|
|
@ -294,33 +333,41 @@ export class ButtonActionExecutor {
|
|||
key,
|
||||
isArray: Array.isArray(value),
|
||||
length: Array.isArray(value) ? value.length : 0,
|
||||
firstItem: Array.isArray(value) && value.length > 0 ? {
|
||||
hasOriginalData: !!value[0]?.originalData,
|
||||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||||
keys: Object.keys(value[0] || {})
|
||||
} : null
|
||||
}))
|
||||
firstItem:
|
||||
Array.isArray(value) && value.length > 0
|
||||
? {
|
||||
hasOriginalData: !!value[0]?.originalData,
|
||||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||||
keys: Object.keys(value[0] || {}),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
});
|
||||
|
||||
// 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정)
|
||||
if (Array.isArray(context.formData)) {
|
||||
console.log("⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀");
|
||||
console.log(
|
||||
"⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀",
|
||||
);
|
||||
console.log("⚠️ [handleSave] formData 배열:", context.formData);
|
||||
// ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀
|
||||
return true; // 성공으로 반환
|
||||
}
|
||||
|
||||
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
|
||||
const selectedItemsKeys = Object.keys(context.formData).filter((key) => {
|
||||
const value = context.formData[key];
|
||||
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
|
||||
isArray: Array.isArray(value),
|
||||
length: Array.isArray(value) ? value.length : 0,
|
||||
firstItem: Array.isArray(value) && value.length > 0 ? {
|
||||
keys: Object.keys(value[0] || {}),
|
||||
hasOriginalData: !!value[0]?.originalData,
|
||||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||||
actualValue: value[0],
|
||||
} : null
|
||||
firstItem:
|
||||
Array.isArray(value) && value.length > 0
|
||||
? {
|
||||
keys: Object.keys(value[0] || {}),
|
||||
hasOriginalData: !!value[0]?.originalData,
|
||||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||||
actualValue: value[0],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
|
||||
});
|
||||
|
|
@ -633,7 +680,7 @@ export class ButtonActionExecutor {
|
|||
private static async handleBatchSave(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
selectedItemsKeys: string[]
|
||||
selectedItemsKeys: string[],
|
||||
): Promise<boolean> {
|
||||
const { formData, tableName, screenId, selectedRowsData, originalData } = context;
|
||||
|
||||
|
|
@ -661,10 +708,10 @@ export class ButtonActionExecutor {
|
|||
// 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기
|
||||
// (여러 단계 모달에서 전달된 데이터 접근용)
|
||||
let modalDataStoreRegistry: Record<string, any[]> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
// Zustand store에서 데이터 가져오기
|
||||
const { useModalDataStore } = await import('@/stores/modalDataStore');
|
||||
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
||||
modalDataStoreRegistry = useModalDataStore.getState().dataRegistry;
|
||||
} catch (error) {
|
||||
console.warn("⚠️ modalDataStore 로드 실패:", error);
|
||||
|
|
@ -676,11 +723,10 @@ export class ButtonActionExecutor {
|
|||
Object.entries(modalDataStoreRegistry).forEach(([key, items]) => {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
// ModalDataItem[] → originalData 추출
|
||||
modalDataStore[key] = items.map(item => item.originalData || item);
|
||||
modalDataStore[key] = items.map((item) => item.originalData || item);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||
for (const key of selectedItemsKeys) {
|
||||
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
|
||||
|
|
@ -699,26 +745,24 @@ export class ButtonActionExecutor {
|
|||
const groupKeys = Object.keys(item.fieldGroups);
|
||||
|
||||
// 각 그룹의 항목 배열 가져오기
|
||||
const groupArrays = groupKeys.map(groupKey => ({
|
||||
const groupArrays = groupKeys.map((groupKey) => ({
|
||||
groupKey,
|
||||
entries: item.fieldGroups[groupKey] || []
|
||||
entries: item.fieldGroups[groupKey] || [],
|
||||
}));
|
||||
|
||||
// 카티션 곱 계산 함수
|
||||
const cartesianProduct = (arrays: any[][]): any[][] => {
|
||||
if (arrays.length === 0) return [[]];
|
||||
if (arrays.length === 1) return arrays[0].map(item => [item]);
|
||||
if (arrays.length === 1) return arrays[0].map((item) => [item]);
|
||||
|
||||
const [first, ...rest] = arrays;
|
||||
const restProduct = cartesianProduct(rest);
|
||||
|
||||
return first.flatMap(item =>
|
||||
restProduct.map(combination => [item, ...combination])
|
||||
);
|
||||
return first.flatMap((item) => restProduct.map((combination) => [item, ...combination]));
|
||||
};
|
||||
|
||||
// 모든 그룹의 카티션 곱 생성
|
||||
const entryArrays = groupArrays.map(g => g.entries);
|
||||
const entryArrays = groupArrays.map((g) => g.entries);
|
||||
const combinations = cartesianProduct(entryArrays);
|
||||
|
||||
// 각 조합을 개별 레코드로 저장
|
||||
|
|
@ -1107,7 +1151,7 @@ export class ButtonActionExecutor {
|
|||
if (!dataSourceId && context.allComponents) {
|
||||
// TableList 우선 감지
|
||||
const tableListComponent = context.allComponents.find(
|
||||
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
|
||||
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName,
|
||||
);
|
||||
|
||||
if (tableListComponent) {
|
||||
|
|
@ -1119,7 +1163,7 @@ export class ButtonActionExecutor {
|
|||
} else {
|
||||
// TableList가 없으면 SplitPanelLayout의 좌측 패널 감지
|
||||
const splitPanelComponent = context.allComponents.find(
|
||||
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName
|
||||
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName,
|
||||
);
|
||||
|
||||
if (splitPanelComponent) {
|
||||
|
|
@ -1477,7 +1521,7 @@ export class ButtonActionExecutor {
|
|||
comp.type === "screen-split-panel" ||
|
||||
comp.componentType === "screen-split-panel" ||
|
||||
comp.type === "split-panel-layout" ||
|
||||
comp.componentType === "split-panel-layout"
|
||||
comp.componentType === "split-panel-layout",
|
||||
);
|
||||
}
|
||||
console.log("🔍 [openEditModal] 분할 패널 확인:", {
|
||||
|
|
@ -1632,7 +1676,8 @@ export class ButtonActionExecutor {
|
|||
if (copiedData[field] !== undefined) {
|
||||
const originalValue = copiedData[field];
|
||||
const ruleIdKey = `${field}_numberingRuleId`;
|
||||
const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
|
||||
const hasNumberingRule =
|
||||
rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
|
||||
|
||||
// 품목코드를 무조건 공백으로 초기화
|
||||
copiedData[field] = "";
|
||||
|
|
@ -2637,7 +2682,7 @@ export class ButtonActionExecutor {
|
|||
if (Array.isArray(response)) {
|
||||
// 배열로 직접 반환된 경우
|
||||
dataToExport = response;
|
||||
} else if (response && 'data' in response) {
|
||||
} else if (response && "data" in response) {
|
||||
// EntityJoinResponse 객체인 경우
|
||||
dataToExport = response.data;
|
||||
} else {
|
||||
|
|
@ -2691,102 +2736,99 @@ export class ButtonActionExecutor {
|
|||
const sheetName = config.excelSheetName || "Sheet1";
|
||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||
|
||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||
let visibleColumns: string[] | undefined = undefined;
|
||||
let columnLabels: Record<string, string> | undefined = undefined;
|
||||
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
|
||||
let visibleColumns: string[] | undefined = undefined;
|
||||
let columnLabels: Record<string, string> | undefined = undefined;
|
||||
|
||||
try {
|
||||
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
|
||||
|
||||
if (layoutResponse.data?.success && layoutResponse.data?.data) {
|
||||
let layoutData = layoutResponse.data.data;
|
||||
|
||||
// components가 문자열이면 파싱
|
||||
if (typeof layoutData.components === "string") {
|
||||
layoutData.components = JSON.parse(layoutData.components);
|
||||
}
|
||||
|
||||
// 테이블 리스트 컴포넌트 찾기
|
||||
const findTableListComponent = (components: any[]): any => {
|
||||
if (!Array.isArray(components)) return null;
|
||||
|
||||
for (const comp of components) {
|
||||
// componentType이 'table-list'인지 확인
|
||||
const isTableList = comp.componentType === "table-list";
|
||||
|
||||
// componentConfig 안에서 테이블명 확인
|
||||
const matchesTable =
|
||||
comp.componentConfig?.selectedTable === context.tableName ||
|
||||
comp.componentConfig?.tableName === context.tableName;
|
||||
|
||||
if (isTableList && matchesTable) {
|
||||
return comp;
|
||||
}
|
||||
if (comp.children && comp.children.length > 0) {
|
||||
const found = findTableListComponent(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tableListComponent = findTableListComponent(layoutData.components || []);
|
||||
|
||||
if (tableListComponent && tableListComponent.componentConfig?.columns) {
|
||||
const columns = tableListComponent.componentConfig.columns;
|
||||
|
||||
// visible이 true인 컬럼만 추출
|
||||
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
|
||||
|
||||
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
|
||||
try {
|
||||
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||
params: { page: 1, size: 9999 },
|
||||
});
|
||||
|
||||
if (layoutResponse.data?.success && layoutResponse.data?.data) {
|
||||
let layoutData = layoutResponse.data.data;
|
||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||
let columnData = columnsResponse.data.data;
|
||||
|
||||
// components가 문자열이면 파싱
|
||||
if (typeof layoutData.components === 'string') {
|
||||
layoutData.components = JSON.parse(layoutData.components);
|
||||
// data가 객체이고 columns 필드가 있으면 추출
|
||||
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
// 테이블 리스트 컴포넌트 찾기
|
||||
const findTableListComponent = (components: any[]): any => {
|
||||
if (!Array.isArray(components)) return null;
|
||||
if (Array.isArray(columnData)) {
|
||||
columnLabels = {};
|
||||
|
||||
for (const comp of components) {
|
||||
// componentType이 'table-list'인지 확인
|
||||
const isTableList = comp.componentType === 'table-list';
|
||||
|
||||
// componentConfig 안에서 테이블명 확인
|
||||
const matchesTable =
|
||||
comp.componentConfig?.selectedTable === context.tableName ||
|
||||
comp.componentConfig?.tableName === context.tableName;
|
||||
|
||||
if (isTableList && matchesTable) {
|
||||
return comp;
|
||||
// API에서 가져온 라벨로 매핑
|
||||
columnData.forEach((colData: any) => {
|
||||
const colName = colData.column_name || colData.columnName;
|
||||
// 우선순위: column_label > label > displayName > columnName
|
||||
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
|
||||
if (colName && labelValue) {
|
||||
columnLabels![colName] = labelValue;
|
||||
}
|
||||
if (comp.children && comp.children.length > 0) {
|
||||
const found = findTableListComponent(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tableListComponent = findTableListComponent(layoutData.components || []);
|
||||
|
||||
if (tableListComponent && tableListComponent.componentConfig?.columns) {
|
||||
const columns = tableListComponent.componentConfig.columns;
|
||||
|
||||
// visible이 true인 컬럼만 추출
|
||||
visibleColumns = columns
|
||||
.filter((col: any) => col.visible !== false)
|
||||
.map((col: any) => col.columnName);
|
||||
|
||||
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
|
||||
try {
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||
params: { page: 1, size: 9999 }
|
||||
});
|
||||
|
||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||
let columnData = columnsResponse.data.data;
|
||||
|
||||
// data가 객체이고 columns 필드가 있으면 추출
|
||||
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
columnLabels = {};
|
||||
|
||||
// API에서 가져온 라벨로 매핑
|
||||
columnData.forEach((colData: any) => {
|
||||
const colName = colData.column_name || colData.columnName;
|
||||
// 우선순위: column_label > label > displayName > columnName
|
||||
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
|
||||
if (colName && labelValue) {
|
||||
columnLabels![colName] = labelValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 실패 시 컴포넌트 설정의 displayName 사용
|
||||
columnLabels = {};
|
||||
columns.forEach((col: any) => {
|
||||
if (col.columnName) {
|
||||
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||||
// 실패 시 컴포넌트 설정의 displayName 사용
|
||||
columnLabels = {};
|
||||
columns.forEach((col: any) => {
|
||||
if (col.columnName) {
|
||||
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||||
}
|
||||
|
||||
// 🎨 카테고리 값들 조회 (한 번만)
|
||||
const categoryMap: Record<string, Record<string, string>> = {};
|
||||
|
|
@ -2801,9 +2843,9 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
|
||||
// 백엔드에서 정의된 카테고리 컬럼들
|
||||
categoryColumns = categoryColumnsResponse.data.map((col: any) =>
|
||||
col.column_name || col.columnName || col.name
|
||||
).filter(Boolean); // undefined 제거
|
||||
categoryColumns = categoryColumnsResponse.data
|
||||
.map((col: any) => col.column_name || col.columnName || col.name)
|
||||
.filter(Boolean); // undefined 제거
|
||||
|
||||
// 각 카테고리 컬럼의 값들 조회
|
||||
for (const columnName of categoryColumns) {
|
||||
|
|
@ -2820,7 +2862,6 @@ export class ButtonActionExecutor {
|
|||
categoryMap[columnName][code] = label;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error);
|
||||
|
|
@ -2849,15 +2890,15 @@ export class ButtonActionExecutor {
|
|||
let value = row[columnName];
|
||||
|
||||
// writer → writer_name 사용
|
||||
if (columnName === 'writer' && row['writer_name']) {
|
||||
value = row['writer_name'];
|
||||
if (columnName === "writer" && row["writer_name"]) {
|
||||
value = row["writer_name"];
|
||||
}
|
||||
// 다른 엔티티 필드들도 _name 우선 사용
|
||||
else if (row[`${columnName}_name`]) {
|
||||
value = row[`${columnName}_name`];
|
||||
}
|
||||
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
|
||||
else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) {
|
||||
else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) {
|
||||
value = categoryMap[columnName][value];
|
||||
}
|
||||
|
||||
|
|
@ -2867,7 +2908,6 @@ export class ButtonActionExecutor {
|
|||
|
||||
return filteredRow;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// 최대 행 수 제한
|
||||
|
|
@ -3142,12 +3182,12 @@ export class ButtonActionExecutor {
|
|||
|
||||
const confirmMerge = confirm(
|
||||
`⚠️ 코드 병합 확인\n\n` +
|
||||
`${oldValue} → ${newValue}\n\n` +
|
||||
`영향받는 데이터:\n` +
|
||||
`- 테이블 수: ${preview.preview.length}개\n` +
|
||||
`- 총 행 수: ${totalRows}개\n\n` +
|
||||
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
||||
`계속하시겠습니까?`
|
||||
`${oldValue} → ${newValue}\n\n` +
|
||||
`영향받는 데이터:\n` +
|
||||
`- 테이블 수: ${preview.preview.length}개\n` +
|
||||
`- 총 행 수: ${totalRows}개\n\n` +
|
||||
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
||||
`계속하시겠습니까?`,
|
||||
);
|
||||
|
||||
if (!confirmMerge) {
|
||||
|
|
@ -3172,8 +3212,7 @@ export class ButtonActionExecutor {
|
|||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
toast.success(
|
||||
`코드 병합 완료!\n` +
|
||||
`${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`
|
||||
`코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`,
|
||||
);
|
||||
|
||||
// 화면 새로고침
|
||||
|
|
@ -3289,6 +3328,318 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치정보 가져오기 액션 처리
|
||||
*/
|
||||
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
|
||||
|
||||
// 브라우저 Geolocation API 지원 확인
|
||||
if (!navigator.geolocation) {
|
||||
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 위도/경도 저장 필드 확인
|
||||
const latField = config.geolocationLatField;
|
||||
const lngField = config.geolocationLngField;
|
||||
|
||||
if (!latField || !lngField) {
|
||||
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 로딩 토스트 표시
|
||||
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
|
||||
|
||||
// Geolocation 옵션 설정
|
||||
const options: PositionOptions = {
|
||||
enableHighAccuracy: config.geolocationHighAccuracy !== false, // 기본 true
|
||||
timeout: config.geolocationTimeout || 10000, // 기본 10초
|
||||
maximumAge: config.geolocationMaxAge || 0, // 기본 0 (항상 새로운 위치)
|
||||
};
|
||||
|
||||
// 위치 정보 가져오기 (Promise로 래핑)
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, options);
|
||||
});
|
||||
|
||||
// 로딩 토스트 제거
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
const { latitude, longitude, accuracy, altitude, heading, speed } = position.coords;
|
||||
const timestamp = new Date(position.timestamp);
|
||||
|
||||
console.log("📍 위치정보 획득 성공:", {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
timestamp: timestamp.toISOString(),
|
||||
});
|
||||
|
||||
// 폼 데이터 업데이트
|
||||
const updates: Record<string, any> = {
|
||||
[latField]: latitude,
|
||||
[lngField]: longitude,
|
||||
};
|
||||
|
||||
// 선택적 필드들
|
||||
if (config.geolocationAccuracyField && accuracy !== null) {
|
||||
updates[config.geolocationAccuracyField] = accuracy;
|
||||
}
|
||||
if (config.geolocationTimestampField) {
|
||||
updates[config.geolocationTimestampField] = timestamp.toISOString();
|
||||
}
|
||||
|
||||
// 🆕 추가 필드 변경 (위치정보 + 상태변경)
|
||||
let extraTableUpdated = false;
|
||||
if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
|
||||
const extraTableName = config.geolocationExtraTableName;
|
||||
const currentTableName = config.geolocationTableName || context.tableName;
|
||||
|
||||
// 다른 테이블에 UPDATE하는 경우
|
||||
if (extraTableName && extraTableName !== currentTableName) {
|
||||
console.log("📍 다른 테이블 필드 변경:", {
|
||||
targetTable: extraTableName,
|
||||
field: config.geolocationExtraField,
|
||||
value: config.geolocationExtraValue,
|
||||
keyField: config.geolocationExtraKeyField,
|
||||
keySourceField: config.geolocationExtraKeySourceField,
|
||||
});
|
||||
|
||||
// 키 값 가져오기
|
||||
const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""];
|
||||
|
||||
if (keyValue && config.geolocationExtraKeyField) {
|
||||
try {
|
||||
// 다른 테이블 UPDATE API 호출
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.put(`/dynamic-form/update-field`, {
|
||||
tableName: extraTableName,
|
||||
keyField: config.geolocationExtraKeyField,
|
||||
keyValue: keyValue,
|
||||
updateField: config.geolocationExtraField,
|
||||
updateValue: config.geolocationExtraValue,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
extraTableUpdated = true;
|
||||
console.log("✅ 다른 테이블 UPDATE 성공:", response.data);
|
||||
} else {
|
||||
console.error("❌ 다른 테이블 UPDATE 실패:", response.data);
|
||||
toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`);
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("❌ 다른 테이블 UPDATE API 오류:", apiError);
|
||||
toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", {
|
||||
keySourceField: config.geolocationExtraKeySourceField,
|
||||
keyValue,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 같은 테이블 (현재 폼 데이터에 추가)
|
||||
updates[config.geolocationExtraField] = config.geolocationExtraValue;
|
||||
console.log("📍 같은 테이블 추가 필드 변경:", {
|
||||
field: config.geolocationExtraField,
|
||||
value: config.geolocationExtraValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// formData 업데이트
|
||||
if (context.onFormDataChange) {
|
||||
Object.entries(updates).forEach(([field, value]) => {
|
||||
context.onFormDataChange?.(field, value);
|
||||
});
|
||||
}
|
||||
|
||||
// 성공 메시지 생성
|
||||
let successMsg =
|
||||
config.successMessage ||
|
||||
`위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`;
|
||||
|
||||
// 추가 필드 변경이 있으면 메시지에 포함
|
||||
if (config.geolocationUpdateField && config.geolocationExtraField) {
|
||||
if (extraTableUpdated) {
|
||||
successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
|
||||
} else if (
|
||||
!config.geolocationExtraTableName ||
|
||||
config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)
|
||||
) {
|
||||
successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 메시지 표시
|
||||
toast.success(successMsg);
|
||||
|
||||
// 자동 저장 옵션이 활성화된 경우
|
||||
if (config.geolocationAutoSave && context.onSave) {
|
||||
console.log("📍 위치정보 자동 저장 실행");
|
||||
try {
|
||||
await context.onSave();
|
||||
toast.success("위치 정보가 저장되었습니다.");
|
||||
} catch (saveError) {
|
||||
console.error("❌ 위치정보 자동 저장 실패:", saveError);
|
||||
toast.error("위치 정보 저장에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 위치정보 가져오기 실패:", error);
|
||||
toast.dismiss();
|
||||
|
||||
// GeolocationPositionError 처리
|
||||
if (error.code) {
|
||||
switch (error.code) {
|
||||
case 1: // PERMISSION_DENIED
|
||||
toast.error("위치 정보 접근이 거부되었습니다.\n브라우저 설정에서 위치 권한을 허용해주세요.");
|
||||
break;
|
||||
case 2: // POSITION_UNAVAILABLE
|
||||
toast.error("위치 정보를 사용할 수 없습니다.\nGPS 신호를 확인해주세요.");
|
||||
break;
|
||||
case 3: // TIMEOUT
|
||||
toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요.");
|
||||
break;
|
||||
default:
|
||||
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다.");
|
||||
}
|
||||
} else {
|
||||
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
|
||||
*/
|
||||
private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
|
||||
|
||||
const { formData, tableName, onFormDataChange, onSave } = context;
|
||||
|
||||
// 변경할 필드 확인
|
||||
const targetField = config.updateTargetField;
|
||||
const targetValue = config.updateTargetValue;
|
||||
const multipleFields = config.updateMultipleFields || [];
|
||||
|
||||
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
|
||||
if (!targetField && multipleFields.length === 0) {
|
||||
toast.error("변경할 필드가 설정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 확인 메시지 표시 (설정된 경우)
|
||||
if (config.confirmMessage) {
|
||||
const confirmed = window.confirm(config.confirmMessage);
|
||||
if (!confirmed) {
|
||||
console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 변경할 필드 목록 구성
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
// 단일 필드 변경
|
||||
if (targetField && targetValue !== undefined) {
|
||||
updates[targetField] = targetValue;
|
||||
}
|
||||
|
||||
// 다중 필드 변경
|
||||
multipleFields.forEach(({ field, value }) => {
|
||||
updates[field] = value;
|
||||
});
|
||||
|
||||
console.log("🔄 변경할 필드들:", updates);
|
||||
|
||||
// formData 업데이트
|
||||
if (onFormDataChange) {
|
||||
Object.entries(updates).forEach(([field, value]) => {
|
||||
onFormDataChange(field, value);
|
||||
});
|
||||
}
|
||||
|
||||
// 자동 저장 (기본값: true)
|
||||
const autoSave = config.updateAutoSave !== false;
|
||||
|
||||
if (autoSave) {
|
||||
// onSave 콜백이 있으면 사용
|
||||
if (onSave) {
|
||||
console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)");
|
||||
try {
|
||||
await onSave();
|
||||
toast.success(config.successMessage || "상태가 변경되었습니다.");
|
||||
return true;
|
||||
} catch (saveError) {
|
||||
console.error("❌ 필드 값 변경 저장 실패:", saveError);
|
||||
toast.error(config.errorMessage || "상태 변경 저장에 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// API를 통한 직접 저장
|
||||
if (tableName && formData) {
|
||||
console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)");
|
||||
try {
|
||||
// PK 필드 찾기 (id 또는 테이블명_id)
|
||||
const pkField = formData.id !== undefined ? "id" : `${tableName}_id`;
|
||||
const pkValue = formData[pkField] || formData.id;
|
||||
|
||||
if (!pkValue) {
|
||||
toast.error("레코드 ID를 찾을 수 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 업데이트할 데이터 구성 (변경할 필드들만)
|
||||
const updateData = {
|
||||
...updates,
|
||||
[pkField]: pkValue, // PK 포함
|
||||
};
|
||||
|
||||
const response = await DynamicFormApi.updateData(tableName, updateData);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(config.successMessage || "상태가 변경되었습니다.");
|
||||
|
||||
// 테이블 새로고침 이벤트 발생
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("refreshTableData", {
|
||||
detail: { tableName },
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
toast.error(response.message || config.errorMessage || "상태 변경에 실패했습니다.");
|
||||
return false;
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("❌ 필드 값 변경 API 호출 실패:", apiError);
|
||||
toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 저장이 비활성화된 경우 폼 데이터만 변경
|
||||
toast.success(config.successMessage || "필드 값이 변경되었습니다. 저장 버튼을 눌러 저장하세요.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 필드 값 변경 실패:", error);
|
||||
toast.error(config.errorMessage || "필드 값 변경 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 유효성 검사
|
||||
*/
|
||||
|
|
@ -3397,4 +3748,21 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
successMessage: "데이터가 전달되었습니다.",
|
||||
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
|
||||
},
|
||||
geolocation: {
|
||||
type: "geolocation",
|
||||
geolocationHighAccuracy: true,
|
||||
geolocationTimeout: 10000,
|
||||
geolocationMaxAge: 0,
|
||||
geolocationAutoSave: false,
|
||||
confirmMessage: "현재 위치 정보를 가져오시겠습니까?",
|
||||
successMessage: "위치 정보를 가져왔습니다.",
|
||||
errorMessage: "위치 정보를 가져오는 중 오류가 발생했습니다.",
|
||||
},
|
||||
update_field: {
|
||||
type: "update_field",
|
||||
updateAutoSave: true,
|
||||
confirmMessage: "상태를 변경하시겠습니까?",
|
||||
successMessage: "상태가 변경되었습니다.",
|
||||
errorMessage: "상태 변경 중 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue