371 lines
9.6 KiB
TypeScript
371 lines
9.6 KiB
TypeScript
/**
|
|
* V3 Action 메타 컴포넌트 렌더러
|
|
* - 버튼 + CRUD 액션 실행
|
|
* - config.steps 순차 처리
|
|
* - 자체적으로 완전히 동작하는 액션 시스템
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { ActionComponentConfig } from "@/lib/api/metaComponent";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { v2EventBus } from "@/lib/v2-core/events/EventBus";
|
|
import { V2_EVENTS } from "@/lib/v2-core/events/types";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { cn } from "@/lib/utils";
|
|
import { Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
interface ActionRendererProps {
|
|
id: string;
|
|
config: ActionComponentConfig;
|
|
// 데이터
|
|
formData?: Record<string, any>;
|
|
selectedRowsData?: any[];
|
|
// 컨텍스트
|
|
tableName?: string;
|
|
companyCode?: string;
|
|
screenId?: number;
|
|
userId?: string;
|
|
// 콜백
|
|
onRefresh?: () => void;
|
|
// UI
|
|
isDesignMode?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function ActionRenderer({
|
|
id,
|
|
config,
|
|
formData = {},
|
|
selectedRowsData = [],
|
|
tableName,
|
|
companyCode,
|
|
screenId,
|
|
userId,
|
|
onRefresh,
|
|
isDesignMode = false,
|
|
disabled = false,
|
|
className,
|
|
}: ActionRendererProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
const router = useRouter();
|
|
|
|
// 버튼 클릭 핸들러
|
|
const handleClick = async () => {
|
|
// 디자인 모드에서는 실행 안 함
|
|
if (isDesignMode) {
|
|
return;
|
|
}
|
|
|
|
// 활성화 조건 체크
|
|
if (config.enableCondition) {
|
|
const isEnabled = evaluateCondition(config.enableCondition);
|
|
if (!isEnabled) {
|
|
toast.warning("이 작업을 실행할 수 없습니다.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 확인 대화상자가 있으면 먼저 표시
|
|
if (config.confirmDialog) {
|
|
setShowConfirm(true);
|
|
return;
|
|
}
|
|
|
|
// 확인 없이 바로 실행
|
|
await executeAction();
|
|
};
|
|
|
|
// 조건 평가 (간단한 버전)
|
|
const evaluateCondition = (condition: any): boolean => {
|
|
// TODO: 복잡한 조건 평가 로직 구현
|
|
// 현재는 항상 true 반환
|
|
return true;
|
|
};
|
|
|
|
// 액션 실행
|
|
const executeAction = async () => {
|
|
setShowConfirm(false);
|
|
setLoading(true);
|
|
|
|
try {
|
|
// config.steps 순차 실행
|
|
if (config.steps && config.steps.length > 0) {
|
|
for (const step of config.steps) {
|
|
await executeStep(step);
|
|
}
|
|
} else {
|
|
toast.info("실행할 액션이 없습니다.");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("Action 실행 실패:", error);
|
|
toast.error(error.message || "액션 실행에 실패했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 개별 스텝 실행
|
|
const executeStep = async (step: any) => {
|
|
switch (step.type) {
|
|
case "save":
|
|
await executeSaveStep(step);
|
|
break;
|
|
case "delete":
|
|
await executeDeleteStep(step);
|
|
break;
|
|
case "refresh":
|
|
executeRefreshStep(step);
|
|
break;
|
|
case "toast":
|
|
executeToastStep(step);
|
|
break;
|
|
case "api":
|
|
await executeApiStep(step);
|
|
break;
|
|
case "navigate":
|
|
executeNavigateStep(step);
|
|
break;
|
|
default:
|
|
console.warn(`알 수 없는 스텝 타입: ${step.type}`);
|
|
}
|
|
};
|
|
|
|
// Save 스텝 실행
|
|
const executeSaveStep = async (step: any) => {
|
|
const targetTable = step.target || tableName;
|
|
if (!targetTable) {
|
|
throw new Error("저장할 테이블명이 없습니다.");
|
|
}
|
|
|
|
// formData에서 데이터 수집
|
|
const dataToSave = { ...formData };
|
|
|
|
// company_code 자동 추가
|
|
if (companyCode && !dataToSave.company_code) {
|
|
dataToSave.company_code = companyCode;
|
|
}
|
|
|
|
// 저장 모드 판단: 새로 생성 vs 수정
|
|
const isEdit = step.mode === "edit" || (dataToSave && Object.keys(dataToSave).length > 0 && dataToSave.id);
|
|
|
|
if (isEdit) {
|
|
// 수정 모드
|
|
await tableTypeApi.editTableData(targetTable, dataToSave, dataToSave);
|
|
toast.success("수정 완료");
|
|
} else {
|
|
// 생성 모드
|
|
await tableTypeApi.addTableData(targetTable, dataToSave);
|
|
toast.success("저장 완료");
|
|
}
|
|
|
|
// 테이블 새로고침 이벤트 발행
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
|
|
tableName: targetTable,
|
|
target: "single",
|
|
});
|
|
|
|
// onRefresh 콜백 호출
|
|
onRefresh?.();
|
|
};
|
|
|
|
// Delete 스텝 실행
|
|
const executeDeleteStep = async (step: any) => {
|
|
const targetTable = step.target || tableName;
|
|
if (!targetTable) {
|
|
throw new Error("삭제할 테이블명이 없습니다.");
|
|
}
|
|
|
|
// 선택된 행 확인
|
|
if (!selectedRowsData || selectedRowsData.length === 0) {
|
|
toast.warning("삭제할 데이터를 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 선택된 행 삭제
|
|
await tableTypeApi.deleteTableData(targetTable, selectedRowsData);
|
|
toast.success(`${selectedRowsData.length}개 항목이 삭제되었습니다.`);
|
|
|
|
// 테이블 새로고침 이벤트 발행
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
|
|
tableName: targetTable,
|
|
target: "single",
|
|
});
|
|
|
|
// onRefresh 콜백 호출
|
|
onRefresh?.();
|
|
};
|
|
|
|
// Refresh 스텝 실행
|
|
const executeRefreshStep = (step: any) => {
|
|
const targetTable = step.target || tableName;
|
|
|
|
if (targetTable) {
|
|
// 특정 테이블 새로고침
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
|
|
tableName: targetTable,
|
|
target: "single",
|
|
});
|
|
} else {
|
|
// 전체 새로고침
|
|
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
|
|
target: "all",
|
|
});
|
|
}
|
|
|
|
// onRefresh 콜백 호출
|
|
onRefresh?.();
|
|
|
|
toast.success("새로고침 완료");
|
|
};
|
|
|
|
// Toast 스텝 실행
|
|
const executeToastStep = (step: any) => {
|
|
const variant = step.variant || "info";
|
|
const message = step.message || "알림";
|
|
|
|
switch (variant) {
|
|
case "success":
|
|
toast.success(message);
|
|
break;
|
|
case "error":
|
|
toast.error(message);
|
|
break;
|
|
case "warning":
|
|
toast.warning(message);
|
|
break;
|
|
case "info":
|
|
default:
|
|
toast.info(message);
|
|
break;
|
|
}
|
|
};
|
|
|
|
// API 스텝 실행
|
|
const executeApiStep = async (step: any) => {
|
|
const method = (step.method || "GET").toLowerCase();
|
|
const endpoint = step.endpoint;
|
|
const body = step.body || {};
|
|
|
|
if (!endpoint) {
|
|
throw new Error("API 엔드포인트가 없습니다.");
|
|
}
|
|
|
|
let response;
|
|
switch (method) {
|
|
case "get":
|
|
response = await apiClient.get(endpoint);
|
|
break;
|
|
case "post":
|
|
response = await apiClient.post(endpoint, body);
|
|
break;
|
|
case "put":
|
|
response = await apiClient.put(endpoint, body);
|
|
break;
|
|
case "delete":
|
|
response = await apiClient.delete(endpoint, { data: body });
|
|
break;
|
|
default:
|
|
throw new Error(`지원하지 않는 HTTP 메서드: ${method}`);
|
|
}
|
|
|
|
// 성공 메시지
|
|
if (step.successMessage) {
|
|
toast.success(step.successMessage);
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
// Navigate 스텝 실행
|
|
const executeNavigateStep = (step: any) => {
|
|
const path = step.path;
|
|
if (!path) {
|
|
console.warn("Navigate 스텝에 path가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
// Next.js router로 화면 이동
|
|
router.push(path);
|
|
};
|
|
|
|
// 버튼 variant 매핑
|
|
const getButtonVariant = () => {
|
|
switch (config.buttonType) {
|
|
case "primary":
|
|
return "default";
|
|
case "secondary":
|
|
return "secondary";
|
|
case "destructive":
|
|
return "destructive";
|
|
case "ghost":
|
|
return "ghost";
|
|
case "outline":
|
|
return "outline";
|
|
default:
|
|
return "default";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
id={id}
|
|
variant={getButtonVariant()}
|
|
onClick={handleClick}
|
|
disabled={disabled || loading || isDesignMode}
|
|
className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}
|
|
>
|
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{config.label}
|
|
</Button>
|
|
|
|
{/* 확인 대화상자 */}
|
|
{config.confirmDialog && (
|
|
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{config.confirmDialog.title || "확인"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{config.confirmDialog.message || "이 작업을 수행하시겠습니까?"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowConfirm(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
variant={config.buttonType === "destructive" ? "destructive" : "default"}
|
|
onClick={executeAction}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
확인
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|