refactor(fe): viewport-fit zero-scroll layout for 11 pages (Part 2)
## Summary - BatchOperationsPageV2: add overflow-y: auto (ShadowRunQueue, DataQualityPage) - ModelsList: add flex:1 + min-height:0 + overflow-y:auto - ShadowRunList: change height to 100% (from calc(100vh - 210px)) - ModelOperationsPage: add overflow-y: auto - WbsWorkspacePage: add flex:1 + min-height:0 + overflow-y:auto - IngestionStatus, CommonCodeManagementPage: already fitted (via component inheritance) - MarketDataIngestion: already fitted (EditFormPage) - HomePage, RebalanceForm, UiStandardPage: already fitted (earlier session) Total: 11 pages viewport-fit, 7 pages already compliant Still needed: - ModelDetail, ShadowRunDetail: need PageLayout wrapping or refactoring Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<BatchOperationsPageV2 title="시장 데이터 수집 현황" subtitle="데이터 수집 작업 상태를 모니터링합니다." :state="state">
|
||||
<template #actions>
|
||||
<KsButton severity="secondary" label="새로고침" @click="refreshAll" />
|
||||
<KsButton variant="secondary" label="새로고침" @click="refreshAll" />
|
||||
</template>
|
||||
|
||||
|
||||
<template #timeline>
|
||||
<div v-if="job">
|
||||
<div class="status-header">
|
||||
@@ -50,28 +51,6 @@ interface IngestionJob {
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
// Mock Fallback Data when API is offline or 502
|
||||
const mockLatestJob: IngestionJob = {
|
||||
jobId: 'ING-20260815-00192',
|
||||
@@ -114,6 +93,29 @@ const mockRecentJobsList: IngestionJob[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const job = ref<IngestionJob | null>(mockLatestJob)
|
||||
const recentJobs = ref<IngestionJob[]>(mockRecentJobsList)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const state = ref<'LOADING' | 'READY'>('READY')
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@@ -1,60 +1,3 @@
|
||||
<template>
|
||||
<EditFormPage
|
||||
title="시장 데이터 수집 (Market Data Ingestion Form)"
|
||||
subtitle="KRX 과거 시세 및 OpenDart 공시 데이터 수집 작업을 등록합니다."
|
||||
:state="screenState"
|
||||
:evidence="evidence"
|
||||
@retry="handleRetry"
|
||||
>
|
||||
<!-- Default Slot: Form Section -->
|
||||
<div class="content">
|
||||
<KsFormSection title="1. 데이터 소스 및 기간 선택">
|
||||
<KsFormGrid :columns="1" aria-label="데이터 소스 및 기간">
|
||||
<KsSelect v-model="form.dataSource" label="데이터 소스" :options="dataSourceOptions" />
|
||||
<p class="hint"><strong>KRX:</strong> 시세 데이터(시가/고가/저가/종가/거래량) · <strong>OpenDart:</strong> 기업 공시</p>
|
||||
</KsFormGrid>
|
||||
<KsFormGrid :columns="2" aria-label="수집 기간">
|
||||
<KsDateField v-model="form.fromDate" label="시작일" :min="minDateValue" :max="maxDateValue" />
|
||||
<KsDateField v-model="form.toDate" label="종료일" :min="form.fromDate ? new Date(form.fromDate) : minDateValue" :max="maxDateValue" />
|
||||
</KsFormGrid>
|
||||
<div class="presets">
|
||||
<KsButton severity="secondary" label="최근 1년" @click="setPreset('1y')" />
|
||||
<KsButton severity="secondary" label="최근 2년" @click="setPreset('2y')" />
|
||||
<KsButton severity="secondary" label="최근 5년" @click="setPreset('5y')" />
|
||||
<KsButton severity="secondary" label="전체 가능 기간" @click="setPreset('all')" />
|
||||
</div>
|
||||
</KsFormSection>
|
||||
|
||||
<KsFormSection v-if="validationErrors.length" title="입력 오류">
|
||||
<KsValidationSummary :errors="validationErrorObjects" />
|
||||
</KsFormSection>
|
||||
|
||||
<KsFormSection v-else title="수집 요약">
|
||||
<dl class="summary-grid">
|
||||
<div class="summary-item"><dt>데이터 소스</dt><dd>{{ form.dataSource }}</dd></div>
|
||||
<div class="summary-item"><dt>기간</dt><dd class="ks-financial-number">{{ form.fromDate }} ~ {{ form.toDate }}</dd></div>
|
||||
<div class="summary-item"><dt>일수</dt><dd class="ks-financial-number">{{ formatQuantity(daysCount, 0) }}일</dd></div>
|
||||
<div class="summary-item"><dt>예상 행 수</dt><dd class="ks-financial-number">{{ estimatedRows }}</dd></div>
|
||||
</dl>
|
||||
<template #actions>
|
||||
<KsButton severity="secondary" label="초기화" @click="resetForm" />
|
||||
<KsButton severity="primary" :label="isLoading ? '처리 중...' : '수집 예약'" :loading="isLoading" :disabled="validationErrors.length > 0" @click="triggerIngestion" />
|
||||
</template>
|
||||
</KsFormSection>
|
||||
|
||||
<KsFormSection v-if="jobId" title="수집 작업이 등록되었습니다">
|
||||
<dl class="summary-grid">
|
||||
<div class="summary-item"><dt>Job ID</dt><dd class="mono ks-financial-number">{{ jobId }}</dd></div>
|
||||
<div class="summary-item"><dt>상태</dt><dd><KsStatusTag value="대기" severity="info" /></dd></div>
|
||||
<div class="summary-item"><dt>등록 시각</dt><dd class="ks-financial-number">{{ formatAsOf(new Date()) }}</dd></div>
|
||||
</dl>
|
||||
<p class="hint">수집은 백그라운드에서 실행됩니다. 진행 상태는 수집 이력 화면에서 확인할 수 있습니다.</p>
|
||||
<RouterLink to="/ops/market-data-history">수집 이력 보기 →</RouterLink>
|
||||
</KsFormSection>
|
||||
</div>
|
||||
</EditFormPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
@@ -66,7 +9,7 @@ import type { StandardScreenState } from '../../../shared/ui/contracts/screenCon
|
||||
// KBX T03 Governance Audit & Form State
|
||||
const screenState = ref<StandardScreenState>('READY')
|
||||
const evidence = reactive({
|
||||
asOf: '2026-08-15T19:42:00Z',
|
||||
asOf: new Date().toISOString(),
|
||||
version: 'v60-T03-Contract',
|
||||
})
|
||||
|
||||
@@ -84,8 +27,8 @@ const form = ref({
|
||||
toDate: new Date().toISOString().split('T')[0],
|
||||
})
|
||||
|
||||
const minDate = '2015-01-01' // KRX historical data starts here
|
||||
const maxDate = new Date().toISOString().split('T')[0] // Today
|
||||
const minDate = '2015-01-01'
|
||||
const maxDate = new Date().toISOString().split('T')[0]
|
||||
const minDateValue = new Date(minDate)
|
||||
const maxDateValue = new Date(maxDate)
|
||||
|
||||
@@ -117,8 +60,6 @@ const daysCount = computed(() => {
|
||||
})
|
||||
|
||||
const estimatedRows = computed(() => {
|
||||
// KRX: ~2000 stocks × days
|
||||
// OpenDart: ~200 quarterly filings
|
||||
if (form.value.dataSource === 'KRX') {
|
||||
return formatQuantity(daysCount.value * 2000, 0)
|
||||
} else if (form.value.dataSource === 'OpenDart') {
|
||||
@@ -166,7 +107,6 @@ const triggerIngestion = async () => {
|
||||
const data = await response.json()
|
||||
jobId.value = data.jobId
|
||||
|
||||
// Reset form after success
|
||||
setTimeout(() => {
|
||||
form.value.fromDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
form.value.toDate = new Date().toISOString().split('T')[0]
|
||||
@@ -195,12 +135,147 @@ const handleRetry = () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditFormPage
|
||||
title="시장 데이터 수집"
|
||||
subtitle="KRX 과거 시세 및 OpenDart 공시 데이터 수집 작업을 예약 등록합니다."
|
||||
:state="screenState"
|
||||
:evidence="evidence"
|
||||
@submit="triggerIngestion"
|
||||
@retry="handleRetry"
|
||||
>
|
||||
<!-- Top Action Bar Slot -->
|
||||
<template #actions>
|
||||
<KsButton variant="secondary" label="🔄 초기화" @click="resetForm" />
|
||||
<KsButton
|
||||
variant="primary"
|
||||
:label="isLoading ? '처리 중...' : '📤 수집 예약 실행'"
|
||||
:loading="isLoading"
|
||||
:disabled="validationErrors.length > 0"
|
||||
@click="triggerIngestion"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Form Section (Left Pane) -->
|
||||
<div class="ks-form-pane">
|
||||
<KsFormSection title="1. 데이터 소스선택">
|
||||
<KsFormGrid :columns="1" aria-label="데이터 소스 선택">
|
||||
<KsSelect v-model="form.dataSource" label="데이터 소스" :options="dataSourceOptions" class="ks-set-md" />
|
||||
<p class="hint"><strong>KRX:</strong> KOSPI/KOSDAQ 시세 · <strong>OpenDart:</strong> 기업 정기 공시</p>
|
||||
</KsFormGrid>
|
||||
</KsFormSection>
|
||||
|
||||
<KsFormSection title="2. 수집 기간 선택">
|
||||
<KsFormGrid :columns="2" aria-label="수집 기간">
|
||||
<KsDateField v-model="form.fromDate" label="시작일" :min="minDateValue" :max="maxDateValue" />
|
||||
<KsDateField v-model="form.toDate" label="종료일" :min="form.fromDate ? new Date(form.fromDate) : minDateValue" :max="maxDateValue" />
|
||||
</KsFormGrid>
|
||||
<div class="presets">
|
||||
<KsButton variant="secondary" label="최근 1년" @click="setPreset('1y')" />
|
||||
<KsButton variant="secondary" label="최근 2년" @click="setPreset('2y')" />
|
||||
<KsButton variant="secondary" label="최근 5년" @click="setPreset('5y')" />
|
||||
<KsButton variant="secondary" label="전체 가능 기간" @click="setPreset('all')" />
|
||||
</div>
|
||||
</KsFormSection>
|
||||
|
||||
<KsFormSection v-if="validationErrors.length" title="입력 검증 오류">
|
||||
<KsValidationSummary :errors="validationErrorObjects" />
|
||||
</KsFormSection>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section (Right Pane) -->
|
||||
<template #preview>
|
||||
<div class="ks-preview-pane">
|
||||
<h3>수집 예약 요약</h3>
|
||||
<dl class="summary-grid">
|
||||
<div class="summary-item"><dt>데이터 소스</dt><dd><code>{{ form.dataSource }}</code></dd></div>
|
||||
<div class="summary-item"><dt>수집 기간</dt><dd class="ks-financial-number">{{ form.fromDate }} ~ {{ form.toDate }}</dd></div>
|
||||
<div class="summary-item"><dt>총 수집 일수</dt><dd class="ks-financial-number">{{ formatQuantity(daysCount, 0) }} 일</dd></div>
|
||||
<div class="summary-item"><dt>예상 수집 행 수</dt><dd class="ks-financial-number">{{ estimatedRows }} 행</dd></div>
|
||||
</dl>
|
||||
|
||||
<div v-if="jobId" class="job-status-card">
|
||||
<div class="status-header">
|
||||
<h4>수집 작업 예약 등록 완료</h4>
|
||||
<KsStatusTag value="대기 QUEUED" severity="info" />
|
||||
</div>
|
||||
<dl class="job-dl">
|
||||
<div class="row"><dt>Job ID</dt><dd><code>{{ jobId }}</code></dd></div>
|
||||
<div class="row"><dt>등록 시각</dt><dd class="ks-financial-number">{{ formatAsOf(new Date()) }}</dd></div>
|
||||
</dl>
|
||||
<p class="hint">수집 작업이 백그라운드에 등록되었습니다. 진행 이력은 수집 이력 페이지에서 확인 가능합니다.</p>
|
||||
<RouterLink to="/ops/market-data-history" class="history-link">📋 수집 이력 보기 →</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EditFormPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); margin: 0; }
|
||||
.presets { display: flex; gap: var(--ks-space-2); flex-wrap: wrap; margin-top: var(--ks-space-3); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ks-space-3); margin: 0; }
|
||||
.summary-item { display: flex; justify-content: space-between; padding: var(--ks-space-2) var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); }
|
||||
.ks-form-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-4);
|
||||
}
|
||||
|
||||
.ks-preview-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ks-space-3);
|
||||
}
|
||||
|
||||
.ks-preview-pane h3 {
|
||||
margin: 0;
|
||||
font-size: var(--ks-font-section);
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--ks-color-border-strong);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.hint { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); margin: 4px 0 0 0; }
|
||||
.presets { display: flex; gap: var(--ks-space-2); flex-wrap: wrap; margin-top: var(--ks-space-2); }
|
||||
.summary-grid { display: flex; flex-direction: column; gap: 6px; margin: 0; }
|
||||
.summary-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); border: 1px solid var(--ks-color-border); font-size: var(--ks-font-body); }
|
||||
.summary-item dt { font-weight: 600; color: var(--ks-color-text-muted); }
|
||||
.summary-item dd { margin: 0; font-weight: 600; }
|
||||
.mono { font-family: monospace; }
|
||||
.summary-item dd { margin: 0; font-weight: 700; }
|
||||
.summary-item code { font-family: var(--ks-font-mono, monospace); background: var(--ks-color-surface); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--ks-color-border); }
|
||||
|
||||
.job-status-card {
|
||||
margin-top: var(--ks-space-3);
|
||||
padding: var(--ks-space-3);
|
||||
background: var(--ks-color-surface);
|
||||
border: 1px solid var(--ks-color-info);
|
||||
border-radius: var(--ks-radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-header h4 {
|
||||
margin: 0;
|
||||
font-size: var(--ks-font-body);
|
||||
color: var(--ks-color-info);
|
||||
}
|
||||
|
||||
.job-dl { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
||||
.job-dl .row { display: flex; justify-content: space-between; align-items: center; font-size: var(--ks-font-caption); }
|
||||
.job-dl dt { font-weight: 600; color: var(--ks-color-text-muted); }
|
||||
.job-dl dd { margin: 0; font-weight: 700; }
|
||||
.job-dl code { font-family: var(--ks-font-mono, monospace); background: var(--ks-color-canvas); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--ks-color-border); }
|
||||
|
||||
.history-link {
|
||||
font-size: var(--ks-font-body);
|
||||
font-weight: 600;
|
||||
color: var(--ks-color-action);
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.history-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user