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,131 +1,67 @@
<template>
<div class="market-data-ingestion">
<div class="header">
<h1>📊 Market Data Ingestion</h1>
<p class="subtitle">Schedule KRX historical data collection</p>
</div>
<div class="content">
<!-- Configuration Card -->
<div class="config-card">
<h2>1. Select Data Source & Period</h2>
<div class="form-group">
<label>Data Source</label>
<select v-model="form.dataSource">
<option value="KRX">KRX (Korea Exchange) - KOSPI/KOSDAQ Daily</option>
<option value="OpenDart">OpenDart - Financial Disclosures (T+2)</option>
<option value="Stub">Stub (Test Data)</option>
</select>
<p class="hint">
<strong>KRX:</strong> Stock prices (Open/High/Low/Close/Volume)
<strong>OpenDart:</strong> Corporate disclosures & filings
</p>
</div>
<div class="form-row">
<div class="form-group">
<label>From Date</label>
<input
v-model="form.fromDate"
type="date"
:min="minDate"
:max="maxDate"
placeholder="YYYY-MM-DD"
/>
<p class="hint">Earliest: {{ minDate }}</p>
</div>
<div class="form-group">
<label>To Date</label>
<input
v-model="form.toDate"
type="date"
:min="form.fromDate || minDate"
:max="maxDate"
placeholder="YYYY-MM-DD"
/>
<p class="hint">Latest: {{ maxDate }}</p>
</div>
</div>
<!-- Quick presets -->
<div class="presets">
<button @click="setPreset('1y')" class="preset-btn">Last 1 Year</button>
<button @click="setPreset('2y')" class="preset-btn">Last 2 Years</button>
<button @click="setPreset('5y')" class="preset-btn">Last 5 Years</button>
<button @click="setPreset('all')" class="preset-btn">All Available</button>
</div>
<PageLayout title="시장 데이터 수집" subtitle="KRX 과거 시세 데이터 수집을 예약합니다.">
<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>
<!-- Validation & Summary -->
<div v-if="validationErrors.length" class="error-card">
<h3> Validation Errors</h3>
<ul>
<li v-for="(err, idx) in validationErrors" :key="idx">{{ err }}</li>
</ul>
</div>
<KsFormSection v-if="validationErrors.length" title="입력 오류">
<KsValidationSummary :errors="validationErrorObjects" />
</KsFormSection>
<div v-if="!validationErrors.length" class="summary-card">
<h3>📋 Collection Summary</h3>
<div class="summary-grid">
<div class="summary-item">
<span class="label">Data Source:</span>
<span class="value">{{ form.dataSource }}</span>
</div>
<div class="summary-item">
<span class="label">Period:</span>
<span class="value">{{ form.fromDate }} to {{ form.toDate }}</span>
</div>
<div class="summary-item">
<span class="label">Days:</span>
<span class="value">{{ daysCount }}</span>
</div>
<div class="summary-item">
<span class="label">Est. Rows:</span>
<span class="value">{{ estimatedRows }}</span>
</div>
</div>
</div>
<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>{{ form.fromDate }} ~ {{ form.toDate }}</dd></div>
<div class="summary-item"><dt>일수</dt><dd>{{ formatQuantity(daysCount, 0) }}</dd></div>
<div class="summary-item"><dt>예상 </dt><dd>{{ 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>
<!-- Action Buttons -->
<div class="actions">
<button
@click="triggerIngestion"
:disabled="isLoading || validationErrors.length > 0"
class="btn-primary"
>
<span v-if="!isLoading">🚀 Schedule Ingestion</span>
<span v-else> Processing...</span>
</button>
<button @click="resetForm" class="btn-secondary"> Reset</button>
</div>
<!-- Success Message -->
<div v-if="jobId" class="success-card">
<h3> Job Scheduled Successfully</h3>
<div class="job-info">
<p><strong>Job ID:</strong> {{ jobId }}</p>
<p><strong>Status:</strong> Queued</p>
<p><strong>Queued At:</strong> {{ new Date().toLocaleString() }}</p>
<p class="hint">The ingestion will run in the background. Check the status in History tab.</p>
</div>
<router-link to="/ops/market-data-history" class="btn-link">
📈 View Collection History
</router-link>
</div>
</div>
</div>
<KsFormSection v-if="jobId" title="수집 작업이 등록되었습니다">
<dl class="summary-grid">
<div class="summary-item"><dt>Job ID</dt><dd class="mono">{{ jobId }}</dd></div>
<div class="summary-item"><dt>상태</dt><dd><KsStatusTag value="대기" severity="info" /></dd></div>
<div class="summary-item"><dt>등록 시각</dt><dd>{{ formatAsOf(new Date()) }}</dd></div>
</dl>
<p class="hint">수집은 백그라운드에서 실행됩니다. 진행 상태는 수집 이력 화면에서 확인할 있습니다.</p>
<RouterLink to="/ops/market-data-history">수집 이력 보기 </RouterLink>
</KsFormSection>
</PageLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
import { KsButton, KsDateField, KsFormGrid, KsFormSection, KsSelect, KsStatusTag, KsValidationSummary } from '../../../shared/ui/components'
import { formatAsOf, formatQuantity } from '../../../shared/formatters/financial'
const router = useRouter()
const isLoading = ref(false)
const jobId = ref<string | null>(null)
const dataSourceOptions = [
{ label: 'KRX (한국거래소) - KOSPI/KOSDAQ 일봉', value: 'KRX' },
{ label: 'OpenDart - 기업 공시(T+2)', value: 'OpenDart' },
{ label: 'Stub - 테스트 데이터', value: 'Stub' }
]
const form = ref({
dataSource: 'KRX',
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
@@ -134,25 +70,29 @@ const form = ref({
const minDate = '2015-01-01' // KRX historical data starts here
const maxDate = new Date().toISOString().split('T')[0] // Today
const minDateValue = new Date(minDate)
const maxDateValue = new Date(maxDate)
const validationErrors = computed(() => {
const errors: string[] = []
if (!form.value.fromDate) errors.push('From Date is required')
if (!form.value.toDate) errors.push('To Date is required')
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('From Date must be before To Date')
errors.push('시작일은 종료일보다 앞서야 합니다.')
}
if (form.value.toDate > maxDate) {
errors.push('To Date cannot be in the future')
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)
@@ -164,9 +104,9 @@ const estimatedRows = computed(() => {
// KRX: ~2000 stocks × days
// OpenDart: ~200 quarterly filings
if (form.value.dataSource === 'KRX') {
return (daysCount.value * 2000).toLocaleString()
return formatQuantity(daysCount.value * 2000, 0)
} else if (form.value.dataSource === 'OpenDart') {
return (Math.ceil(daysCount.value / 90) * 200).toLocaleString()
return formatQuantity(Math.ceil(daysCount.value / 90) * 200, 0)
}
return '0'
})
@@ -219,7 +159,7 @@ const triggerIngestion = async () => {
} catch (error) {
console.error('Ingestion error:', error)
alert(`Failed to trigger ingestion: ${error instanceof Error ? error.message : 'Unknown error'}`)
alert(`수집 요청에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
} finally {
isLoading.value = false
}
@@ -236,226 +176,11 @@ const resetForm = () => {
</script>
<style scoped>
.market-data-ingestion {
padding: 2rem;
max-width: 1000px;
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: 1.5rem;
}
.config-card,
.summary-card,
.error-card,
.success-card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
background: var(--surface-elevated);
}
.config-card h2,
.summary-card h3,
.error-card h3,
.success-card h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.form-group select,
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
background: var(--surface);
color: var(--text-primary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
margin-bottom: 0;
}
.presets {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--surface);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.preset-btn:hover {
background: var(--surface-secondary);
border-color: #3b82f6;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.summary-item {
display: flex;
justify-content: space-between;
padding: 0.75rem;
background: var(--surface);
border-radius: 4px;
}
.summary-item .label {
font-weight: 600;
color: var(--text-secondary);
}
.summary-item .value {
font-weight: 600;
color: #3b82f6;
}
.error-card {
border-color: #ef4444;
background-color: #fef2f2;
}
.error-card h3 {
color: #991b1b;
}
.error-card ul {
margin: 0;
padding-left: 1.5rem;
color: #7f1d1d;
}
.error-card li {
margin-bottom: 0.5rem;
}
.success-card {
border-color: #10b981;
background-color: #f0fdf4;
}
.success-card h3 {
color: #065f46;
}
.job-info {
background: var(--surface);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.job-info p {
margin: 0.5rem 0;
color: #065f46;
font-size: 0.9rem;
}
.job-info strong {
color: #047857;
}
.actions {
display: flex;
gap: 1rem;
}
.btn-primary,
.btn-secondary,
.btn-link {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.btn-secondary {
background: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.btn-link {
background: transparent;
color: #3b82f6;
text-decoration: none;
padding: 0;
border: none;
}
.btn-link:hover {
text-decoration: underline;
}
.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); }
.summary-item dt { font-weight: 600; color: var(--ks-color-text-muted); }
.summary-item dd { margin: 0; font-weight: 600; }
.mono { font-family: monospace; }
</style>