413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
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<string, string> = {
|
||
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<SyncStatusResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [guideOpen, setGuideOpen] = useState(false);
|
||
|
||
// Tab & accordion state
|
||
const [activeDomain, setActiveDomain] = useState<string>('ship');
|
||
const [expandedTable, setExpandedTable] = useState<string>('ship-001');
|
||
const [detailTabs, setDetailTabs] = useState<Record<string, 'preview' | 'stuck'>>({});
|
||
|
||
// 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 <LoadingSpinner />;
|
||
if (!data) return <EmptyState message="데이터를 불러올 수 없습니다" />;
|
||
|
||
return (
|
||
<div>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h1 className="text-2xl font-bold text-wing-text">동기화 현황</h1>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={refreshing}
|
||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-wing-border transition-colors
|
||
${refreshing
|
||
? 'text-wing-muted cursor-not-allowed'
|
||
: 'text-wing-text hover:bg-wing-hover'
|
||
}`}
|
||
>
|
||
<span className={refreshing ? 'animate-spin' : ''}>↻</span>
|
||
{refreshing ? '조회 중...' : '새로고침'}
|
||
</button>
|
||
<HelpButton onClick={() => setGuideOpen(true)} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Domain Tabs ── */}
|
||
<div className="flex gap-0 overflow-x-auto border-b border-wing-border mb-4">
|
||
{data.domains.map((d) => {
|
||
const stuckCount = d.tables.filter((t) => t.stuck).length;
|
||
return (
|
||
<button
|
||
key={d.domain}
|
||
onClick={() => setActiveDomain(d.domain)}
|
||
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||
${activeDomain === d.domain
|
||
? 'border-wing-accent text-wing-accent'
|
||
: 'border-transparent text-wing-muted hover:text-wing-text hover:border-wing-border'
|
||
}`}
|
||
>
|
||
<span className="mr-1">{DOMAIN_ICONS[d.domain] || ''}</span>
|
||
{d.domainLabel}
|
||
<span className="ml-1 text-xs opacity-60">({d.tables.length})</span>
|
||
{stuckCount > 0 && (
|
||
<span className="ml-1.5 inline-flex items-center justify-center w-5 h-5 text-[10px] font-bold text-white bg-red-500 rounded-full">
|
||
{stuckCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* ── Table Accordions ── */}
|
||
{activeDomainGroup && (
|
||
<div className="space-y-2">
|
||
{activeDomainGroup.tables.map((table) => (
|
||
<TableAccordion
|
||
key={table.tableKey}
|
||
table={table}
|
||
expanded={expandedTable === table.tableKey}
|
||
detailTab={getDetailTab(table.tableKey)}
|
||
onToggle={() => toggleAccordion(table.tableKey)}
|
||
onDetailTabChange={(tab) => setDetailTab(table.tableKey, tab)}
|
||
onReset={() => { setResetTableKey(table.tableKey); setResetConfirmOpen(true); }}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reset Confirm Modal */}
|
||
<ConfirmModal
|
||
open={resetConfirmOpen}
|
||
title="P→N 리셋 확인"
|
||
message={`${resetTable?.sourceTable ?? ''}의 P 상태 레코드를 모두 N(대기)으로 리셋하시겠습니까?\n리셋된 레코드는 다음 동기화 실행 시 재처리됩니다.`}
|
||
confirmLabel={resetting ? '리셋 중...' : '리셋'}
|
||
onConfirm={handleReset}
|
||
onCancel={() => setResetConfirmOpen(false)}
|
||
/>
|
||
|
||
<GuideModal
|
||
open={guideOpen}
|
||
pageTitle="동기화 현황"
|
||
sections={GUIDE_ITEMS}
|
||
onClose={() => setGuideOpen(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className={`bg-wing-card rounded-xl border overflow-hidden
|
||
${table.stuck ? 'border-amber-400 ring-1 ring-amber-100' : 'border-wing-border'}`}>
|
||
{/* Accordion header */}
|
||
<button
|
||
onClick={onToggle}
|
||
className="w-full flex items-center justify-between px-5 py-3 hover:bg-wing-hover transition-colors text-left"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{table.stuck && <span className="text-red-500">⚠️</span>}
|
||
<div>
|
||
<span className="text-wing-text font-semibold text-sm">{table.targetTable}</span>
|
||
<span className="text-xs text-wing-muted ml-2">{table.tableKey}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full font-semibold tabular-nums bg-amber-100 text-amber-700">
|
||
{table.pendingCount.toLocaleString()}
|
||
</span>
|
||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full font-semibold tabular-nums
|
||
${table.stuck ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
|
||
{table.processingCount.toLocaleString()}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full font-semibold tabular-nums bg-emerald-100 text-emerald-700">
|
||
{table.completedCount.toLocaleString()}
|
||
</span>
|
||
<span className="text-wing-muted w-16 text-right ml-1">{formatRelativeTime(table.lastSyncTime)}</span>
|
||
<span className="text-wing-muted">{expanded ? '▲' : '▼'}</span>
|
||
</div>
|
||
</button>
|
||
|
||
{/* Accordion body */}
|
||
{expanded && (
|
||
<div className="border-t border-wing-border p-5">
|
||
{/* Stats row */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||
<MiniStat label="대기 (N)" value={table.pendingCount} color="text-amber-600 dark:text-amber-400" />
|
||
<MiniStat label="진행 (P)" value={table.processingCount} color="text-blue-600 dark:text-blue-400"
|
||
warn={table.stuck} />
|
||
<MiniStat label="완료 (S)" value={table.completedCount} color="text-emerald-600 dark:text-emerald-400" />
|
||
<div className="bg-wing-bg rounded-lg p-3">
|
||
<p className="text-xs text-wing-muted">최근 동기화</p>
|
||
<p className="text-sm font-medium text-wing-text mt-0.5">{formatRelativeTime(table.lastSyncTime)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail sub-tabs */}
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => onDetailTabChange('preview')}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||
${detailTab === 'preview'
|
||
? 'bg-wing-accent text-white'
|
||
: 'bg-wing-bg text-wing-muted hover:bg-wing-hover'
|
||
}`}
|
||
>
|
||
동기화 데이터
|
||
</button>
|
||
<button
|
||
onClick={() => onDetailTabChange('stuck')}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||
${detailTab === 'stuck'
|
||
? 'bg-wing-accent text-white'
|
||
: 'bg-wing-bg text-wing-muted hover:bg-wing-hover'
|
||
}`}
|
||
>
|
||
P 상태 레코드
|
||
{table.processingCount > 0 && (
|
||
<span className="ml-1 text-red-300">({table.processingCount.toLocaleString()})</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
{detailTab === 'stuck' && table.stuck && (
|
||
<button
|
||
onClick={onReset}
|
||
className="px-3 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
|
||
>
|
||
전체 P→N 리셋
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tab content */}
|
||
{detailTab === 'preview' && (
|
||
<InlineDataTable tableKey={table.tableKey} fetchFn={batchApi.getSyncDataPreview} />
|
||
)}
|
||
{detailTab === 'stuck' && (
|
||
<InlineDataTable tableKey={table.tableKey} fetchFn={batchApi.getStuckRecords} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface MiniStatProps {
|
||
label: string;
|
||
value: number;
|
||
color: string;
|
||
warn?: boolean;
|
||
}
|
||
|
||
function MiniStat({ label, value, color, warn }: MiniStatProps) {
|
||
return (
|
||
<div className={`bg-wing-bg rounded-lg p-3 ${warn ? 'ring-1 ring-red-400' : ''}`}>
|
||
<p className="text-xs text-wing-muted">{label}</p>
|
||
<p className={`text-lg font-bold mt-0.5 tabular-nums ${color}`}>
|
||
{value.toLocaleString()}
|
||
{warn && <span className="ml-1 text-xs text-red-500">고착</span>}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface InlineDataTableProps {
|
||
tableKey: string;
|
||
fetchFn: (tableKey: string, limit: number) => Promise<SyncDataPreviewResponse>;
|
||
}
|
||
|
||
function InlineDataTable({ tableKey, fetchFn }: InlineDataTableProps) {
|
||
const [data, setData] = useState<SyncDataPreviewResponse | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(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 <div className="py-8"><LoadingSpinner /></div>;
|
||
if (error) return <div className="text-center py-8 text-red-400 text-sm">조회 실패: {error}</div>;
|
||
if (!data || data.rows.length === 0) {
|
||
return <div className="text-center py-8 text-wing-muted text-sm">데이터가 없습니다</div>;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="overflow-x-auto rounded-lg border border-wing-border">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-wing-border">
|
||
{data.columns.map((col) => (
|
||
<th
|
||
key={col}
|
||
className={`px-3 py-2 text-left font-medium whitespace-nowrap bg-wing-bg
|
||
${col === 'batch_flag' ? 'text-blue-500' : 'text-wing-muted'}`}
|
||
>
|
||
{col}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.rows.map((row, idx) => (
|
||
<tr key={idx} className="border-b border-wing-border/50 hover:bg-wing-hover">
|
||
{data.columns.map((col) => (
|
||
<td
|
||
key={col}
|
||
className={`px-3 py-1.5 whitespace-nowrap max-w-[200px] truncate
|
||
${col === 'batch_flag' ? 'font-bold text-blue-600 dark:text-blue-400' : 'text-wing-text'}`}
|
||
>
|
||
{formatCellValue(row[col])}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="text-xs text-wing-muted mt-2">
|
||
{data.rows.length}건 표시 (전체 {data.totalCount.toLocaleString()}건) · {data.targetSchema}.{data.targetTable}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 '-';
|
||
}
|
||
}
|