feat: 배치 관리 페이지 및 API 개선
- 배치 작업 타입 조회 API 추가 및 오류 처리 개선 - 배치 관리 페이지에서 작업 타입을 객체 형태로 변환하여 설정 - 성공률 계산 로직에서 null 체크 추가 - 누적 실행 횟수, 총 성공 횟수, 총 실패 횟수 계산 시 null 체크 추가 Made-with: Cursor
This commit is contained in:
parent
c120492378
commit
b6ec4a4904
|
|
@ -77,7 +77,7 @@ export default function BatchManagementPage() {
|
|||
const loadJobTypes = async () => {
|
||||
try {
|
||||
const types = await BatchAPI.getSupportedJobTypes();
|
||||
setJobTypes(types);
|
||||
setJobTypes(types.map(t => ({ value: t, label: t })));
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
}
|
||||
|
|
@ -172,8 +172,8 @@ export default function BatchManagementPage() {
|
|||
};
|
||||
|
||||
const getSuccessRate = (job: BatchJob) => {
|
||||
if (job.execution_count === 0) return 100;
|
||||
return Math.round((job.success_count / job.execution_count) * 100);
|
||||
if (!job.execution_count) return 100;
|
||||
return Math.round(((job.success_count ?? 0) / job.execution_count) * 100);
|
||||
};
|
||||
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
|
|
@ -361,7 +361,7 @@ export default function BatchManagementPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
||||
{jobs.reduce((sum, job) => sum + (job.execution_count ?? 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||
</CardContent>
|
||||
|
|
@ -373,7 +373,7 @@ export default function BatchManagementPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-success">
|
||||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
||||
{jobs.reduce((sum, job) => sum + (job.success_count ?? 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||
</CardContent>
|
||||
|
|
@ -385,7 +385,7 @@ export default function BatchManagementPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
||||
{jobs.reduce((sum, job) => sum + (job.failure_count ?? 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -209,16 +209,101 @@ function FullWidthOverlayRow({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데스크톱: 원본 캔버스 좌표 기반 렌더링 (transform: scale 로 축소)
|
||||
* 디자이너에서 설계한 레이아웃을 그대로 유지하면서 뷰포트에 맞춤
|
||||
*/
|
||||
function DesktopCanvasRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
renderComponent,
|
||||
}: ResponsiveGridRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerW, setContainerW] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setContainerW(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const scale = containerW > 0 ? Math.min(containerW / canvasWidth, 1) : 1;
|
||||
const scaledHeight = canvasHeight * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-screen-runtime="true"
|
||||
className="bg-background relative w-full overflow-x-hidden"
|
||||
style={{ minHeight: `${scaledHeight}px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${canvasWidth}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{topLevel.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
data-component-id={component.id}
|
||||
data-component-type={typeId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.size?.width ? `${component.size.width}px` : "auto",
|
||||
height: component.size?.height ? `${component.size.height}px` : "auto",
|
||||
zIndex: component.position.z || 1,
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResponsiveGridRenderer({
|
||||
components,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
renderComponent,
|
||||
}: ResponsiveGridRendererProps) {
|
||||
const { isMobile } = useResponsive();
|
||||
const { isMobile, isTablet } = useResponsive();
|
||||
|
||||
// 데스크톱: 원본 캔버스 좌표 유지 (스케일링)
|
||||
// 풀위드스 컴포넌트(테이블, 스플릿패널 등)가 있으면 flex 레이아웃 사용
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
|
||||
const useCanvasLayout = !isMobile && !isTablet && !hasFullWidthComponent;
|
||||
|
||||
if (useCanvasLayout) {
|
||||
return (
|
||||
<DesktopCanvasRenderer
|
||||
components={components}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const processedRows = useMemo(() => {
|
||||
const topLevel = components.filter((c) => !c.parentId);
|
||||
const rows = groupComponentsIntoRows(topLevel);
|
||||
|
||||
const result: ProcessedRow[] = [];
|
||||
|
|
@ -261,7 +346,7 @@ export function ResponsiveGridRenderer({
|
|||
}
|
||||
}
|
||||
return result;
|
||||
}, [components]);
|
||||
}, [topLevel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -88,8 +88,8 @@ export const useWebTypes = (params?: WebTypeQueryParams) => {
|
|||
|
||||
return result.data || [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
|
||||
cacheTime: 10 * 60 * 1000, // 10분간 메모리 보관
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
// 웹타입 생성
|
||||
|
|
|
|||
|
|
@ -56,9 +56,14 @@ export interface BatchJob {
|
|||
job_type: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
schedule_cron?: string;
|
||||
is_active: string;
|
||||
last_execution?: Date;
|
||||
last_executed_at?: string;
|
||||
next_execution?: Date;
|
||||
execution_count?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
status?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
|
|
@ -390,6 +395,52 @@ export class BatchAPI {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 배치 작업 타입 조회
|
||||
*/
|
||||
static async getSupportedJobTypes(): Promise<string[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<string[]>>('/batch-management/job-types');
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "작업 타입 조회에 실패했습니다.");
|
||||
}
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 삭제
|
||||
*/
|
||||
static async deleteBatchJob(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<void>>(`/batch-management/jobs/${id}`);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 작업 삭제 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 실행
|
||||
*/
|
||||
static async executeBatchJob(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<void>>(`/batch-management/jobs/${id}/execute`);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 실행에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 작업 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* auth_tokens 테이블의 서비스명 목록 조회
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue