import { useState, useCallback, useEffect } from 'react'; import { batchApi, type SyncStatusResponse, type SyncTableStatus, type SyncDataPreviewResponse, } from '../api/batchApi'; import { useToastContext } from '../contexts/ToastContext'; import LoadingSpinner from '../components/LoadingSpinner'; import EmptyState from '../components/EmptyState'; import ConfirmModal from '../components/ConfirmModal'; import GuideModal, { HelpButton } from '../components/GuideModal'; const DOMAIN_ICONS: Record = { ship: '🚒', company: '🏒', event: '⚠️', facility: '🏭', psc: 'πŸ”', movements: 'πŸ“', code: '🏷️', 'risk-compliance': 'πŸ›‘οΈ', }; const GUIDE_ITEMS = [ { title: '도메인 νƒ­', content: 'Ship, PSC λ“± λ„λ©”μΈλ³„λ‘œ ν…Œμ΄λΈ”μ„ κ·Έλ£Ήν•‘ν•˜μ—¬ μ‘°νšŒν•©λ‹ˆλ‹€.\nP κ³ μ°© ν…Œμ΄λΈ”μ΄ μžˆλŠ” λ„λ©”μΈμ—λŠ” κ²½κ³  뱃지가 ν‘œμ‹œλ©λ‹ˆλ‹€.', }, { title: 'ν…Œμ΄λΈ” μ•„μ½”λ””μ–Έ', content: '각 ν…Œμ΄λΈ”μ„ 펼치면 λŒ€κΈ°(N)/μ§„ν–‰(P)/μ™„λ£Œ(S) κ±΄μˆ˜μ™€ 상세 데이터λ₯Ό 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.\n⚠️ ν‘œμ‹œλŠ” P μƒνƒœμ— 고착된 λ ˆμ½”λ“œκ°€ μžˆμŒμ„ μ˜λ―Έν•©λ‹ˆλ‹€.', }, { title: '동기화 데이터 / P μƒνƒœ λ ˆμ½”λ“œ', content: '동기화 데이터 νƒ­: νƒ€κ²Ÿ μŠ€ν‚€λ§ˆ(std_snp_svc)의 졜근 동기화 데이터λ₯Ό λ³΄μ—¬μ€λ‹ˆλ‹€.\nP μƒνƒœ λ ˆμ½”λ“œ νƒ­: Writer μ‹€νŒ¨λ‘œ P μƒνƒœμ— 멈좘 λ ˆμ½”λ“œλ₯Ό ν™•μΈν•˜κ³  리셋할 수 μžˆμŠ΅λ‹ˆλ‹€.', }, ]; export default function SyncStatus() { const { showToast } = useToastContext(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [guideOpen, setGuideOpen] = useState(false); // Tab & accordion state const [activeDomain, setActiveDomain] = useState('ship'); const [expandedTable, setExpandedTable] = useState('ship-001'); const [detailTabs, setDetailTabs] = useState>({}); // Reset confirm const [resetTableKey, setResetTableKey] = useState(''); const [resetConfirmOpen, setResetConfirmOpen] = useState(false); const [resetting, setResetting] = useState(false); const [refreshing, setRefreshing] = useState(false); const loadData = useCallback(async (refresh = false) => { try { const result = await batchApi.getSyncStatus(refresh); setData(result); } catch { showToast('동기화 ν˜„ν™© 쑰회 μ‹€νŒ¨', 'error'); } finally { setLoading(false); setRefreshing(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { loadData(); }, [loadData]); const handleRefresh = () => { setRefreshing(true); loadData(true); }; const toggleAccordion = (tableKey: string) => { setExpandedTable((prev) => (prev === tableKey ? '' : tableKey)); }; const getDetailTab = (tableKey: string) => detailTabs[tableKey] || 'preview'; const setDetailTab = (tableKey: string, tab: 'preview' | 'stuck') => { setDetailTabs((prev) => ({ ...prev, [tableKey]: tab })); }; const handleReset = async () => { setResetting(true); try { const result = await batchApi.resetStuckRecords(resetTableKey); const allTables = data?.domains.flatMap((d) => d.tables) ?? []; const table = allTables.find((t) => t.tableKey === resetTableKey); showToast(`${table?.sourceTable ?? resetTableKey}: ${result.resetCount}건 리셋 μ™„λ£Œ`, 'success'); setResetConfirmOpen(false); loadData(); } catch { showToast('리셋 μ‹€νŒ¨', 'error'); } finally { setResetting(false); } }; const activeDomainGroup = data?.domains.find((d) => d.domain === activeDomain); const resetTable = data?.domains.flatMap((d) => d.tables).find((t) => t.tableKey === resetTableKey); if (loading) return ; if (!data) return ; return (
{/* Header */}

동기화 ν˜„ν™©

setGuideOpen(true)} />
{/* ── Domain Tabs ── */}
{data.domains.map((d) => { const stuckCount = d.tables.filter((t) => t.stuck).length; return ( ); })}
{/* ── Table Accordions ── */} {activeDomainGroup && (
{activeDomainGroup.tables.map((table) => ( toggleAccordion(table.tableKey)} onDetailTabChange={(tab) => setDetailTab(table.tableKey, tab)} onReset={() => { setResetTableKey(table.tableKey); setResetConfirmOpen(true); }} /> ))}
)} {/* Reset Confirm Modal */} setResetConfirmOpen(false)} /> setGuideOpen(false)} />
); } // ── Sub Components ──────────────────────────────────────────── interface TableAccordionProps { table: SyncTableStatus; expanded: boolean; detailTab: 'preview' | 'stuck'; onToggle: () => void; onDetailTabChange: (tab: 'preview' | 'stuck') => void; onReset: () => void; } function TableAccordion({ table, expanded, detailTab, onToggle, onDetailTabChange, onReset }: TableAccordionProps) { return (
{/* Accordion header */} {/* Accordion body */} {expanded && (
{/* Stats row */}

졜근 동기화

{formatRelativeTime(table.lastSyncTime)}

{/* Detail sub-tabs */}
{detailTab === 'stuck' && table.stuck && ( )}
{/* Tab content */} {detailTab === 'preview' && ( )} {detailTab === 'stuck' && ( )}
)}
); } interface MiniStatProps { label: string; value: number; color: string; warn?: boolean; } function MiniStat({ label, value, color, warn }: MiniStatProps) { return (

{label}

{value.toLocaleString()} {warn && κ³ μ°©}

); } interface InlineDataTableProps { tableKey: string; fetchFn: (tableKey: string, limit: number) => Promise; } function InlineDataTable({ tableKey, fetchFn }: InlineDataTableProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); setError(null); setData(null); fetchFn(tableKey, 20) .then(setData) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, [tableKey, fetchFn]); if (loading) return
; if (error) return
쑰회 μ‹€νŒ¨: {error}
; if (!data || data.rows.length === 0) { return
데이터가 μ—†μŠ΅λ‹ˆλ‹€
; } return (
{data.columns.map((col) => ( ))} {data.rows.map((row, idx) => ( {data.columns.map((col) => ( ))} ))}
{col}
{formatCellValue(row[col])}

{data.rows.length}건 ν‘œμ‹œ (전체 {data.totalCount.toLocaleString()}건) · {data.targetSchema}.{data.targetTable}

); } function formatCellValue(value: unknown): string { if (value === null || value === undefined) return '-'; if (typeof value === 'object') return JSON.stringify(value); return String(value); } function formatRelativeTime(dateStr: string | null): string { if (!dateStr) return '-'; try { const date = new Date(dateStr); if (isNaN(date.getTime())) return '-'; const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMin = Math.floor(diffMs / 60000); if (diffMin < 1) return '방금 μ „'; if (diffMin < 60) return `${diffMin}λΆ„ μ „`; const diffHour = Math.floor(diffMin / 60); if (diffHour < 24) return `${diffHour}μ‹œκ°„ μ „`; const diffDay = Math.floor(diffHour / 24); return `${diffDay}일 μ „`; } catch { return '-'; } }