Files
KArtSell.Aegis/frontend/src/features/marketData/pages/MarketDataIngestion.vue
T
kjh2064 1be7029f8f
deploy / deploy (push) Failing after 51s
deploy / notify (push) Successful in 1s
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>
2026-08-16 15:00:03 +09:00

282 lines
10 KiB
Vue

<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { RouterLink } from 'vue-router'
import EditFormPage from '../../../shared/ui/screen-types/v2/EditFormPage.vue'
import { KsButton, KsDateField, KsFormGrid, KsFormSection, KsSelect, KsStatusTag, KsValidationSummary } from '../../../shared/ui/components'
import { formatAsOf, formatQuantity } from '../../../shared/formatters/financial'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// KBX T03 Governance Audit & Form State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: new Date().toISOString(),
version: 'v60-T03-Contract',
})
const isLoading = ref(false)
const jobId = ref<string | null>(null)
const dataSourceOptions = [
{ label: 'KRX (한국거래소) - KOSPI/KOSDAQ 일봉', value: 'KRX' },
{ label: 'OpenDart (금융감독원) - 기업 공시', value: 'OpenDart' },
]
const form = ref({
dataSource: 'KRX',
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0],
})
const minDate = '2015-01-01'
const maxDate = new Date().toISOString().split('T')[0]
const minDateValue = new Date(minDate)
const maxDateValue = new Date(maxDate)
const validationErrors = computed(() => {
const errors: string[] = []
if (!form.value.fromDate) errors.push('시작일은 필수입니다.')
if (!form.value.toDate) errors.push('종료일은 필수입니다.')
if (form.value.fromDate && form.value.toDate) {
if (form.value.fromDate > form.value.toDate) {
errors.push('시작일은 종료일보다 앞서야 합니다.')
}
if (form.value.toDate > maxDate) {
errors.push('종료일은 오늘 이후일 수 없습니다.')
}
}
return errors
})
const validationErrorObjects = computed(() => validationErrors.value.map(message => ({ message })))
const daysCount = computed(() => {
if (!form.value.fromDate || !form.value.toDate) return 0
const from = new Date(form.value.fromDate)
const to = new Date(form.value.toDate)
return Math.ceil((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24))
})
const estimatedRows = computed(() => {
if (form.value.dataSource === 'KRX') {
return formatQuantity(daysCount.value * 2000, 0)
} else if (form.value.dataSource === 'OpenDart') {
return formatQuantity(Math.ceil(daysCount.value / 90) * 200, 0)
}
return '0'
})
const setPreset = (preset: string) => {
const today = new Date()
const from = new Date()
if (preset === '1y') from.setFullYear(from.getFullYear() - 1)
else if (preset === '2y') from.setFullYear(from.getFullYear() - 2)
else if (preset === '5y') from.setFullYear(from.getFullYear() - 5)
else if (preset === 'all') from.setFullYear(2015)
form.value.fromDate = from.toISOString().split('T')[0]
form.value.toDate = today.toISOString().split('T')[0]
}
const triggerIngestion = async () => {
if (validationErrors.value.length > 0) return
isLoading.value = true
try {
const response = await fetch('/api/market/ingest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-KArtSell-User': 'ingestion-user',
'X-KArtSell-Role': 'DataAdmin',
},
body: JSON.stringify({
dataSource: form.value.dataSource,
fromDate: form.value.fromDate,
toDate: form.value.toDate,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
jobId.value = data.jobId
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]
jobId.value = null
}, 5000)
} catch (error) {
console.error('Ingestion error:', error)
alert(`수집 요청에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
} finally {
isLoading.value = false
}
}
const resetForm = () => {
form.value = {
dataSource: 'KRX',
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
toDate: new Date().toISOString().split('T')[0],
}
jobId.value = null
}
const handleRetry = () => {
screenState.value = 'READY'
}
</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>
.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: 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>