snp-sync-batch/frontend/src/pages/SyncStatus.tsx

413 lines
16 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
>
PN
</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()}) &middot; {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 '-';
}
}