snp-batch-validation/frontend/src/pages/Recollects.tsx
HYOJIN 033daff378 feat(ui): 각 화면별 사용자 가이드 추가 (#41)
- GuideModal 컴포넌트 신규 생성 (아코디언 방식 가이드 모달 + HelpButton)
- 8개 페이지에 (?) 도움말 버튼 및 화면별 사용자 가이드 추가
  - 대시보드, 작업 목록, 실행 이력, 실행 상세
  - 재수집 이력, 재수집 상세, 스케줄 관리, 타임라인

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:02:12 +09:00

1074 lines
61 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
batchApi,
type RecollectionHistoryDto,
type RecollectionSearchResponse,
type CollectionPeriodDto,
type LastCollectionStatusDto,
type JobDisplayName,
} from '../api/batchApi';
import { formatDateTime, formatDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import StatusBadge from '../components/StatusBadge';
import InfoModal from '../components/InfoModal';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import GuideModal, { HelpButton } from '../components/GuideModal';
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED';
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
{ value: 'ALL', label: '전체' },
{ value: 'COMPLETED', label: '완료' },
{ value: 'FAILED', label: '실패' },
{ value: 'STARTED', label: '실행중' },
];
const POLLING_INTERVAL_MS = 10_000;
const PAGE_SIZE = 20;
/** datetime 문자열에서 date input용 값 추출 (YYYY-MM-DD) */
function toDateInput(dt: string | null): string {
if (!dt) return '';
return dt.substring(0, 10);
}
/** datetime 문자열에서 time input용 값 추출 (HH:mm) */
function toTimeInput(dt: string | null): string {
if (!dt) return '00:00';
const t = dt.substring(11, 16);
return t || '00:00';
}
/** date + time을 ISO datetime 문자열로 결합 */
function toIsoDateTime(date: string, time: string): string {
return `${date}T${time || '00:00'}:00`;
}
interface PeriodEdit {
fromDate: string;
fromTime: string;
toDate: string;
toTime: string;
}
// ── 수집 상태 임계값 (분 단위) ────────────────────────────────
const THRESHOLD_WARN_MINUTES = 24 * 60; // 24시간
const THRESHOLD_DANGER_MINUTES = 48 * 60; // 48시간
type CollectionStatusLevel = 'normal' | 'warn' | 'danger';
function getStatusLevel(elapsedMinutes: number): CollectionStatusLevel {
if (elapsedMinutes < 0) return 'danger';
if (elapsedMinutes <= THRESHOLD_WARN_MINUTES) return 'normal';
if (elapsedMinutes <= THRESHOLD_DANGER_MINUTES) return 'warn';
return 'danger';
}
function getStatusLabel(level: CollectionStatusLevel): string {
if (level === 'normal') return '정상';
if (level === 'warn') return '주의';
return '경고';
}
function getStatusColor(level: CollectionStatusLevel): string {
if (level === 'normal') return 'text-green-500';
if (level === 'warn') return 'text-yellow-500';
return 'text-red-500';
}
function getStatusBgColor(level: CollectionStatusLevel): string {
if (level === 'normal') return 'bg-green-500/10 text-green-600 dark:text-green-400';
if (level === 'warn') return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400';
return 'bg-red-500/10 text-red-600 dark:text-red-400';
}
function formatElapsed(minutes: number): string {
if (minutes < 0) return '-';
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 ${minutes % 60}분 전`;
const days = Math.floor(hours / 24);
const remainHours = hours % 24;
return remainHours > 0 ? `${days}${remainHours}시간 전` : `${days}일 전`;
}
/** 기간 프리셋 정의 (시간 단위) */
const DURATION_PRESETS = [
{ label: '6시간', hours: 6 },
{ label: '12시간', hours: 12 },
{ label: '하루', hours: 24 },
{ label: '일주일', hours: 168 },
] as const;
/** 시작 날짜+시간에 시간(hours)을 더해 종료 날짜+시간을 반환 */
function addHoursToDateTime(
date: string,
time: string,
hours: number,
): { toDate: string; toTime: string } {
if (!date) return { toDate: '', toTime: '00:00' };
const dt = new Date(`${date}T${time || '00:00'}:00`);
dt.setTime(dt.getTime() + hours * 60 * 60 * 1000);
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, '0');
const d = String(dt.getDate()).padStart(2, '0');
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
return { toDate: `${y}-${m}-${d}`, toTime: `${hh}:${mm}` };
}
export default function Recollects() {
const navigate = useNavigate();
const { showToast } = useToastContext();
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]);
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
const [periods, setPeriods] = useState<CollectionPeriodDto[]>([]);
const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]);
const [selectedApiKey, setSelectedApiKey] = useState('');
const [apiDropdownOpen, setApiDropdownOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [loading, setLoading] = useState(true);
// 날짜 범위 필터 + 페이지네이션
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [useSearch, setUseSearch] = useState(false);
// 실패건 수 (jobExecutionId → count)
const [failedRecordCounts, setFailedRecordCounts] = useState<Record<number, number>>({});
// 실패 로그 모달
const [failLogTarget, setFailLogTarget] = useState<RecollectionHistoryDto | null>(null);
// 가이드 모달
const [guideOpen, setGuideOpen] = useState(false);
const RECOLLECTS_GUIDE = [
{
title: '재수집이란?',
content: '재수집은 특정 기간의 데이터를 다시 수집하는 기능입니다.\n수집 누락이나 데이터 오류가 발생했을 때 사용합니다.\n자동 재수집은 시스템이 실패 건을 자동으로 재시도하며, 수동 재수집은 사용자가 직접 요청합니다.',
},
{
title: '마지막 수집 완료 일시',
content: '각 API별 마지막 수집 완료 일시를 보여줍니다.\n이 정보를 참고하여 재수집이 필요한 기간을 판단할 수 있습니다.',
},
{
title: '재수집 기간 관리',
content: '1. "재수집 기간 관리" 영역에서 수집할 작업을 선택합니다\n2. 수집 기간(시작~종료)을 설정합니다\n3. 재수집 사유를 입력합니다 (선택)\n4. "재수집 요청" 버튼을 클릭합니다\n\n기간 설정 시 기존 수집 기간과 중복되면 경고가 표시됩니다.',
},
{
title: '이력 조회',
content: '작업 선택, 상태 필터, 날짜 범위로 재수집 이력을 검색할 수 있습니다.\n상태: 완료(COMPLETED) / 실패(FAILED) / 실행중(STARTED)\n각 행을 클릭하면 상세 화면으로 이동합니다.',
},
];
// 수집 기간 관리 패널
const [periodPanelOpen, setPeriodPanelOpen] = useState(false);
const [selectedPeriodKey, setSelectedPeriodKey] = useState<string>('');
const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false);
const [periodEdits, setPeriodEdits] = useState<Record<string, PeriodEdit>>({});
const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
const [executingApiKey, setExecutingApiKey] = useState<string | null>(null);
const [manualToDate, setManualToDate] = useState<Record<string, boolean>>({});
const [selectedDuration, setSelectedDuration] = useState<Record<string, number | null>>({});
const getPeriodEdit = (p: CollectionPeriodDto): PeriodEdit => {
if (periodEdits[p.apiKey]) return periodEdits[p.apiKey];
return {
fromDate: toDateInput(p.rangeFromDate),
fromTime: toTimeInput(p.rangeFromDate),
toDate: toDateInput(p.rangeToDate),
toTime: toTimeInput(p.rangeToDate),
};
};
const updatePeriodEdit = (apiKey: string, field: keyof PeriodEdit, value: string) => {
const current = periodEdits[apiKey] || getPeriodEdit(periods.find((p) => p.apiKey === apiKey)!);
setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...current, [field]: value } }));
};
const applyDurationPreset = (apiKey: string, hours: number) => {
const p = periods.find((pp) => pp.apiKey === apiKey);
if (!p) return;
const edit = periodEdits[apiKey] || getPeriodEdit(p);
if (!edit.fromDate) {
showToast('재수집 시작일시를 먼저 선택해 주세요.', 'error');
return;
}
const { toDate, toTime } = addHoursToDateTime(edit.fromDate, edit.fromTime, hours);
setSelectedDuration((prev) => ({ ...prev, [apiKey]: hours }));
setPeriodEdits((prev) => ({
...prev,
[apiKey]: { ...edit, toDate, toTime },
}));
};
const handleFromDateChange = (apiKey: string, field: 'fromDate' | 'fromTime', value: string) => {
const p = periods.find((pp) => pp.apiKey === apiKey);
if (!p) return;
const edit = periodEdits[apiKey] || getPeriodEdit(p);
const updated = { ...edit, [field]: value };
// 기간 프리셋이 선택된 상태면 종료일시도 자동 갱신
const dur = selectedDuration[apiKey];
if (dur != null && !manualToDate[apiKey]) {
const { toDate, toTime } = addHoursToDateTime(updated.fromDate, updated.fromTime, dur);
updated.toDate = toDate;
updated.toTime = toTime;
}
setPeriodEdits((prev) => ({ ...prev, [apiKey]: updated }));
};
const handleResetPeriod = async (p: CollectionPeriodDto) => {
setSavingApiKey(p.apiKey);
try {
await batchApi.resetCollectionPeriod(p.apiKey);
showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success');
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
await loadPeriods();
} catch (err) {
showToast(err instanceof Error ? err.message : '수집 기간 초기화에 실패했습니다.', 'error');
} finally {
setSavingApiKey(null);
}
};
const handleSavePeriod = async (p: CollectionPeriodDto) => {
const edit = getPeriodEdit(p);
if (!edit.fromDate || !edit.toDate) {
showToast('시작일과 종료일을 모두 입력해 주세요.', 'error');
return;
}
const from = toIsoDateTime(edit.fromDate, edit.fromTime);
const to = toIsoDateTime(edit.toDate, edit.toTime);
const now = new Date().toISOString().substring(0, 19);
if (from >= now) {
showToast('재수집 시작일시는 현재 시간보다 이전이어야 합니다.', 'error');
return;
}
if (to >= now) {
showToast('재수집 종료일시는 현재 시간보다 이전이어야 합니다.', 'error');
return;
}
if (from >= to) {
showToast('재수집 시작일시는 종료일시보다 이전이어야 합니다.', 'error');
return;
}
setSavingApiKey(p.apiKey);
try {
await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to });
showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success');
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
await loadPeriods();
} catch (err) {
showToast(err instanceof Error ? err.message : '수집 기간 저장에 실패했습니다.', 'error');
} finally {
setSavingApiKey(null);
}
};
const handleExecuteRecollect = async (p: CollectionPeriodDto) => {
if (!p.jobName) {
showToast('연결된 Job이 없습니다.', 'error');
return;
}
setExecutingApiKey(p.apiKey);
try {
const result = await batchApi.executeJob(p.jobName, {
executionMode: 'RECOLLECT',
apiKey: p.apiKey,
executor: 'MANUAL',
reason: '수집 기간 관리 화면에서 수동 실행',
});
showToast(result.message || `${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
setLoading(true);
} catch (err) {
showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error');
} finally {
setExecutingApiKey(null);
}
};
const loadLastCollectionStatuses = useCallback(async () => {
try {
const data = await batchApi.getLastCollectionStatuses();
setLastCollectionStatuses(data);
} catch {
/* 수집 성공일시 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */
}
}, []);
const loadPeriods = useCallback(async () => {
try {
const data = await batchApi.getCollectionPeriods();
setPeriods(data);
} catch {
/* 수집기간 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */
}
}, []);
const loadMetadata = useCallback(async () => {
await Promise.all([loadLastCollectionStatuses(), loadPeriods()]);
}, [loadLastCollectionStatuses, loadPeriods]);
const [initialLoad, setInitialLoad] = useState(true);
const loadHistories = useCallback(async () => {
try {
const params: {
apiKey?: string;
status?: string;
fromDate?: string;
toDate?: string;
page?: number;
size?: number;
} = {
page: useSearch ? page : 0,
size: PAGE_SIZE,
};
if (selectedApiKey) params.apiKey = selectedApiKey;
if (statusFilter !== 'ALL') params.status = statusFilter;
if (useSearch && startDate) params.fromDate = `${startDate}T00:00:00`;
if (useSearch && endDate) params.toDate = `${endDate}T23:59:59`;
const data: RecollectionSearchResponse = await batchApi.searchRecollections(params);
setHistories(data.content);
setTotalPages(data.totalPages);
setTotalCount(data.totalElements);
setFailedRecordCounts(data.failedRecordCounts ?? {});
if (!useSearch) setPage(data.number);
setInitialLoad(false);
} catch (err) {
console.error('이력 조회 실패:', err);
/* 초기 로드 실패만 toast, 폴링 중 실패는 console.error만 */
if (initialLoad) {
showToast('재수집 이력을 불러오지 못했습니다.', 'error');
}
setHistories([]);
setTotalPages(0);
setTotalCount(0);
} finally {
setLoading(false);
}
}, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page, showToast, initialLoad]);
usePoller(loadMetadata, 60_000, []);
usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]);
const collectionStatusSummary = useMemo(() => {
let normal = 0, warn = 0, danger = 0;
for (const s of lastCollectionStatuses) {
const level = getStatusLevel(s.elapsedMinutes);
if (level === 'normal') normal++;
else if (level === 'warn') warn++;
else danger++;
}
return { normal, warn, danger, total: lastCollectionStatuses.length };
}, [lastCollectionStatuses]);
const filteredHistories = useMemo(() => {
if (useSearch) return histories;
if (statusFilter === 'ALL') return histories;
return histories.filter((h) => h.executionStatus === statusFilter);
}, [histories, statusFilter, useSearch]);
const handleSearch = async () => {
setUseSearch(true);
setPage(0);
setLoading(true);
await loadHistories();
};
const handleResetSearch = () => {
setUseSearch(false);
setStartDate('');
setEndDate('');
setPage(0);
setTotalPages(0);
setTotalCount(0);
setLoading(true);
};
const handlePageChange = (newPage: number) => {
if (newPage < 0 || newPage >= totalPages) return;
setPage(newPage);
setLoading(true);
};
useEffect(() => {
batchApi.getDisplayNames()
.then(setDisplayNames)
.catch(() => { /* displayName 로드 실패 무시 */ });
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
}
return map;
}, [displayNames]);
const getApiLabel = (apiKey: string) => {
if (displayNameMap[apiKey]) return displayNameMap[apiKey];
const p = periods.find((p) => p.apiKey === apiKey);
return p?.apiKeyName || apiKey;
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<HelpButton onClick={() => setGuideOpen(true)} />
</div>
<p className="mt-1 text-sm text-wing-muted">
.
</p>
</div>
{/* 마지막 수집 성공일시 패널 */}
<div className="bg-wing-surface rounded-xl shadow-md">
<button
onClick={() => setLastCollectionPanelOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
>
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-wing-muted transition-transform ${lastCollectionPanelOpen ? '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>
<span className="text-sm font-semibold text-wing-text"> </span>
<span className="text-xs text-wing-muted">({collectionStatusSummary.total})</span>
{collectionStatusSummary.danger > 0 && (
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400">
{collectionStatusSummary.danger}
</span>
)}
{collectionStatusSummary.warn > 0 && (
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
{collectionStatusSummary.warn}
</span>
)}
</div>
<span className="text-xs text-wing-muted">
{lastCollectionPanelOpen ? '접기' : '펼치기'}
</span>
</button>
{lastCollectionPanelOpen && (
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
{lastCollectionStatuses.length === 0 ? (
<div className="py-4 text-center text-sm text-wing-muted">
.
</div>
) : (
<>
{/* 요약 바 */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500" />
<span className="text-wing-muted"></span>
<span className="font-semibold text-wing-text">{collectionStatusSummary.normal}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-yellow-500" />
<span className="text-wing-muted"></span>
<span className="font-semibold text-wing-text">{collectionStatusSummary.warn}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-wing-muted"></span>
<span className="font-semibold text-wing-text">{collectionStatusSummary.danger}</span>
</div>
<span className="text-xs text-wing-muted ml-auto">
기준: 정상 24h · 24~48h · 48h
</span>
</div>
{/* 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border/50">
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"> </th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"> </th>
<th className="text-left py-2 px-3 text-xs font-medium text-wing-muted"></th>
<th className="text-center py-2 px-3 text-xs font-medium text-wing-muted"></th>
</tr>
</thead>
<tbody>
{lastCollectionStatuses.map((s) => {
const level = getStatusLevel(s.elapsedMinutes);
return (
<tr key={s.apiKey} className="border-b border-wing-border/30 hover:bg-wing-hover/50 transition-colors">
<td className="py-2.5 px-3">
<div className="text-wing-text font-medium">{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}</div>
</td>
<td className="py-2.5 px-3 font-mono text-xs text-wing-muted">
{formatDateTime(s.lastSuccessDate)}
</td>
<td className={`py-2.5 px-3 text-xs font-medium ${getStatusColor(level)}`}>
{formatElapsed(s.elapsedMinutes)}
</td>
<td className="py-2.5 px-3 text-center">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBgColor(level)}`}>
{level === 'normal' && '●'}
{level === 'warn' && '▲'}
{level === 'danger' && '■'}
{' '}{getStatusLabel(level)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
)}
</div>
{/* 수집 기간 관리 패널 */}
<div className="bg-wing-surface rounded-xl shadow-md">
<button
onClick={() => setPeriodPanelOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
>
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-wing-muted transition-transform ${periodPanelOpen ? '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>
<span className="text-sm font-semibold text-wing-text"> </span>
<span className="text-xs text-wing-muted">({periods.length})</span>
</div>
<span className="text-xs text-wing-muted">
{periodPanelOpen ? '접기' : '펼치기'}
</span>
</button>
{periodPanelOpen && (
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
{periods.length === 0 ? (
<div className="py-4 text-center text-sm text-wing-muted">
.
</div>
) : (
<>
{/* 작업 선택 드롭다운 */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="relative">
<button
onClick={() => setPeriodDropdownOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors min-w-[200px] justify-between"
>
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
{selectedPeriodKey
? (displayNameMap[selectedPeriodKey] || periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
: '작업을 선택하세요'}
</span>
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{periodDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setPeriodDropdownOpen(false)} />
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
{periods.map((p) => (
<button
key={p.apiKey}
onClick={() => {
setSelectedPeriodKey(p.apiKey);
setPeriodDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
selectedPeriodKey === p.apiKey
? 'bg-wing-accent/10 text-wing-accent font-medium'
: 'text-wing-text'
}`}
>
<div>{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}</div>
</button>
))}
</div>
</>
)}
</div>
</div>
{/* 선택된 작업의 기간 편집 */}
{selectedPeriodKey && (() => {
const p = periods.find((pp) => pp.apiKey === selectedPeriodKey);
if (!p) return null;
const edit = getPeriodEdit(p);
const hasChange = !!periodEdits[p.apiKey];
const isSaving = savingApiKey === p.apiKey;
const isExecuting = executingApiKey === p.apiKey;
const isManual = !!manualToDate[p.apiKey];
const activeDur = selectedDuration[p.apiKey] ?? null;
return (
<div className="bg-wing-card rounded-lg p-4 space-y-3">
<div className="flex items-center gap-4 text-sm">
<span className="text-wing-muted">:</span>
<span className="font-mono text-xs text-wing-text">{p.jobName || '-'}</span>
</div>
{/* Line 1: 재수집 시작일시 */}
<div>
<label className="block text-xs font-medium text-wing-muted mb-1"> </label>
<div className="flex items-center gap-1">
<input
type="date"
value={edit.fromDate}
onChange={(e) => handleFromDateChange(p.apiKey, 'fromDate', e.target.value)}
className="flex-[3] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
<input
type="time"
value={edit.fromTime}
onChange={(e) => handleFromDateChange(p.apiKey, 'fromTime', e.target.value)}
className="flex-[2] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
</div>
</div>
{/* Line 2: 기간 선택 버튼 + 직접입력 토글 */}
<div className="flex items-center gap-2 flex-wrap">
{DURATION_PRESETS.map(({ label, hours }) => (
<button
key={hours}
onClick={() => {
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
applyDurationPreset(p.apiKey, hours);
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
!isManual && activeDur === hours
? 'bg-wing-accent text-white border-wing-accent shadow-sm'
: 'bg-wing-surface text-wing-muted border-wing-border hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
<div className="ml-auto flex items-center gap-1.5">
<span className="text-xs text-wing-muted"></span>
<button
onClick={() => {
const next = !isManual;
setManualToDate((prev) => ({ ...prev, [p.apiKey]: next }));
if (next) {
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
}
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
isManual ? 'bg-wing-accent' : 'bg-wing-border'
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
isManual ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Line 3: 재수집 종료일시 */}
<div>
<label className="block text-xs font-medium text-wing-muted mb-1"> </label>
<div className="flex items-center gap-1">
<input
type="date"
value={edit.toDate}
disabled={!isManual}
onChange={(e) => updatePeriodEdit(p.apiKey, 'toDate', e.target.value)}
className={`flex-[3] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${
isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed'
}`}
/>
<input
type="time"
value={edit.toTime}
disabled={!isManual}
onChange={(e) => updatePeriodEdit(p.apiKey, 'toTime', e.target.value)}
className={`flex-[2] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${
isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed'
}`}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={() => handleResetPeriod(p)}
disabled={isSaving}
className="px-4 py-2 text-sm font-medium rounded-lg text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => handleSavePeriod(p)}
disabled={isSaving || !hasChange}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
hasChange
? 'text-white bg-wing-accent hover:bg-wing-accent/80 shadow-sm'
: 'text-wing-muted bg-wing-surface border border-wing-border cursor-not-allowed'
}`}
>
{isSaving ? '저장중...' : '기간 저장'}
</button>
<button
onClick={() => handleExecuteRecollect(p)}
disabled={isExecuting || !p.jobName}
className="px-4 py-2 text-sm font-medium rounded-lg text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isExecuting ? '실행중...' : '재수집 실행'}
</button>
</div>
</div>
);
})()}
</>
)}
</div>
)}
</div>
{/* 필터 영역 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-3">
{/* API 선택 */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="relative">
<button
onClick={() => setApiDropdownOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
>
{selectedApiKey
? getApiLabel(selectedApiKey)
: '전체'}
<svg className={`w-4 h-4 text-wing-muted transition-transform ${apiDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{apiDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setApiDropdownOpen(false)} />
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
<button
onClick={() => { setSelectedApiKey(''); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
!selectedApiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
}`}
>
</button>
{periods.map((p) => (
<button
key={p.apiKey}
onClick={() => { setSelectedApiKey(p.apiKey); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
}`}
>
{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}
</button>
))}
</div>
</>
)}
</div>
{selectedApiKey && (
<button
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
>
</button>
)}
</div>
{selectedApiKey && (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full">
{getApiLabel(selectedApiKey)}
<button
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
className="hover:text-wing-text transition-colors"
>
&times;
</button>
</span>
</div>
)}
</div>
{/* 상태 필터 버튼 그룹 */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map(({ value, label }) => (
<button
key={value}
onClick={() => {
setStatusFilter(value);
setPage(0);
setLoading(true);
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === value
? 'bg-wing-accent text-white shadow-sm'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* 날짜 범위 필터 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
<span className="text-wing-muted text-sm">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSearch}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
>
</button>
{useSearch && (
<button
onClick={handleResetSearch}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
)}
<button
onClick={() => batchApi.exportRecollectionHistories({
apiKey: selectedApiKey || undefined,
status: statusFilter !== 'ALL' ? statusFilter : undefined,
fromDate: useSearch && startDate ? `${startDate}T00:00:00` : undefined,
toDate: useSearch && endDate ? `${endDate}T23:59:59` : undefined,
})}
className="px-4 py-2 text-sm font-medium text-wing-text border border-wing-border rounded-lg hover:bg-wing-hover transition-colors"
>
CSV
</button>
</div>
</div>
</div>
{/* 재수집 이력 테이블 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
{loading ? (
<LoadingSpinner />
) : filteredHistories.length === 0 ? (
<EmptyState
message="재수집 이력이 없습니다."
sub={
statusFilter !== 'ALL'
? '다른 상태 필터를 선택해 보세요.'
: selectedApiKey
? '선택한 API의 재수집 이력이 없습니다.'
: undefined
}
/>
) : (
<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-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-center"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{filteredHistories.map((hist) => (
<tr
key={hist.historyId}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-4 py-4 font-mono text-wing-text">
#{hist.historyId}
</td>
<td className="px-4 py-4 text-wing-text">
<div className="max-w-[120px] truncate" title={displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}>
{displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}
</div>
</td>
<td className="px-4 py-4">
{hist.executionStatus === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(hist)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<StatusBadge status={hist.executionStatus} />
</button>
) : (
<StatusBadge status={hist.executionStatus} />
)}
{hist.hasOverlap && (
<span className="ml-1 text-xs text-amber-500" title="기간 중복 감지">
!</span>
)}
</td>
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
<div>{formatDateTime(hist.rangeFromDate)}</div>
</td>
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
<div>{formatDateTime(hist.rangeToDate)}</div>
</td>
<td className="px-4 py-4 text-wing-muted whitespace-nowrap">
{formatDuration(hist.durationMs)}
</td>
<td className="px-4 py-4 text-center">
{(() => {
const count = hist.jobExecutionId
? (failedRecordCounts[hist.jobExecutionId] ?? 0)
: 0;
if (hist.executionStatus === 'STARTED') {
return <span className="text-xs text-wing-muted">-</span>;
}
return count > 0 ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700">
{count}
</span>
) : (
<span className="text-xs text-wing-muted">0</span>
);
})()}
</td>
<td className="px-4 py-4 text-right">
<button
onClick={() => navigate(`/recollects/${hist.historyId}`)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 결과 건수 + 페이지네이션 */}
{!loading && filteredHistories.length > 0 && (
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
<div className="text-xs text-wing-muted">
{totalCount}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
<span className="text-xs text-wing-muted">
{page + 1} / {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
</div>
)}
</div>
)}
</div>
{/* 실패 로그 뷰어 모달 */}
<InfoModal
open={failLogTarget !== null}
title={
failLogTarget
? `실패 로그 - #${failLogTarget.historyId} (${failLogTarget.jobName})`
: '실패 로그'
}
onClose={() => setFailLogTarget(null)}
>
{failLogTarget && (
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
</h4>
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
{failLogTarget.executionStatus}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
</h4>
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{failLogTarget.failureReason || '실패 사유 없음'}
</pre>
</div>
</div>
)}
</InfoModal>
<GuideModal
open={guideOpen}
pageTitle="재수집 이력"
sections={RECOLLECTS_GUIDE}
onClose={() => setGuideOpen(false)}
/>
</div>
);
}