V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -1,82 +1,43 @@
<template>
<div class="ingestion-status">
<div class="header">
<h1>Market Data Ingestion</h1>
<p class="subtitle">Monitor data collection status</p>
</div>
<BatchOperationsPageV2 title="시장 데이터 수집 현황" subtitle="데이터 수집 작업 상태를 모니터링합니다." :state="state">
<template #actions>
<KsButton severity="secondary" label="새로고침" @click="refreshAll" />
</template>
<div class="content">
<!-- Status summary -->
<div v-if="job" class="status-card">
<template #timeline>
<div v-if="job">
<div class="status-header">
<h2>Job {{ job.jobId.substring(0, 8) }}</h2>
<span :class="['status-badge', `status-${job.status.toLowerCase()}`]">
{{ job.status }}
</span>
<KsStatusTag :value="job.status" :severity="statusSeverity(job.status)" />
</div>
<div class="status-grid">
<div class="stat">
<span class="label">Rows Processed</span>
<span class="value">{{ job.rowsProcessed.toLocaleString() }}</span>
</div>
<div class="stat">
<span class="label">Rows Failed</span>
<span class="value error">{{ job.rowsFailed }}</span>
</div>
<div class="stat">
<span class="label">Quality Score</span>
<span class="value">{{ calculateQualityScore(job) }}%</span>
</div>
<div class="stat" v-if="job.durationSeconds">
<span class="label">Duration</span>
<span class="value">{{ job.durationSeconds }}s</span>
</div>
</div>
<div v-if="job.errorMessage" class="error-section">
<strong>Error:</strong> {{ job.errorMessage }}
<div class="stat"><span class="label">처리된 </span><span class="value">{{ formatQuantity(job.rowsProcessed, 0) }}</span></div>
<div class="stat"><span class="label">실패한 </span><span class="value error">{{ formatQuantity(job.rowsFailed, 0) }}</span></div>
<div class="stat"><span class="label">품질 점수</span><span class="value">{{ formatPercent(calculateQualityScore(job) / 100, 0) }}</span></div>
<div v-if="job.durationSeconds" class="stat"><span class="label">소요 시간</span><span class="value">{{ job.durationSeconds }}</span></div>
</div>
<p v-if="job.errorMessage" class="error-section"><strong>오류:</strong> {{ job.errorMessage }}</p>
</div>
<p v-else class="hint">수집 상태를 불러오는 중입니다...</p>
</template>
<!-- Loading state -->
<div v-else class="loading">
<p>Fetching ingestion status...</p>
</div>
<template #records>
<DataGridShell :rows="recentJobs" :columns="historyColumns" empty-message="최근 수집 이력이 없습니다." />
</template>
<!-- Historical jobs -->
<div class="history-section">
<h3>Recent Ingestions</h3>
<table class="history-table">
<thead>
<tr>
<th>Job ID</th>
<th>Status</th>
<th>Rows</th>
<th>Duration</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in recentJobs" :key="idx" :class="`status-${item.status.toLowerCase()}`">
<td>{{ item.jobId.substring(0, 8) }}</td>
<td><span :class="['status-badge', `status-${item.status.toLowerCase()}`]">{{ item.status }}</span></td>
<td>{{ item.rowsProcessed }}</td>
<td>{{ item.durationSeconds ? `${item.durationSeconds}s` : '—' }}</td>
<td>{{ item.completedAt ? new Date(item.completedAt).toLocaleDateString() : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<template #reprocess>
<p class="hint">진행 중이거나 대기 중인 작업이 있으면 10 간격으로 자동 새로고침됩니다.</p>
</template>
</BatchOperationsPageV2>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import BatchOperationsPageV2 from '../../../shared/ui/screen-types/v2/BatchOperationsPageV2.vue'
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
import type { UiGridColumn, UiSeverity } from '../../../shared/ui/adapter/contracts'
import { formatAsOf, formatPercent, formatQuantity } from '../../../shared/formatters/financial'
interface IngestionJob {
jobId: string
@@ -93,6 +54,23 @@ const job = ref<IngestionJob | null>(null)
const recentJobs = ref<IngestionJob[]>([])
const isLoading = ref(true)
const error = ref<string | null>(null)
const state = ref<'LOADING' | 'READY'>('LOADING')
const historyColumns: UiGridColumn[] = [
{ field: 'jobId', header: 'Job ID', formatter: value => String(value).substring(0, 8) },
{ field: 'status', header: '상태' },
{ field: 'rowsProcessed', header: '처리 행', formatter: value => formatQuantity(value as number, 0) },
{ field: 'durationSeconds', header: '소요 시간', formatter: value => value ? `${value}` : '—' },
{ field: 'completedAt', header: '완료 시각', formatter: value => value ? formatAsOf(value as string) : '—' }
]
function statusSeverity(status: string): UiSeverity {
const normalized = status.toLowerCase()
if (normalized === 'completed') return 'success'
if (normalized === 'failed') return 'danger'
if (normalized === 'running') return 'info'
return 'warning'
}
// Fetch latest job status from API
const fetchLatestJob = async () => {
@@ -136,12 +114,18 @@ const fetchRecentJobs = async () => {
}
} catch (err) {
console.error('Failed to fetch recent jobs:', err)
error.value = 'Failed to load job history'
error.value = '수집 이력을 불러오지 못했습니다.'
} finally {
isLoading.value = false
state.value = 'READY'
}
}
function refreshAll() {
state.value = 'LOADING'
Promise.all([fetchLatestJob(), fetchRecentJobs()]).finally(() => { state.value = 'READY' })
}
onMounted(() => {
fetchLatestJob()
fetchRecentJobs()
@@ -164,153 +148,13 @@ const calculateQualityScore = (job: IngestionJob): number => {
</script>
<style scoped>
.ingestion-status {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.header {
margin-bottom: 2rem;
}
.header h1 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
.content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.status-card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
background: var(--surface-elevated);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.status-header h2 {
margin: 0;
font-size: 1.2rem;
}
.status-badge {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.status-completed {
background-color: #10b981;
color: white;
}
.status-badge.status-running {
background-color: #3b82f6;
color: white;
}
.status-badge.status-failed {
background-color: #ef4444;
color: white;
}
.status-badge.status-queued {
background-color: #f59e0b;
color: white;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat .label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat .value {
font-size: 1.5rem;
font-weight: 600;
}
.stat .value.error {
color: #ef4444;
}
.error-section {
margin-top: 1rem;
padding: 1rem;
background-color: #fee2e2;
border-left: 4px solid #ef4444;
color: #7f1d1d;
border-radius: 4px;
}
.history-section h3 {
margin-top: 2rem;
margin-bottom: 1rem;
}
.history-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.history-table thead {
background-color: var(--surface-secondary);
}
.history-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
}
.history-table td {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
.history-table tbody tr.status-completed {
background-color: #f0fdf4;
}
.history-table tbody tr.status-failed {
background-color: #fef2f2;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--ks-space-4); }
.status-header h2 { margin: 0; font-size: var(--ks-font-section); }
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }
.stat { display: flex; flex-direction: column; gap: var(--ks-space-1); }
.stat .label { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); }
.stat .value { font-size: var(--ks-font-page); font-weight: 600; }
.stat .value.error { color: var(--ks-color-danger); }
.error-section { margin-top: var(--ks-space-3); padding: var(--ks-space-3); background: color-mix(in srgb, var(--ks-color-danger) 8%, var(--ks-color-surface)); border-left: 4px solid var(--ks-color-danger); border-radius: var(--ks-radius-sm); }
.hint { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
</style>