snp-batch-validation/frontend/src/pages/ExecutionDetail.tsx
HYOJIN ba7c5af5f1 fix: S&P Collector 다크모드 미적용 및 라벨 디자인 통일 (#122)
- 실행이력상세/재수집이력상세 API 호출 로그 다크모드 적용
- 개별 호출 로그 (ApiLogSection) 필터/테이블 다크모드 적용
- 작업관리 스케줄 라벨 rounded-full 및 디자인 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:52:23 +09:00

709 lines
36 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } from '../api/batchApi';
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
import StatusBadge from '../components/StatusBadge';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import Pagination from '../components/Pagination';
import DetailStatCard from '../components/DetailStatCard';
import ApiLogSection from '../components/ApiLogSection';
import InfoItem from '../components/InfoItem';
import GuideModal, { HelpButton } from '../components/GuideModal';
const POLLING_INTERVAL_MS = 5000;
const EXECUTION_DETAIL_GUIDE = [
{
title: '실행 기본 정보',
content: '실행의 시작/종료 시간, 소요 시간, 종료 코드, 에러 메시지 등 기본 정보를 보여줍니다.\n실행 중인 경우 5초마다 자동으로 갱신됩니다.',
},
{
title: '처리 통계',
content: '4개의 통계 카드로 전체 처리 현황을 요약합니다.\n• 읽기(Read): 외부 API에서 조회한 건수\n• 쓰기(Write): DB에 저장된 건수\n• 건너뜀(Skip): 처리하지 않은 건수\n• 필터(Filter): 조건에 의해 제외된 건수',
},
{
title: 'Step 실행 정보',
content: '배치 작업은 하나 이상의 Step으로 구성됩니다.\n각 Step의 상태, 처리 건수, 커밋/롤백 횟수를 확인할 수 있습니다.\nAPI 호출 정보에서는 총 호출 수, 성공/에러 수, 평균 응답 시간을 보여줍니다.',
},
{
title: 'API 호출 로그',
content: '각 Step에서 호출한 외부 API의 상세 로그를 확인할 수 있습니다.\n요청 URL, 응답 코드, 응답 시간 등을 페이지 단위로 조회합니다.',
},
{
title: '실패 건 관리',
content: '처리 중 실패한 레코드가 있으면 목록으로 표시됩니다.\n• 실패 건 재수집: 실패한 데이터를 다시 수집합니다\n• 일괄 RESOLVED: 모든 실패 건을 해결됨으로 처리합니다\n• 재시도 초기화: 재시도 횟수를 초기화하여 자동 재수집 대상에 포함시킵니다',
},
];
interface StepCardProps {
step: StepExecutionDto;
jobName: string;
jobExecutionId: number;
}
function StepCard({ step, jobName, jobExecutionId }: StepCardProps) {
const stats = [
{ label: '읽기', value: step.readCount },
{ label: '쓰기', value: step.writeCount },
{ label: '커밋', value: step.commitCount },
{ label: '롤백', value: step.rollbackCount },
{ label: '읽기 건너뜀', value: step.readSkipCount },
{ label: '처리 건너뜀', value: step.processSkipCount },
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
{ label: '필터', value: step.filterCount },
];
return (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-wing-text">
{step.stepName}
</h3>
<StatusBadge status={step.status} />
</div>
<span className="text-sm text-wing-muted">
{step.duration != null
? formatDuration(step.duration)
: calculateDuration(step.startTime, step.endTime)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
<div className="text-wing-muted">
: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
</div>
<div className="text-wing-muted">
: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map(({ label, value }) => (
<div
key={label}
className="rounded-lg bg-wing-card px-3 py-2 text-center"
>
<p className="text-lg font-bold text-wing-text">
{value.toLocaleString()}
</p>
<p className="text-xs text-wing-muted">{label}</p>
</div>
))}
</div>
{/* API 호출 정보: apiLogSummary가 있으면 개별 로그 리스트, 없으면 기존 apiCallInfo 요약 */}
{step.apiLogSummary ? (
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-wing-text">{step.apiLogSummary.totalCalls.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"> </p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-600">{step.apiLogSummary.successCount.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className={`text-sm font-bold ${step.apiLogSummary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
{step.apiLogSummary.errorCount.toLocaleString()}
</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-blue-600">{Math.round(step.apiLogSummary.avgResponseMs).toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-red-500">{step.apiLogSummary.maxResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-wing-surface px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-500">{step.apiLogSummary.minResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
</div>
{step.apiLogSummary.totalCalls > 0 && (
<ApiLogSection stepExecutionId={step.stepExecutionId} summary={step.apiLogSummary} />
)}
</div>
) : step.apiCallInfo && (
<div className="mt-4 rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-blue-500">URL:</span>{' '}
<span className="text-blue-900 font-mono break-all">{step.apiCallInfo.apiUrl}</span>
</div>
<div>
<span className="text-blue-500">Method:</span>{' '}
<span className="text-blue-900 font-semibold">{step.apiCallInfo.method}</span>
</div>
<div>
<span className="text-blue-500">:</span>{' '}
<span className="text-blue-900">{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}</span>
</div>
{step.apiCallInfo.lastCallTime && (
<div>
<span className="text-blue-500">:</span>{' '}
<span className="text-blue-900">{step.apiCallInfo.lastCallTime}</span>
</div>
)}
</div>
</div>
)}
{/* 호출 실패 데이터 토글 */}
{step.failedRecords && step.failedRecords.length > 0 && (
<FailedRecordsToggle records={step.failedRecords} jobName={jobName} jobExecutionId={jobExecutionId} />
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
{step.exitMessage}
</p>
</div>
)}
</div>
);
}
export default function ExecutionDetail() {
const { id: paramId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const executionId = paramId
? Number(paramId)
: Number(searchParams.get('id'));
const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [guideOpen, setGuideOpen] = useState(false);
const isRunning = detail
? detail.status === 'STARTED' || detail.status === 'STARTING'
: false;
const loadDetail = useCallback(async () => {
if (!executionId || isNaN(executionId)) {
setError('유효하지 않은 실행 ID입니다.');
setLoading(false);
return;
}
try {
const data = await batchApi.getExecutionDetail(executionId);
setDetail(data);
setError(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: '실행 상세 정보를 불러오지 못했습니다.',
);
} finally {
setLoading(false);
}
}, [executionId]);
/* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [
executionId,
]);
if (loading) return <LoadingSpinner />;
if (error || !detail) {
return (
<div className="space-y-4">
<button
onClick={() => navigate('/executions')}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
<EmptyState
icon="&#x26A0;"
message={error || '실행 정보를 찾을 수 없습니다.'}
/>
</div>
);
}
const jobParams = Object.entries(detail.jobParameters);
return (
<div className="space-y-6">
{/* 상단 내비게이션 */}
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
{/* Job 기본 정보 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text">
#{detail.executionId}
</h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted">
{detail.jobName}
</p>
</div>
<StatusBadge status={detail.status} className="text-sm" />
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<InfoItem label="시작시간" value={formatDateTime(detail.startTime)} />
<InfoItem label="종료시간" value={formatDateTime(detail.endTime)} />
<InfoItem
label="소요시간"
value={
detail.duration != null
? formatDuration(detail.duration)
: calculateDuration(detail.startTime, detail.endTime)
}
/>
<InfoItem label="Exit Code" value={detail.exitCode} />
{detail.exitMessage && (
<div className="sm:col-span-2 lg:col-span-3">
<InfoItem label="Exit Message" value={detail.exitMessage} />
</div>
)}
</div>
</div>
{/* 실행 통계 카드 4개 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<DetailStatCard
label="읽기 (Read)"
value={detail.readCount}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<DetailStatCard
label="쓰기 (Write)"
value={detail.writeCount}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<DetailStatCard
label="건너뜀 (Skip)"
value={detail.skipCount}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<DetailStatCard
label="필터 (Filter)"
value={detail.filterCount}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
icon="&#x1F50D;"
/>
</div>
{/* Job Parameters */}
{jobParams.length > 0 && (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
Job Parameters
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-6 py-3 font-medium">Key</th>
<th className="px-6 py-3 font-medium">Value</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{jobParams.map(([key, value]) => (
<tr
key={key}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-6 py-3 font-mono text-wing-text">
{key}
</td>
<td className="px-6 py-3 text-wing-muted break-all">
{value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Step 실행 정보 */}
<div>
<h2 className="text-lg font-semibold text-wing-text mb-4">
Step
<span className="ml-2 text-sm font-normal text-wing-muted">
({detail.stepExecutions.length})
</span>
</h2>
{detail.stepExecutions.length === 0 ? (
<EmptyState message="Step 실행 정보가 없습니다." />
) : (
<div className="space-y-4">
{detail.stepExecutions.map((step) => (
<StepCard
key={step.stepExecutionId}
step={step}
jobName={detail.jobName}
jobExecutionId={executionId}
/>
))}
</div>
)}
</div>
<GuideModal
open={guideOpen}
onClose={() => setGuideOpen(false)}
pageTitle="실행 상세"
sections={EXECUTION_DETAIL_GUIDE}
/>
</div>
);
}
const FAILED_PAGE_SIZE = 10;
function FailedRecordsToggle({ records, jobName, jobExecutionId }: { records: FailedRecordDto[]; jobName: string; jobExecutionId: number }) {
const [open, setOpen] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
const [retrying, setRetrying] = useState(false);
const [resolving, setResolving] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetting, setResetting] = useState(false);
const [page, setPage] = useState(0);
const navigate = useNavigate();
const failedRecords = records.filter((r) => r.status === 'FAILED');
const totalPages = Math.ceil(records.length / FAILED_PAGE_SIZE);
const pagedRecords = records.slice(page * FAILED_PAGE_SIZE, (page + 1) * FAILED_PAGE_SIZE);
const statusColor = (status: string) => {
switch (status) {
case 'RESOLVED': return 'text-emerald-600 bg-emerald-50';
case 'RETRY_PENDING': return 'text-amber-600 bg-amber-50';
default: return 'text-red-600 bg-red-50';
}
};
const MAX_RETRY_COUNT = 3;
const retryStatusLabel = (record: FailedRecordDto) => {
if (record.status !== 'FAILED') return null;
if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' };
if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' };
return { label: '대기', color: 'text-blue-600 bg-blue-100' };
};
const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT);
const handleRetry = async () => {
setRetrying(true);
try {
const result = await batchApi.retryFailedRecords(jobName, failedRecords.length, jobExecutionId);
if (result.success) {
setShowConfirm(false);
if (result.executionId) {
navigate(`/executions/${result.executionId}`);
} else {
alert(result.message || '재수집이 요청되었습니다.');
}
} else {
alert(result.message || '재수집 실행에 실패했습니다.');
}
} catch {
alert('재수집 실행에 실패했습니다.');
} finally {
setRetrying(false);
}
};
const handleResolve = async () => {
setResolving(true);
try {
const ids = failedRecords.map((r) => r.id);
await batchApi.resolveFailedRecords(ids);
setShowResolveConfirm(false);
navigate(0);
} catch {
alert('일괄 RESOLVED 처리에 실패했습니다.');
} finally {
setResolving(false);
}
};
const handleResetRetry = async () => {
setResetting(true);
try {
const ids = exceededRecords.map((r) => r.id);
await batchApi.resetRetryCount(ids);
setShowResetConfirm(false);
navigate(0);
} catch {
alert('재시도 초기화에 실패했습니다.');
} finally {
setResetting(false);
}
};
return (
<div className="mt-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3">
<div className="flex items-center justify-between">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-red-600 hover:text-red-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({records.length.toLocaleString()}, FAILED {failedRecords.length})
</button>
{failedRecords.length > 0 && (
<div className="flex items-center gap-1.5">
{exceededRecords.length > 0 && (
<button
onClick={() => setShowResetConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({exceededRecords.length})
</button>
)}
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
RESOLVED ({failedRecords.length})
</button>
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
</div>
)}
</div>
{open && (
<div className="mt-2">
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-red-500/20 text-red-700">
<tr>
<th className="px-2 py-1.5 font-medium">Record Key</th>
<th className="px-2 py-1.5 font-medium"> </th>
<th className="px-2 py-1.5 font-medium text-center"></th>
<th className="px-2 py-1.5 font-medium text-center"></th>
<th className="px-2 py-1.5 font-medium"> </th>
</tr>
</thead>
<tbody className="divide-y divide-red-500/20">
{pagedRecords.map((record) => (
<tr
key={record.id}
className="bg-wing-surface hover:bg-red-500/10"
>
<td className="px-2 py-1.5 font-mono text-red-900">
{record.recordKey}
</td>
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
{record.errorMessage || '-'}
</td>
<td className="px-2 py-1.5 text-center">
{(() => {
const info = retryStatusLabel(record);
return info ? (
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
{info.label}
</span>
) : (
<span className="text-wing-muted">-</span>
);
})()}
</td>
<td className="px-2 py-1.5 text-center">
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
{record.status}
</span>
</td>
<td className="px-2 py-1.5 text-red-500 whitespace-nowrap">
{formatDateTime(record.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
totalElements={records.length}
pageSize={FAILED_PAGE_SIZE}
onPageChange={setPage}
/>
</div>
)}
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-3">
{failedRecords.length} IMO에 .
</p>
<div className="bg-wing-card rounded-lg p-3 mb-4 max-h-40 overflow-y-auto">
<div className="flex flex-wrap gap-1">
{failedRecords.map((r) => (
<span
key={r.id}
className="inline-flex px-2 py-0.5 text-xs font-mono bg-red-500/20 text-red-700 rounded"
>
{r.recordKey}
</span>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowConfirm(false)}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleRetry}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{retrying ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'재수집 실행'
)}
</button>
</div>
</div>
</div>
)}
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
<p className="text-sm text-wing-muted mb-4">
FAILED {failedRecords.length} RESOLVED로 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResolve}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resolving ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'RESOLVED 처리'
)}
</button>
</div>
</div>
</div>
)}
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-wing-surface rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-4">
{exceededRecords.length} retryCount를 0 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-wing-card hover:bg-wing-hover rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResetRetry}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resetting ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'초기화 실행'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}