V13-FE-011: finalize search list layout slice
This commit is contained in:
+4
-11
@@ -1,16 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { AppShellLayout } from './shared/ui/layouts'
|
||||
import { RouterView } from 'vue-router'
|
||||
import KsAppShell from './shared/shell/KsAppShell.vue'
|
||||
</script>
|
||||
<template>
|
||||
<AppShellLayout>
|
||||
<template #navigation><nav class="app-nav" aria-label="주요 메뉴">
|
||||
<section><h2>Research</h2><RouterLink to="/research/sell-decision">매도 의사결정</RouterLink></section>
|
||||
<section><h2>Portfolio</h2><RouterLink to="/portfolio/risk">포트폴리오 리스크</RouterLink><RouterLink to="/portfolio/rebalance">리밸런싱 제안</RouterLink></section>
|
||||
<section><h2>Operations</h2><RouterLink to="/ops/data-quality">데이터 품질</RouterLink><RouterLink to="/ops/market-data-ingestion">시장 데이터 수집</RouterLink><RouterLink to="/ops/market-data-history">수집 이력</RouterLink><RouterLink to="/ops/model-operations">모델 운영</RouterLink></section>
|
||||
<section><h2>Internal</h2><RouterLink to="/internal/wbs">WBS 작업공간</RouterLink><RouterLink to="/internal/ui-standard">컴포넌트 카탈로그</RouterLink></section>
|
||||
</nav></template>
|
||||
<KsAppShell>
|
||||
<RouterView />
|
||||
</AppShellLayout>
|
||||
</KsAppShell>
|
||||
</template>
|
||||
<style scoped>.app-nav{display:grid;gap:var(--ks-space-2)}.app-nav a{padding:var(--ks-space-2) var(--ks-space-3);border-radius:var(--ks-radius-sm);text-decoration:none}.app-nav a.router-link-active{background:var(--ks-color-neutral-100);font-weight:700}</style>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/research/sell-decision' },
|
||||
{ path: '/research/sell-decision', component: () => import('../features/sell-decision/pages/SellDecisionPage.vue'), meta: { screenId: 'SCR-002', templateId: 'T02' } },
|
||||
{ path: '/ops/data-quality', component: () => import('../features/data-quality/pages/DataQualityPage.vue'), meta: { screenId: 'SCR-013', templateId: 'T08' } },
|
||||
{ path: '/ops/model-operations', component: () => import('../features/model-operations/pages/ModelOperationsPage.vue'), meta: { screenId: 'SCR-015', templateId: 'T10' } },
|
||||
{ path: '/ops/market-data-ingestion', component: () => import('../features/marketData/pages/MarketDataIngestion.vue'), meta: { screenId: 'SCR-016', templateId: 'T08' } },
|
||||
{ path: '/ops/market-data-history', component: () => import('../features/marketData/pages/IngestionStatus.vue'), meta: { screenId: 'SCR-017', templateId: 'T08' } },
|
||||
{ path: '/portfolio/risk', component: () => import('../features/portfolio/pages/RiskDashboard.vue'), meta: { screenId: 'SCR-018', templateId: 'T07' } },
|
||||
{ path: '/portfolio/rebalance', component: () => import('../features/portfolio/pages/RebalanceForm.vue'), meta: { screenId: 'SCR-019', templateId: 'T03' } },
|
||||
{ path: '/internal/ui-standard', component: () => import('../features/ui-standard/pages/UiStandardPage.vue'), meta: { screenId: 'SCR-DEV-001', templateId: 'T01', internalOnly: true } },
|
||||
{ path: '/internal/wbs', component: () => import('../features/wbs/pages/WbsWorkspacePage.vue'), meta: { screenId: 'SCR-DEV-002', templateId: 'T01', internalOnly: true } }
|
||||
]
|
||||
});
|
||||
+11
-10
@@ -3,15 +3,16 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/research/sell-decision' },
|
||||
{ path: '/research/sell-decision', component: () => import('../features/sell-decision/pages/SellDecisionPage.vue'), meta: { screenId: 'SCR-002', templateId: 'T02' } },
|
||||
{ path: '/ops/data-quality', component: () => import('../features/data-quality/pages/DataQualityPage.vue'), meta: { screenId: 'SCR-013', templateId: 'T08' } },
|
||||
{ path: '/ops/model-operations', component: () => import('../features/model-operations/pages/ModelOperationsPage.vue'), meta: { screenId: 'SCR-015', templateId: 'T10' } },
|
||||
{ path: '/ops/market-data-ingestion', component: () => import('../features/marketData/pages/MarketDataIngestion.vue'), meta: { screenId: 'SCR-016', templateId: 'T08' } },
|
||||
{ path: '/ops/market-data-history', component: () => import('../features/marketData/pages/IngestionStatus.vue'), meta: { screenId: 'SCR-017', templateId: 'T08' } },
|
||||
{ path: '/portfolio/risk', component: () => import('../features/portfolio/pages/RiskDashboard.vue'), meta: { screenId: 'SCR-018', templateId: 'T07' } },
|
||||
{ path: '/portfolio/rebalance', component: () => import('../features/portfolio/pages/RebalanceForm.vue'), meta: { screenId: 'SCR-019', templateId: 'T03' } },
|
||||
{ path: '/internal/ui-standard', component: () => import('../features/ui-standard/pages/UiStandardPage.vue'), meta: { screenId: 'SCR-DEV-001', templateId: 'T01', internalOnly: true } },
|
||||
{ path: '/internal/wbs', component: () => import('../features/wbs/pages/WbsWorkspacePage.vue'), meta: { screenId: 'SCR-DEV-002', templateId: 'T01', internalOnly: true } }
|
||||
{ path: '/', redirect: '/home' },
|
||||
{ path: '/home', component: () => import('../features/home/pages/HomePage.vue'), meta: { screenId: 'SCR-000', templateId: 'T00', module: 'Home', section: 'Home', title: '홈', order: 0, favoriteAllowed: false } },
|
||||
{ path: '/research/sell-decision', component: () => import('../features/sell-decision/pages/SellDecisionPage.vue'), meta: { screenId: 'SCR-002', templateId: 'T03', module: 'Research', section: 'Research', title: '매도 의사결정', order: 1, favoriteAllowed: true } },
|
||||
{ path: '/ops/data-quality', component: () => import('../features/data-quality/pages/DataQualityPage.vue'), meta: { screenId: 'SCR-013', templateId: 'T08', module: 'Operations', section: 'Operations', title: '데이터 품질', order: 1, favoriteAllowed: true } },
|
||||
{ path: '/ops/model-operations', component: () => import('../features/model-operations/pages/ModelOperationsPage.vue'), meta: { screenId: 'SCR-015', templateId: 'T10', module: 'Operations', section: 'Operations', title: '모델 운영', order: 4, favoriteAllowed: true } },
|
||||
{ path: '/ops/market-data-ingestion', component: () => import('../features/marketData/pages/MarketDataIngestion.vue'), meta: { screenId: 'SCR-016', templateId: 'T08', module: 'Operations', section: 'Operations', title: '시장 데이터 수집', order: 2, favoriteAllowed: true } },
|
||||
{ path: '/ops/market-data-history', component: () => import('../features/marketData/pages/IngestionStatus.vue'), meta: { screenId: 'SCR-017', templateId: 'T08', module: 'Operations', section: 'Operations', title: '수집 이력', order: 3, favoriteAllowed: true } },
|
||||
{ path: '/portfolio/risk', component: () => import('../features/portfolio/pages/RiskDashboard.vue'), meta: { screenId: 'SCR-018', templateId: 'T07', module: 'Portfolio', section: 'Portfolio', title: '포트폴리오 리스크', order: 1, favoriteAllowed: true } },
|
||||
{ path: '/portfolio/rebalance', component: () => import('../features/portfolio/pages/RebalanceForm.vue'), meta: { screenId: 'SCR-019', templateId: 'T03', module: 'Portfolio', section: 'Portfolio', title: '리밸런싱 제안', order: 2, favoriteAllowed: true } },
|
||||
{ path: '/internal/ui-standard', component: () => import('../features/ui-standard/pages/UiStandardPage.vue'), meta: { screenId: 'SCR-DEV-001', templateId: 'T01', module: 'Design System', section: 'Design System', title: '컴포넌트 확인', order: 1, favoriteAllowed: false, internalOnly: false } },
|
||||
{ path: '/internal/wbs', component: () => import('../features/wbs/pages/WbsWorkspacePage.vue'), meta: { screenId: 'SCR-DEV-002', templateId: 'T01', module: 'Internal', section: 'Internal', title: 'WBS 작업공간', order: 1, favoriteAllowed: false, internalOnly: true } }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -5,18 +5,18 @@ html { color-scheme: light; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ks-color-canvas);
|
||||
color: var(--ks-color-neutral-950);
|
||||
color: var(--ks-color-text);
|
||||
font-family: Inter, Pretendard, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: var(--ks-font-body);
|
||||
line-height: var(--ks-line-body);
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
a { color: var(--ks-color-action); }
|
||||
:focus-visible { outline: 3px solid color-mix(in srgb, var(--ks-color-focus) 55%, transparent); outline-offset: 2px; }
|
||||
:focus-visible { outline: var(--ks-focus-ring-width) solid color-mix(in srgb, var(--ks-color-focus) 55%, transparent); outline-offset: var(--ks-focus-ring-offset); }
|
||||
.ks-financial-number { font-variant-numeric: tabular-nums; }
|
||||
.ks-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.ks-card { background: var(--ks-color-surface); border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-md); box-shadow: var(--ks-shadow-sm); }
|
||||
.ks-card { background: var(--ks-color-surface-raised); border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-md); box-shadow: var(--ks-shadow-sm); }
|
||||
.ks-stack { display: grid; gap: var(--ks-space-4); }
|
||||
.ks-inline { display: flex; align-items: center; gap: var(--ks-space-2); flex-wrap: wrap; }
|
||||
.ks-muted { color: var(--ks-color-neutral-600); }
|
||||
.ks-muted { color: var(--ks-color-text-muted); }
|
||||
.ks-danger-text { color: var(--ks-color-danger); }
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
--ks-color-surface: #ffffff;
|
||||
--ks-color-canvas: #f7f8fa;
|
||||
--ks-color-focus: #2563eb;
|
||||
--ks-color-text: var(--ks-color-neutral-950);
|
||||
--ks-color-text-muted: var(--ks-color-neutral-600);
|
||||
--ks-color-text-on-dark: var(--ks-color-surface);
|
||||
--ks-color-surface-raised: var(--ks-color-surface);
|
||||
--ks-color-border: var(--ks-color-neutral-200);
|
||||
--ks-color-border-strong: var(--ks-color-neutral-300);
|
||||
--ks-color-warning-border: var(--ks-color-warning);
|
||||
--ks-space-1: 0.25rem;
|
||||
--ks-space-2: 0.5rem;
|
||||
--ks-space-3: 0.75rem;
|
||||
@@ -27,6 +34,7 @@
|
||||
--ks-radius-lg: 0.75rem;
|
||||
--ks-shadow-sm: 0 1px 2px rgb(0 0 0 / 8%);
|
||||
--ks-shadow-md: 0 8px 24px rgb(0 0 0 / 10%);
|
||||
--ks-shadow-lg: 0 16px 48px rgb(0 0 0 / 16%);
|
||||
--ks-font-page: 1.5rem;
|
||||
--ks-line-page: 2rem;
|
||||
--ks-font-section: 1.125rem;
|
||||
@@ -38,6 +46,15 @@
|
||||
--ks-control-height: 2.75rem;
|
||||
--ks-grid-density: 2.25rem;
|
||||
--ks-content-max: 100rem;
|
||||
--ks-focus-ring-width: 3px;
|
||||
--ks-focus-ring-offset: 2px;
|
||||
--ks-shell-sidebar-width: 16rem;
|
||||
--ks-shell-header-height: 3.5rem;
|
||||
--ks-shell-sidenav-width: 13.75rem;
|
||||
--ks-shell-sidenav-collapsed: 3.5rem;
|
||||
--ks-shell-tabs-height: 2.5rem;
|
||||
--ks-shell-pageheader-height: 3rem;
|
||||
--ks-shell-commandbar-height: 2.75rem;
|
||||
}
|
||||
|
||||
[data-density='compact'] {
|
||||
|
||||
@@ -2,32 +2,29 @@
|
||||
import { computed } from 'vue'
|
||||
import type { UiGridColumn } from '../../../shared/ui/adapter/contracts'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
|
||||
import type { DataQualityRun } from '../schema'
|
||||
|
||||
// Template fixture only. Production data must come from DAT-03 and pass Zod validation.
|
||||
const rows: DataQualityRun[] = []
|
||||
const columns = computed<UiGridColumn[]>(() => [
|
||||
{ field: 'source', header: 'Source' },
|
||||
{ field: 'session', header: 'Session' },
|
||||
{ field: 'status', header: 'DQ' },
|
||||
{ field: 'rowCount', header: 'Rows' },
|
||||
{ field: 'failedRows', header: 'Failed' },
|
||||
{ field: 'sourceWatermark', header: 'Watermark' },
|
||||
{ field: 'datasetId', header: 'Dataset' },
|
||||
{ field: 'completedAt', header: 'Completed' }
|
||||
{ field: 'source', header: '소스' },
|
||||
{ field: 'session', header: '세션' },
|
||||
{ field: 'status', header: '품질 상태' },
|
||||
{ field: 'rowCount', header: '전체 행' },
|
||||
{ field: 'failedRows', header: '실패 행' },
|
||||
{ field: 'sourceWatermark', header: '워터마크' },
|
||||
{ field: 'datasetId', header: '데이터셋' },
|
||||
{ field: 'completedAt', header: '완료 시각' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header>
|
||||
<h1>데이터 품질 운영</h1>
|
||||
<p>Raw→PIT→DQ→Dataset lineage를 확인합니다. QUARANTINED 데이터는 추천에 사용할 수 없습니다.</p>
|
||||
</header>
|
||||
<PageLayout title="데이터 품질 운영" subtitle="Raw→PIT→DQ→Dataset lineage를 확인합니다. QUARANTINED 데이터는 추천에 사용할 수 없습니다.">
|
||||
<DataGridShell
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
empty-message="DAT-03 계약이 구현되면 서버 검증 결과가 표시됩니다."
|
||||
/>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { buildNavigationEntries, groupByModule, type NavigationEntry } from '../../../shared/shell/navigationCatalog'
|
||||
import { useScreenPreferenceStore } from '../../../shared/shell/screenPreferenceStore'
|
||||
|
||||
const router = useRouter()
|
||||
const entries = computed(() => buildNavigationEntries(router.getRoutes()).filter(entry => !entry.internalOnly))
|
||||
const sections = computed(() => groupByModule(entries.value))
|
||||
const preference = useScreenPreferenceStore()
|
||||
|
||||
const entryByScreenId = computed(() => new Map(entries.value.map(entry => [entry.screenId, entry])))
|
||||
const favorites = computed(() => preference.favoriteScreenIds.map(id => entryByScreenId.value.get(id)).filter((entry): entry is NavigationEntry => Boolean(entry)))
|
||||
const recents = computed(() => preference.recents.map(recent => entryByScreenId.value.get(recent.screenId)).filter((entry): entry is NavigationEntry => Boolean(entry)).filter(entry => !favorites.value.some(fav => fav.screenId === entry.screenId)))
|
||||
const workbench = computed(() => [...favorites.value, ...recents.value].slice(0, 10))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="ks-home">
|
||||
<header class="ks-home__header">
|
||||
<div>
|
||||
<p>K-ArtSell Aegis</p>
|
||||
<h1>홈</h1>
|
||||
<span>업무를 검색하고, 이어서 처리하고, 즐겨찾기로 자주 쓰는 화면에 바로 접근합니다.</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="ks-home__section" aria-labelledby="ks-home-attention-title">
|
||||
<header><h2 id="ks-home-attention-title">확인 필요</h2></header>
|
||||
<p class="ks-home__empty">현재 확인할 작업이나 알림이 없습니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="ks-home__section" aria-labelledby="ks-home-workbench-title">
|
||||
<header><h2 id="ks-home-workbench-title">바로 시작</h2><small>즐겨찾기 {{ favorites.length }} · 최근 {{ recents.length }}</small></header>
|
||||
<div v-if="workbench.length" class="ks-home__workbench-list" role="list">
|
||||
<RouterLink v-for="entry in workbench" :key="entry.screenId" :to="entry.path" role="listitem" class="ks-home__workbench-item">
|
||||
<span class="source">{{ favorites.some(fav => fav.screenId === entry.screenId) ? '즐겨찾기' : '최근' }}</span>
|
||||
<span class="main"><b>{{ entry.title }}</b><small>{{ entry.module }}</small></span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-else class="ks-home__empty">아직 즐겨찾기하거나 최근에 연 화면이 없습니다. 아래에서 화면을 찾아보세요.</p>
|
||||
</section>
|
||||
|
||||
<section class="ks-home__all" aria-label="전체 업무">
|
||||
<header><h2>모듈별 업무</h2></header>
|
||||
<div class="ks-home__modules">
|
||||
<section v-for="module in sections" :key="module.module" class="ks-home__module">
|
||||
<header><strong>{{ module.module }}</strong><small>{{ module.entries.length }}개 화면</small></header>
|
||||
<div class="ks-home__module-links">
|
||||
<div v-for="entry in module.entries" :key="entry.screenId" class="ks-home__module-row">
|
||||
<RouterLink class="launch" :to="entry.path">{{ entry.title }}</RouterLink>
|
||||
<button
|
||||
v-if="entry.favoriteAllowed"
|
||||
type="button"
|
||||
class="favorite"
|
||||
:aria-pressed="preference.isFavorite(entry.screenId)"
|
||||
:aria-label="preference.isFavorite(entry.screenId) ? `${entry.title} 즐겨찾기 해제` : `${entry.title} 즐겨찾기 추가`"
|
||||
@click="preference.toggleFavorite(entry.screenId)"
|
||||
>{{ preference.isFavorite(entry.screenId) ? '★' : '☆' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-home { display: grid; gap: var(--ks-space-4); max-width: var(--ks-content-max); margin: 0 auto; }
|
||||
.ks-home__header p, .ks-home__header span { margin: 0; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-home__header h1 { margin: 0; font-size: var(--ks-font-page); }
|
||||
.ks-home__section, .ks-home__all { border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-md); background: var(--ks-color-surface); }
|
||||
.ks-home__section > header, .ks-home__all > header { display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-3); padding: var(--ks-space-2) var(--ks-space-3); border-bottom: 1px solid var(--ks-color-border); }
|
||||
.ks-home__section > header h2, .ks-home__all > header h2 { margin: 0; font-size: var(--ks-font-section); }
|
||||
.ks-home__section > header small { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-home__empty { margin: 0; padding: var(--ks-space-4) var(--ks-space-3); color: var(--ks-color-text-muted); font-size: var(--ks-font-body); }
|
||||
.ks-home__workbench-list { display: flex; flex-direction: column; }
|
||||
.ks-home__workbench-item { display: grid; grid-template-columns: 5rem minmax(0, 1fr); align-items: center; gap: var(--ks-space-2); padding: var(--ks-space-2) var(--ks-space-3); border-bottom: 1px solid var(--ks-color-border); text-decoration: none; color: inherit; }
|
||||
.ks-home__workbench-item:last-child { border-bottom: 0; }
|
||||
.ks-home__workbench-item .source { font-size: var(--ks-font-caption); font-weight: 600; color: var(--ks-color-action); }
|
||||
.ks-home__workbench-item .main small { display: block; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-home__modules { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); }
|
||||
.ks-home__module { border-right: 1px solid var(--ks-color-border); border-bottom: 1px solid var(--ks-color-border); }
|
||||
.ks-home__module > header { display: flex; align-items: center; justify-content: space-between; padding: var(--ks-space-2) var(--ks-space-3); border-bottom: 1px solid var(--ks-color-border); }
|
||||
.ks-home__module > header small { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-home__module-row { display: flex; align-items: center; }
|
||||
.ks-home__module-row .launch { flex: 1; padding: var(--ks-space-2) var(--ks-space-3); text-decoration: none; color: inherit; }
|
||||
.ks-home__module-row .favorite { width: 2rem; border: 0; background: transparent; color: var(--ks-color-text-muted); }
|
||||
.ks-home__module-row .favorite[aria-pressed='true'] { color: var(--ks-color-action); }
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,89 +1,47 @@
|
||||
<template>
|
||||
<div class="rebalance-form">
|
||||
<div class="header">
|
||||
<h1>Portfolio Rebalancing</h1>
|
||||
<p class="subtitle">Adjust target weights and trigger rebalancing</p>
|
||||
</div>
|
||||
<PageLayout title="포트폴리오 리밸런싱" subtitle="목표 비중을 조정하고 리밸런싱을 실행합니다.">
|
||||
<KsFormSection title="현재 구성">
|
||||
<DataGridShell :rows="currentPositions" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
|
||||
<p class="ks-stack"><strong>포트폴리오 총 평가액:</strong> {{ formatCurrency(totalValue, 'USD') }}</p>
|
||||
</KsFormSection>
|
||||
|
||||
<div class="content">
|
||||
<!-- Current Composition -->
|
||||
<div class="card">
|
||||
<h2>Current Composition</h2>
|
||||
<table class="positions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Quantity</th>
|
||||
<th>Market Price</th>
|
||||
<th>Market Value</th>
|
||||
<th>Weight %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pos in currentPositions" :key="pos.symbol">
|
||||
<td>{{ pos.symbol }}</td>
|
||||
<td>{{ pos.quantity.toLocaleString() }}</td>
|
||||
<td>${{ pos.marketPrice.toFixed(2) }}</td>
|
||||
<td>${{ pos.marketValue.toLocaleString() }}</td>
|
||||
<td>{{ pos.weightPercent.toFixed(1) }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="total">
|
||||
<strong>Total Portfolio Value:</strong> ${{ totalValue.toLocaleString() }}
|
||||
<KsFormSection title="목표 비중 설정">
|
||||
<KsFormGrid :columns="1" aria-label="리밸런싱 설정">
|
||||
<KsNumberField v-model="driftThreshold" label="허용 이탈 임계값 %" :min="0" :max="50" :max-fraction-digits="0" />
|
||||
</KsFormGrid>
|
||||
|
||||
<div class="targets ks-stack">
|
||||
<div v-for="(target, idx) in targetWeights" :key="idx" class="target-row">
|
||||
<KsTextField v-model="target.symbol" label="종목코드" :input-id="`target-symbol-${idx}`" />
|
||||
<KsNumberField v-model="target.targetPercent" label="목표 비중 %" :min="0" :max="100" :max-fraction-digits="0" :input-id="`target-percent-${idx}`" />
|
||||
<KsButton severity="danger" label="삭제" @click="removeTarget(idx)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Weights Form -->
|
||||
<div class="card">
|
||||
<h2>Set Target Weights</h2>
|
||||
<div class="form-group">
|
||||
<div class="drift-threshold">
|
||||
<label>Drift Threshold %:</label>
|
||||
<input v-model.number="driftThreshold" type="number" min="0" max="50" step="1" />
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<KsButton severity="secondary" label="종목 추가" @click="addTarget" />
|
||||
<KsButton severity="primary" label="리밸런싱 실행" @click="triggerRebalance" />
|
||||
</template>
|
||||
</KsFormSection>
|
||||
|
||||
<div class="targets">
|
||||
<div v-for="(target, idx) in targetWeights" :key="idx" class="target-row">
|
||||
<input v-model="target.symbol" placeholder="Symbol" class="symbol-input" />
|
||||
<input v-model.number="target.targetPercent" type="number" min="0" max="100" step="1" placeholder="%" class="percent-input" />
|
||||
<button @click="removeTarget(idx)" class="btn-remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="addTarget" class="btn-secondary">+ Add Symbol</button>
|
||||
<button @click="triggerRebalance" class="btn-primary">Trigger Rebalance</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="jobResult" class="card result">
|
||||
<h2>Rebalance Queued</h2>
|
||||
<div class="result-item">
|
||||
<span>Job ID:</span>
|
||||
<span class="mono">{{ jobResult.jobId }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>Status:</span>
|
||||
<span class="status-badge">{{ jobResult.status }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>Estimated Trades:</span>
|
||||
<span>{{ jobResult.estimatedTradeCount }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span>Estimated Cost:</span>
|
||||
<span>${{ jobResult.estimatedCost.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<KsFormSection v-if="jobResult" title="리밸런싱 대기열 등록됨">
|
||||
<dl class="ks-stack">
|
||||
<dt>Job ID</dt><dd class="mono">{{ jobResult.jobId }}</dd>
|
||||
<dt>상태</dt><dd><KsStatusTag :value="jobResult.status" severity="info" /></dd>
|
||||
<dt>예상 거래 건수</dt><dd>{{ formatQuantity(jobResult.estimatedTradeCount, 0) }}</dd>
|
||||
<dt>예상 비용</dt><dd>{{ formatCurrency(jobResult.estimatedCost, 'USD') }}</dd>
|
||||
</dl>
|
||||
</KsFormSection>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import { KsButton, KsFormGrid, KsFormSection, KsNumberField, KsStatusTag, KsTextField } from '../../../shared/ui/components'
|
||||
import type { UiGridColumn } from '../../../shared/ui/adapter/contracts'
|
||||
import { formatCurrency, formatPercent, formatQuantity } from '../../../shared/formatters/financial'
|
||||
|
||||
interface Position {
|
||||
symbol: string
|
||||
@@ -112,6 +70,14 @@ const currentPositions = ref<Position[]>([
|
||||
{ symbol: 'GOOGL', quantity: 50, marketPrice: 140.75, marketValue: 7037.5, weightPercent: 16.5 },
|
||||
])
|
||||
|
||||
const positionColumns = computed<UiGridColumn[]>(() => [
|
||||
{ field: 'symbol', header: '종목코드' },
|
||||
{ field: 'quantity', header: '수량', formatter: value => formatQuantity(value as number, 0) },
|
||||
{ field: 'marketPrice', header: '현재가', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'marketValue', header: '평가금액', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'weightPercent', header: '비중', formatter: value => formatPercent((value as number) / 100, 1) }
|
||||
])
|
||||
|
||||
const driftThreshold = ref(5)
|
||||
const targetWeights = ref<TargetWeight[]>([
|
||||
{ symbol: 'AAPL', targetPercent: 40 },
|
||||
@@ -142,190 +108,10 @@ const triggerRebalance = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rebalance-form {
|
||||
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;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.positions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.positions-table thead {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.positions-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.positions-table td {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.total {
|
||||
padding: 1rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.drift-threshold {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drift-threshold label {
|
||||
font-weight: 600;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.drift-threshold input {
|
||||
width: 100px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.targets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.target-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.symbol-input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.percent-input {
|
||||
width: 80px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result {
|
||||
background-color: #f0fdf4;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item span:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.target-row { display: flex; align-items: end; gap: var(--ks-space-3); }
|
||||
.target-row > :first-child { flex: 1; }
|
||||
.mono { font-family: monospace; }
|
||||
dl.ks-stack { display: grid; grid-template-columns: auto 1fr; gap: var(--ks-space-2) var(--ks-space-4); }
|
||||
dl.ks-stack dt { font-weight: 600; color: var(--ks-color-text-muted); }
|
||||
dl.ks-stack dd { margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,162 +1,112 @@
|
||||
<template>
|
||||
<div class="risk-dashboard">
|
||||
<div class="header">
|
||||
<h1>Portfolio Risk Dashboard</h1>
|
||||
<p class="subtitle">Real-time risk metrics, stress scenarios, and alerts</p>
|
||||
<ScorecardDashboardPage title="포트폴리오 리스크 대시보드" subtitle="실시간 리스크 지표, 스트레스 시나리오, 알림을 확인합니다." :state="state" :warning="error ?? undefined" @retry="fetchDashboard">
|
||||
<template #actions>
|
||||
<div v-if="dashboard" class="health-score">
|
||||
<span class="score-label">Portfolio Health:</span>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" :style="{ width: dashboard.healthScore + '%' }"></div>
|
||||
</div>
|
||||
<span class="score-label">포트폴리오 건전성</span>
|
||||
<div class="score-bar"><div class="score-fill" :style="{ width: dashboard.healthScore + '%' }" /></div>
|
||||
<span class="score-value">{{ dashboard.healthScore }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
<button @click="fetchDashboard" class="btn-retry">Retry</button>
|
||||
</div>
|
||||
<template v-if="dashboard" #kpis>
|
||||
<div class="metric">
|
||||
<span class="label">VAR (95%)</span>
|
||||
<span class="value">{{ formatCurrency(dashboard.riskMetrics.var95, 'USD') }}</span>
|
||||
<span class="note">{{ formatPercent(dashboard.riskMetrics.var95 / dashboard.portfolio.totalValue, 1) }}</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Sharpe Ratio</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.sharpeRatio.toFixed(2) }}</span>
|
||||
<span class="note">252일 이동</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Sortino Ratio</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.sortinoRatio.toFixed(2) }}</span>
|
||||
<span class="note">하방 리스크 중심</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">변동성</span>
|
||||
<span class="value">{{ formatPercent(dashboard.riskMetrics.volatilityPercent / 100, 1) }}</span>
|
||||
<span class="note">연환산</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">상위 5종목 비중</span>
|
||||
<span class="value">{{ formatPercent(dashboard.riskMetrics.topFivePercent / 100, 1) }}</span>
|
||||
<KsStatusTag :value="topFiveLabel" :severity="topFiveSeverity" />
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">최대 보유 비중</span>
|
||||
<span class="value">{{ formatPercent(dashboard.riskMetrics.maxPositionPercent / 100, 1) }}</span>
|
||||
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol ?? 'N/A' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
<template v-if="dashboard" #primary>
|
||||
<h2>포트폴리오 구성</h2>
|
||||
<dl class="portfolio-summary">
|
||||
<div class="summary-item"><dt>총 평가액</dt><dd>{{ formatCurrency(dashboard.portfolio.totalValue, 'USD') }}</dd></div>
|
||||
<div class="summary-item"><dt>보유 종목 수</dt><dd>{{ formatQuantity(dashboard.portfolio.positions.length, 0) }}</dd></div>
|
||||
</dl>
|
||||
<DataGridShell :rows="dashboard.portfolio.positions.slice(0, 5)" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
|
||||
</template>
|
||||
|
||||
<div v-else-if="dashboard" class="content">
|
||||
<!-- Portfolio Composition (VS-04) -->
|
||||
<div class="card portfolio">
|
||||
<h2>Portfolio Composition</h2>
|
||||
<div class="portfolio-summary">
|
||||
<div class="summary-item">
|
||||
<span class="label">Total Value</span>
|
||||
<span class="value">${{ dashboard.portfolio.totalValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Positions</span>
|
||||
<span class="value">{{ dashboard.portfolio.positions.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="positions-mini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
<th>Value</th>
|
||||
<th>Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pos in dashboard.portfolio.positions.slice(0, 5)" :key="pos.symbol">
|
||||
<td><strong>{{ pos.symbol }}</strong></td>
|
||||
<td>{{ pos.quantity.toLocaleString() }}</td>
|
||||
<td>${{ pos.marketPrice.toFixed(2) }}</td>
|
||||
<td>${{ pos.marketValue.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</td>
|
||||
<td>{{ pos.weightPercent.toFixed(1) }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-if="dashboard" #secondary>
|
||||
<h2>스트레스 테스트 시나리오</h2>
|
||||
<div class="scenarios">
|
||||
<button
|
||||
v-for="stress in dashboard.stressResults"
|
||||
:key="stress.scenario"
|
||||
type="button"
|
||||
class="scenario"
|
||||
@click="runStressTest(stress.scenario)"
|
||||
>
|
||||
<span class="name">{{ scenarioLabel(stress.scenario) }}</span>
|
||||
<span class="impact">{{ formatPercent(stress.portfolioLossPercent / 100, 1) }} 포트폴리오</span>
|
||||
<KsStatusTag :value="Math.abs(stress.portfolioLossPercent) > 15 ? '심각' : '보통'" :severity="Math.abs(stress.portfolioLossPercent) > 15 ? 'danger' : 'warning'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- VS-05: Risk Metrics -->
|
||||
<div class="card metrics">
|
||||
<h2>Risk Metrics</h2>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<span class="label">VAR (95%)</span>
|
||||
<span class="value">${{ dashboard.riskMetrics.var95.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</span>
|
||||
<span class="percent">{{ (dashboard.riskMetrics.var95 / dashboard.portfolio.totalValue * 100).toFixed(1) }}%</span>
|
||||
<div v-if="stressResult" class="stress-result">
|
||||
<h3>결과: {{ stressResult.scenario }}</h3>
|
||||
<div class="result-row"><span>포트폴리오 손실</span><span :class="['value', stressResult.loss < 0 ? 'loss' : 'gain']">{{ formatPercent(stressResult.loss / 100, 2) }}</span></div>
|
||||
<div class="result-row"><span>스트레스 VAR</span><span class="value">{{ formatCurrency(stressResult.stressedVar, 'USD') }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #alerts>
|
||||
<h2>활성 리스크 알림</h2>
|
||||
<div v-if="activeAlerts.length > 0" class="alerts-list">
|
||||
<div v-for="alert in activeAlerts" :key="alert.id" class="alert">
|
||||
<div class="alert-header">
|
||||
<span class="threshold">{{ alert.threshold }}</span>
|
||||
<KsStatusTag :value="alert.severity" :severity="alertSeverity(alert.severity)" />
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Sharpe Ratio</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.sharpeRatio.toFixed(2) }}</span>
|
||||
<span class="note">252-day rolling</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Sortino Ratio</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.sortinoRatio.toFixed(2) }}</span>
|
||||
<span class="note">Downside focus</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Volatility</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.volatilityPercent.toFixed(1) }}%</span>
|
||||
<span class="note">Annualized</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Top 5 Holdings</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.topFivePercent.toFixed(1) }}%</span>
|
||||
<span :class="['flag', dashboard.riskMetrics.topFivePercent > 60 ? 'danger' : 'warning']">
|
||||
{{ dashboard.riskMetrics.topFivePercent > 70 ? '🔴 High' : dashboard.riskMetrics.topFivePercent > 50 ? '⚠️ Medium' : '✅ Low' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="label">Max Position</span>
|
||||
<span class="value">{{ dashboard.riskMetrics.maxPositionPercent.toFixed(1) }}%</span>
|
||||
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol || 'N/A' }}</span>
|
||||
<div class="alert-details">
|
||||
<span class="current">{{ formatPercent(alert.current / 100, 1) }}</span>
|
||||
<span class="message">{{ alert.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="no-alerts">현재 활성 알림이 없습니다 — 포트폴리오가 안전 범위 내에 있습니다.</p>
|
||||
</template>
|
||||
|
||||
<!-- VS-06: Stress Testing -->
|
||||
<div class="card stress">
|
||||
<h2>Stress Test Scenarios</h2>
|
||||
<div class="scenarios">
|
||||
<div v-for="stress in dashboard.stressResults" :key="stress.scenario" class="scenario" @click="runStressTest(stress.scenario)">
|
||||
<span class="name">{{ stress.scenario.charAt(0).toUpperCase() + stress.scenario.slice(1) }}</span>
|
||||
<span class="impact">{{ stress.portfolioLossPercent > 0 ? '+' : '' }}{{ stress.portfolioLossPercent.toFixed(1) }}% Portfolio</span>
|
||||
<span :class="['status', Math.abs(stress.portfolioLossPercent) > 15 ? 'severe' : 'moderate']">
|
||||
{{ Math.abs(stress.portfolioLossPercent) > 15 ? 'Severe' : 'Moderate' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stressResult" class="stress-result">
|
||||
<h3>Results: {{ stressResult.scenario }}</h3>
|
||||
<div class="result-row">
|
||||
<span>Portfolio Loss:</span>
|
||||
<span :class="['value', stressResult.loss < 0 ? 'loss' : 'gain']">{{ stressResult.loss > 0 ? '+' : '' }}{{ stressResult.loss.toFixed(2) }}%</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Stressed VAR:</span>
|
||||
<span class="value">${{ stressResult.stressedVar.toLocaleString('en-US', { maximumFractionDigits: 0 }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS-07: Risk Alerts -->
|
||||
<div class="card alerts">
|
||||
<h2>Active Risk Alerts</h2>
|
||||
<div v-if="activeAlerts.length > 0" class="alerts-list">
|
||||
<div v-for="alert in activeAlerts" :key="alert.id" :class="['alert', `severity-${alert.severity.toLowerCase()}`]">
|
||||
<div class="alert-header">
|
||||
<span class="threshold">{{ alert.threshold }}</span>
|
||||
<span class="badge">{{ alert.severity }}</span>
|
||||
</div>
|
||||
<div class="alert-details">
|
||||
<span class="current">{{ alert.current.toFixed(1) }}%</span>
|
||||
<span class="message">{{ alert.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-alerts">
|
||||
✅ No active alerts — portfolio within safe limits
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk Insights (VS-08 aggregated summary) -->
|
||||
<div class="card insights">
|
||||
<h2>Risk Insights</h2>
|
||||
<ul class="insights-list">
|
||||
<li v-for="(insight, idx) in dashboard.riskInsights" :key="idx">
|
||||
{{ insight }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="dashboard?.riskInsights.length" #metricDefinitions>
|
||||
<h2>리스크 인사이트</h2>
|
||||
<ul class="insights-list">
|
||||
<li v-for="(insight, idx) in dashboard.riskInsights" :key="idx">{{ insight }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ScorecardDashboardPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import ScorecardDashboardPage from '../../../shared/ui/screen-types/v2/ScorecardDashboardPage.vue'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import { KsStatusTag } from '../../../shared/ui/components'
|
||||
import type { UiGridColumn, UiSeverity } from '../../../shared/ui/adapter/contracts'
|
||||
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
|
||||
import { formatCurrency, formatPercent, formatQuantity } from '../../../shared/formatters/financial'
|
||||
|
||||
interface StressResult {
|
||||
scenario: string
|
||||
@@ -217,13 +167,43 @@ const portfolioId = ref('550e8400-e29b-41d4-a716-446655440001')
|
||||
const activeAlerts = ref<Alert[]>([
|
||||
{
|
||||
id: '1',
|
||||
threshold: 'Concentration (Top-5)',
|
||||
threshold: '집중도(상위 5종목)',
|
||||
current: 52.3,
|
||||
severity: 'Warning',
|
||||
message: 'Top 5 holdings at 52.3% (threshold: 60%)',
|
||||
message: '상위 5종목 비중 52.3% (임계값 60%)',
|
||||
},
|
||||
])
|
||||
|
||||
const state = computed<StandardScreenState>(() => loading.value ? 'LOADING' : error.value ? 'WARN' : 'READY')
|
||||
|
||||
const positionColumns: UiGridColumn[] = [
|
||||
{ field: 'symbol', header: '종목코드' },
|
||||
{ field: 'quantity', header: '수량', formatter: value => formatQuantity(value as number, 0) },
|
||||
{ field: 'marketPrice', header: '현재가', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'marketValue', header: '평가금액', formatter: value => formatCurrency(value as number, 'USD') },
|
||||
{ field: 'weightPercent', header: '비중', formatter: value => formatPercent((value as number) / 100, 1) }
|
||||
]
|
||||
|
||||
const topFiveLabel = computed(() => {
|
||||
const value = dashboard.value?.riskMetrics.topFivePercent ?? 0
|
||||
return value > 70 ? '높음' : value > 50 ? '중간' : '낮음'
|
||||
})
|
||||
const topFiveSeverity = computed<UiSeverity>(() => {
|
||||
const value = dashboard.value?.riskMetrics.topFivePercent ?? 0
|
||||
return value > 60 ? 'danger' : 'warning'
|
||||
})
|
||||
|
||||
function scenarioLabel(scenario: string): string {
|
||||
return scenario.charAt(0).toUpperCase() + scenario.slice(1)
|
||||
}
|
||||
|
||||
function alertSeverity(severity: string): UiSeverity {
|
||||
const normalized = severity.toLowerCase()
|
||||
if (normalized === 'critical') return 'danger'
|
||||
if (normalized === 'warning') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDashboard()
|
||||
})
|
||||
@@ -243,22 +223,21 @@ const fetchDashboard = async () => {
|
||||
message: a.message,
|
||||
})) || []
|
||||
} else {
|
||||
error.value = 'Failed to fetch dashboard'
|
||||
error.value = '대시보드를 불러오지 못했습니다.'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||
error.value = e instanceof Error ? e.message : '알 수 없는 오류'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const runStressTest = async (scenario: string) => {
|
||||
const scenarioKey = scenario === 'bull' ? 'bull' : scenario === 'bear' ? 'bear' : scenario === 'rateShock' ? 'rateShock' : 'volSpike'
|
||||
const result = dashboard.value?.stressResults.find(s => s.scenario.toLowerCase() === scenario.toLowerCase())
|
||||
|
||||
if (result) {
|
||||
stressResult.value = {
|
||||
scenario: scenario.charAt(0).toUpperCase() + scenario.slice(1),
|
||||
scenario: scenarioLabel(scenario),
|
||||
loss: result.portfolioLossPercent,
|
||||
stressedVar: result.stressedVar,
|
||||
}
|
||||
@@ -267,380 +246,42 @@ const runStressTest = async (scenario: string) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.risk-dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.health-score { display: flex; gap: var(--ks-space-3); align-items: center; min-width: 20rem; }
|
||||
.score-label { font-weight: 600; white-space: nowrap; }
|
||||
.score-bar { flex: 1; height: 1.5rem; background: var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); overflow: hidden; }
|
||||
.score-fill { height: 100%; background: linear-gradient(90deg, var(--ks-color-danger), var(--ks-color-warning), var(--ks-color-success)); }
|
||||
.score-value { font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.metric { display: flex; flex-direction: column; gap: var(--ks-space-1); padding: var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); text-align: center; }
|
||||
.metric .label { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); font-weight: 500; }
|
||||
.metric .value { font-size: var(--ks-font-page); font-weight: 600; }
|
||||
.metric .note { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); }
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.portfolio-summary { display: flex; gap: var(--ks-space-6); margin: 0 0 var(--ks-space-3); padding: var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); }
|
||||
.summary-item { display: flex; flex-direction: column; gap: var(--ks-space-1); }
|
||||
.summary-item dt { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); font-weight: 500; }
|
||||
.summary-item dd { margin: 0; font-size: var(--ks-font-page); font-weight: 600; }
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.scenarios { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--ks-space-3); margin-bottom: var(--ks-space-4); }
|
||||
.scenario { display: flex; flex-direction: column; gap: var(--ks-space-1); padding: var(--ks-space-3); border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: var(--ks-color-surface); text-align: left; cursor: pointer; }
|
||||
.scenario:hover { border-color: var(--ks-color-action); }
|
||||
.scenario .name { font-weight: 600; font-size: var(--ks-font-body); }
|
||||
.scenario .impact { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); }
|
||||
.stress-result { padding: var(--ks-space-3); background: color-mix(in srgb, var(--ks-color-warning) 12%, var(--ks-color-surface)); border-radius: var(--ks-radius-sm); }
|
||||
.result-row { display: flex; justify-content: space-between; padding: var(--ks-space-1) 0; }
|
||||
.result-row .value.loss { color: var(--ks-color-danger); font-weight: 600; }
|
||||
.result-row .value.gain { color: var(--ks-color-success); font-weight: 600; }
|
||||
|
||||
.health-score {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.alerts-list { display: flex; flex-direction: column; gap: var(--ks-space-2); }
|
||||
.alert { padding: var(--ks-space-3); border-radius: var(--ks-radius-sm); background: var(--ks-color-canvas); }
|
||||
.alert-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--ks-space-2); }
|
||||
.alert-header .threshold { font-weight: 600; }
|
||||
.alert-details { display: flex; justify-content: space-between; font-size: var(--ks-font-body); }
|
||||
.alert-details .current { font-weight: 600; }
|
||||
.alert-details .message { color: var(--ks-color-text-muted); }
|
||||
.no-alerts { padding: var(--ks-space-4); text-align: center; color: var(--ks-color-success); font-weight: 500; }
|
||||
|
||||
.score-label {
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 1rem;
|
||||
background-color: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
color: #991b1b;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #991b1b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Metrics Grid */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.metric .percent,
|
||||
.metric .note {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.metric .flag {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Stress Test Scenarios */
|
||||
.scenarios {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.scenario {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario:hover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.scenario .name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.scenario .impact {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scenario .status {
|
||||
font-size: 0.75rem;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stress-result {
|
||||
padding: 1rem;
|
||||
background-color: #fef3c7;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.result-row .value {
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid;
|
||||
border-radius: 4px;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.alert.severity-initial {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.alert.severity-warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.alert.severity-critical {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-header .threshold {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert.severity-initial .badge {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.alert.severity-warning .badge {
|
||||
background-color: #fed7aa;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.alert.severity-critical .badge {
|
||||
background-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-details .current {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-details .message {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-alerts {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Portfolio Card */
|
||||
.portfolio-summary {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.positions-mini {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.positions-mini thead {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.positions-mini th {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.positions-mini td {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Risk Insights */
|
||||
.insights {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.insights-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.insights-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.insights-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.insights-list li::before {
|
||||
content: '💡 ';
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Stress scenario status badges */
|
||||
.scenario .status.severe {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.scenario .status.moderate {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.metric .flag.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.metric .flag.warning {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stress-result .value.loss {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stress-result .value.gain {
|
||||
color: #10b981;
|
||||
}
|
||||
.insights-list { list-style: none; margin: 0; padding: 0; }
|
||||
.insights-list li { padding: var(--ks-space-2) 0; border-bottom: 1px solid var(--ks-color-border); }
|
||||
.insights-list li:last-child { border-bottom: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
|
||||
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
|
||||
import { KsButton, KsCheckbox, KsFormGrid, KsFormSection, KsNumberField } from '../../../shared/ui/components'
|
||||
import PolicyTracePanel from '../components/PolicyTracePanel.vue'
|
||||
import { useEvaluateResearchSellPolicy } from '../queries'
|
||||
import type { ResearchSellPolicyCommand } from '../api'
|
||||
@@ -56,37 +58,19 @@ function retry() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header>
|
||||
<h1>매도 정책 연구 콘솔</h1>
|
||||
<p>
|
||||
순수 정책 계약과 우선순위를 확인하는 연구 전용 화면입니다.
|
||||
고객 제안·공개·주문 기능과 연결되지 않습니다.
|
||||
</p>
|
||||
<strong>RESEARCH_CANDIDATE_NOT_PRODUCTION · 자동주문 OFF</strong>
|
||||
</header>
|
||||
|
||||
<PageLayout title="매도 정책 연구 콘솔" subtitle="순수 정책 계약과 우선순위를 확인하는 연구 전용 화면입니다. 고객 제안·공개·주문 기능과 연결되지 않습니다." status="RESEARCH_CANDIDATE_NOT_PRODUCTION · 자동주문 OFF">
|
||||
<form @submit.prevent="run">
|
||||
<fieldset :disabled="isBusy">
|
||||
<legend>연구 입력 벡터</legend>
|
||||
<label>
|
||||
<input v-model="hardImpairmentApproved" type="checkbox" />
|
||||
Hard impairment 승인
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="capitalFloorBreached" type="checkbox" />
|
||||
자본바닥 위반
|
||||
</label>
|
||||
<label>
|
||||
보호선 이탈 ATR
|
||||
<input v-model.number="gapBelowFloorAtr" type="number" min="0" step="0.1" />
|
||||
</label>
|
||||
<label>
|
||||
연속 종가 이탈
|
||||
<input v-model.number="consecutiveCloseBreaches" type="number" min="0" step="1" />
|
||||
</label>
|
||||
<button type="submit">정책 평가</button>
|
||||
</fieldset>
|
||||
<KsFormSection title="연구 입력 벡터">
|
||||
<KsFormGrid :columns="2" aria-label="연구 입력 벡터">
|
||||
<KsCheckbox v-model="hardImpairmentApproved" label="Hard impairment 승인" :disabled="isBusy" />
|
||||
<KsCheckbox v-model="capitalFloorBreached" label="자본바닥 위반" :disabled="isBusy" />
|
||||
<KsNumberField v-model="gapBelowFloorAtr" label="보호선 이탈 ATR" :min="0" :max-fraction-digits="2" :disabled="isBusy" />
|
||||
<KsNumberField v-model="consecutiveCloseBreaches" label="연속 종가 이탈" :min="0" :max-fraction-digits="0" :disabled="isBusy" />
|
||||
</KsFormGrid>
|
||||
<template #actions>
|
||||
<KsButton type="submit" label="정책 평가" :loading="isBusy" />
|
||||
</template>
|
||||
</KsFormSection>
|
||||
</form>
|
||||
|
||||
<QueryStateBoundary
|
||||
@@ -95,7 +79,7 @@ function retry() {
|
||||
:empty="!mutation.data.value"
|
||||
@retry="retry"
|
||||
>
|
||||
<dl v-if="mutation.data.value">
|
||||
<dl v-if="mutation.data.value" class="ks-stack">
|
||||
<dt>행동</dt><dd>{{ mutation.data.value.action }}</dd>
|
||||
<dt>정책</dt><dd>{{ mutation.data.value.policyId }}</dd>
|
||||
<dt>사유</dt><dd>{{ mutation.data.value.reasonCode }}</dd>
|
||||
@@ -111,5 +95,5 @@ function retry() {
|
||||
:schema-version="mutation.data.value.policyTraceSchemaVersion"
|
||||
/>
|
||||
</QueryStateBoundary>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SearchListCrudPage } from '@/shared/ui/screen-types'
|
||||
import { screenTemplateCatalogue } from '@/shared/ui/screen-types/catalogue'
|
||||
import { KsButton, KsDataGrid, KsSelect, KsStatusTag, KsTextField } from '@/shared/ui/components'
|
||||
import { screenTemplateCatalogue, type ScreenTemplateId } from '@/shared/ui/screen-types/catalogue'
|
||||
import { KsButton, KsDataGrid, KsSelect, KsStatusTag, KsTextField, KsNumberField, KsDateField, KsCheckbox, KsFormGrid, KsFormSection, KsFormSpan, KsValidationSummary } from '@/shared/ui/components'
|
||||
import type { UiGridColumn, UiSelectOption } from '@/shared/ui/adapter/contracts'
|
||||
import { useUiAdapter } from '@/shared/ui/adapter/useUiAdapter'
|
||||
|
||||
const adapter = useUiAdapter()
|
||||
const router = useRouter()
|
||||
const query = ref('')
|
||||
const status = ref<unknown>('ALL')
|
||||
const options: UiSelectOption[] = [{ label: '전체', value: 'ALL' }, { label: '검토 필요', value: 'REVIEW' }, { label: '보류', value: 'HOLD' }]
|
||||
const rows = computed(() => screenTemplateCatalogue.filter(x => !query.value || `${x.id} ${x.name} ${x.component}`.toLowerCase().includes(query.value.toLowerCase())).map(x => ({ id: x.id, name: x.name, component: x.component, evidence: x.mandatoryEvidence.length, state: 'READY' })))
|
||||
const columns: UiGridColumn[] = [{ field: 'id', header: '화면 ID', width: 100 }, { field: 'name', header: '화면 타입' }, { field: 'component', header: '표준 컴포넌트', minWidth: 220 }, { field: 'evidence', header: '필수 증거', width: 110 }, { field: 'state', header: '상태', width: 110 }]
|
||||
const templateFilter = ref<unknown>('ALL')
|
||||
type ScreenCatalogueRow = { screenId: string; name: string; templateId: ScreenTemplateId; templateName: string; path: string }
|
||||
const selectedScreenId = ref<unknown>(null)
|
||||
const demo = ref({ name: '', amount: null as number | null, date: null as Date | null, acknowledged: false })
|
||||
const demoReadonly = ref(false)
|
||||
const submitted = ref(false)
|
||||
const demoErrors = computed(() => submitted.value ? [
|
||||
...(demo.value.name.trim() ? [] : [{ field: 'name', message: '이름을 입력하세요.' }]),
|
||||
...(demo.value.amount !== null && demo.value.amount > 0 ? [] : [{ field: 'amount', message: '0보다 큰 금액을 입력하세요.' }]),
|
||||
...(demo.value.acknowledged ? [] : [{ field: 'acknowledged', message: '확인 항목에 동의하세요.' }])
|
||||
] : [])
|
||||
function submitDemo(): void { submitted.value = true }
|
||||
function resetDemo(): void { demo.value = { name: '', amount: null, date: null, acknowledged: false }; submitted.value = false }
|
||||
const templateOptions: UiSelectOption[] = [{ label: '전체 화면 유형', value: 'ALL' }, ...screenTemplateCatalogue.map(template => ({ label: `${template.id} · ${template.name}`, value: template.id }))]
|
||||
const rows = computed<ScreenCatalogueRow[]>(() => router.getRoutes()
|
||||
.map(route => {
|
||||
const screenId = route.meta.screenId
|
||||
const templateId = route.meta.templateId
|
||||
const name = route.meta.title
|
||||
if (route.path === '/internal/ui-standard' || route.meta.internalOnly === true || typeof screenId !== 'string' || typeof templateId !== 'string' || typeof name !== 'string') return null
|
||||
const template = screenTemplateCatalogue.find(item => item.id === templateId)
|
||||
return template ? { screenId, name, templateId: template.id, templateName: template.name, path: route.path } : null
|
||||
})
|
||||
.filter((row): row is ScreenCatalogueRow => row !== null)
|
||||
.filter(row => templateFilter.value === 'ALL' || row.templateId === templateFilter.value)
|
||||
.filter(row => !query.value || `${row.screenId} ${row.name} ${row.templateId} ${row.templateName}`.toLowerCase().includes(query.value.toLowerCase()))
|
||||
)
|
||||
const screenOptions = computed<UiSelectOption[]>(() => rows.value.map(row => ({ label: `${row.screenId} · ${row.name}`, value: row.screenId })))
|
||||
const selectedScreen = computed(() => rows.value.find(row => row.screenId === selectedScreenId.value) ?? null)
|
||||
const columns: UiGridColumn[] = [{ field: 'screenId', header: '화면 ID', width: 130 }, { field: 'name', header: '기능 화면', minWidth: 180 }, { field: 'templateId', header: '유형', width: 90 }, { field: 'templateName', header: '화면 유형', minWidth: 180 }]
|
||||
function openSelectedScreen(): void { if (selectedScreen.value) void router.push(selectedScreen.value.path) }
|
||||
</script>
|
||||
<template>
|
||||
<SearchListCrudPage title="표준 UI 패턴" subtitle="Feature는 공급자 라이브러리를 직접 사용하지 않고, v4 어댑터·레이아웃·화면 계약을 사용한다." state="READY" :evidence="{ asOf: '2026-08-02', version: 'UI-CONTRACT-4.0' }">
|
||||
<template #actions><KsButton label="새 화면 패킷" severity="secondary" /></template>
|
||||
<template #summary><div class="ks-card summary"><strong>10</strong><span>화면 타입</span></div><div class="ks-card summary"><strong>{{ adapter.descriptor.capabilities.size }}</strong><span>어댑터 포트</span></div><div class="ks-card summary"><KsStatusTag :value="adapter.descriptor.id" severity="info" /><span>{{ adapter.descriptor.vendor }}</span></div><div class="ks-card summary"><KsStatusTag value="자동주문 OFF" severity="warning" /><span>고정 경계</span></div></template>
|
||||
<template #filters><div class="filters"><KsTextField v-model="query" label="검색" placeholder="화면 ID, 타입 또는 컴포넌트" /><KsSelect v-model="status" label="상태" :options="options" /></div></template>
|
||||
<KsDataGrid :rows="rows" :columns="columns" height="25rem" />
|
||||
<SearchListCrudPage title="표준 UI 패턴" subtitle="화면 유형을 선택하고, 실제 화면 ID를 선택해 기능 화면을 엽니다." state="READY" :evidence="{ asOf: '2026-08-02', version: 'UI-CONTRACT-4.0' }">
|
||||
<template #actions><KsButton label="선택 화면 열기" :disabled="!selectedScreen" @click="openSelectedScreen" /></template>
|
||||
<template #summary><div class="ks-card summary"><strong>{{ screenTemplateCatalogue.length }}</strong><span>화면 타입</span></div><div class="ks-card summary"><strong>{{ rows.length }}</strong><span>연결된 기능 화면</span></div><div class="ks-card summary"><KsStatusTag :value="adapter.descriptor.id" severity="info" /><span>{{ adapter.descriptor.vendor }}</span></div><div class="ks-card summary"><KsStatusTag value="자동주문 OFF" severity="warning" /><span>고정 경계</span></div></template>
|
||||
<template #filters><div class="filters"><KsTextField v-model="query" label="화면 검색" placeholder="화면 ID, 기능명 또는 화면 유형" /><KsSelect v-model="templateFilter" label="화면 유형" :options="templateOptions" /><KsSelect v-model="selectedScreenId" label="열 화면" placeholder="화면 ID를 선택하세요" :options="screenOptions" /></div></template>
|
||||
<KsDataGrid :rows="rows" :columns="columns" height="25rem" row-selection="none" />
|
||||
<section class="ks-card component-preview" aria-labelledby="component-preview-title">
|
||||
<div><h2 id="component-preview-title">컴포넌트 동작 프리뷰</h2><p>공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.</p></div>
|
||||
<div class="preview-grid">
|
||||
@@ -27,7 +56,21 @@ const columns: UiGridColumn[] = [{ field: 'id', header: '화면 ID', width: 100
|
||||
<div><h3>Inputs</h3><KsTextField label="텍스트 필드" model-value="샘플 값" /><KsSelect label="선택 필드" model-value="READY" :options="[{ label: '준비', value: 'READY' }, { label: '검토', value: 'REVIEW' }]" /></div>
|
||||
</div>
|
||||
</section>
|
||||
<template #detail><div class="ks-card detail"><h2>교체 계약</h2><p>기본 공급자 교체는 <code>VITE_UI_ADAPTER</code>와 provider registry에서 수행한다. Feature 코드는 변경하지 않는다.</p><p>PrimeVue+AG Grid와 Native reference adapter가 동일 contract test를 통과해야 한다.</p><p>생산 교체는 접근성, 키보드, 상태행렬, 시각회귀, 대량목록 성능을 별도 Gate로 검증한다.</p></div></template>
|
||||
<section class="ks-card component-preview" aria-labelledby="form-template-title">
|
||||
<div><h2 id="form-template-title">입력 유형 기능 테스트</h2><p>서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.</p></div>
|
||||
<KsValidationSummary :errors="demoErrors" />
|
||||
<KsFormSection title="기본 입력" description="필수 항목과 유효성 상태를 조작할 수 있습니다.">
|
||||
<template #actions><KsButton :label="demoReadonly ? '편집 허용' : '읽기 전용'" severity="secondary" @click="demoReadonly = !demoReadonly" /></template>
|
||||
<KsFormGrid aria-label="기능 테스트 입력">
|
||||
<KsTextField v-model="demo.name" label="이름" :disabled="demoReadonly" :error="submitted && !demo.name.trim() ? '필수 입력입니다.' : undefined" />
|
||||
<KsNumberField v-model="demo.amount" label="금액" :disabled="demoReadonly" :error="submitted && (!demo.amount || demo.amount <= 0) ? '0보다 커야 합니다.' : undefined" />
|
||||
<KsDateField v-model="demo.date" label="기준일" :disabled="demoReadonly" />
|
||||
<KsCheckbox v-model="demo.acknowledged" label="검증 조건을 확인했습니다" :disabled="demoReadonly" />
|
||||
<KsFormSpan span="full"><div class="ks-inline"><KsButton label="검증 실행" :disabled="demoReadonly" @click="submitDemo" /><KsButton label="초기화" severity="secondary" @click="resetDemo" /></div></KsFormSpan>
|
||||
</KsFormGrid>
|
||||
</KsFormSection>
|
||||
</section>
|
||||
<template #detail><div class="ks-card detail"><h2>선택한 기능 화면</h2><template v-if="selectedScreen"><p><strong>{{ selectedScreen.screenId }}</strong> · {{ selectedScreen.name }}</p><p>{{ selectedScreen.templateId }} · {{ selectedScreen.templateName }}</p><p><code>{{ selectedScreen.path }}</code></p><KsButton label="선택 화면 열기" @click="openSelectedScreen" /></template><p v-else>목록에서 화면 ID를 선택한 뒤, “선택 화면 열기”를 누르세요.</p></div></template>
|
||||
</SearchListCrudPage>
|
||||
</template>
|
||||
<style scoped>.filters{display:grid;grid-template-columns:2fr 1fr;gap:var(--ks-space-3)}.summary,.detail,.component-preview{padding:var(--ks-space-4)}.summary{display:grid;gap:var(--ks-space-1)}.summary strong{font-size:1.5rem}.detail h2,.component-preview h2{margin-top:0;font-size:var(--ks-font-section)}.preview-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--ks-space-4)}.preview-grid>div{display:grid;align-content:start;gap:var(--ks-space-2);padding:var(--ks-space-3);border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.preview-grid h3{margin:0;font-size:var(--ks-font-caption)}.status-row{display:flex;flex-wrap:wrap;gap:var(--ks-space-2)}@media(max-width:700px){.filters,.preview-grid{grid-template-columns:1fr}}</style>
|
||||
<style scoped>.filters{display:grid;grid-template-columns:2fr 1fr 1fr;gap:var(--ks-space-3)}.summary,.detail,.component-preview{padding:var(--ks-space-4)}.summary{display:grid;gap:var(--ks-space-1)}.summary strong{font-size:1.5rem}.detail h2,.component-preview h2{margin-top:0;font-size:var(--ks-font-section)}.preview-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--ks-space-4)}.preview-grid>div{display:grid;align-content:start;gap:var(--ks-space-2);padding:var(--ks-space-3);border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.preview-grid h3{margin:0;font-size:var(--ks-font-caption)}.status-row{display:flex;flex-wrap:wrap;gap:var(--ks-space-2)}@media(max-width:700px){.filters,.preview-grid{grid-template-columns:1fr}}</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { buildNavigationEntries, groupByModule } from './navigationCatalog'
|
||||
import { useScreenPreferenceStore } from './screenPreferenceStore'
|
||||
import { useWorkspaceStore, type WorkspaceTab } from './workspaceStore'
|
||||
import KsGlobalHeader from './KsGlobalHeader.vue'
|
||||
import KsSideNavigation from './KsSideNavigation.vue'
|
||||
import KsWorkspaceTabs from './KsWorkspaceTabs.vue'
|
||||
import KsMenuSearch from './KsMenuSearch.vue'
|
||||
import type { NavigationEntry } from './navigationCatalog'
|
||||
|
||||
defineProps<{ productName?: string; environment?: string; automationStatus?: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const preference = useScreenPreferenceStore()
|
||||
const workspace = useWorkspaceStore()
|
||||
const collapsed = ref(false)
|
||||
const menuSearchOpen = ref(false)
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION ?? '0.1.0'
|
||||
|
||||
const entries = () => buildNavigationEntries(router.getRoutes()).filter(entry => !entry.internalOnly)
|
||||
const sections = () => groupByModule(entries())
|
||||
const favorites = () => preference.favoriteScreenIds
|
||||
.map(id => entries().find(entry => entry.screenId === id))
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
||||
const activeTabKey = () => {
|
||||
const screenId = route.meta.screenId
|
||||
return typeof screenId === 'string' ? `${screenId}:${route.path}` : null
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
const meta = route.meta
|
||||
if (typeof meta.screenId !== 'string' || meta.module === 'Home') return
|
||||
const title = typeof meta.title === 'string' ? meta.title : route.path
|
||||
preference.recordVisit(meta.screenId, route.path)
|
||||
workspace.open(meta.screenId, route.path, title)
|
||||
}, { immediate: true })
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
}
|
||||
function openMenuSearch() {
|
||||
menuSearchOpen.value = true
|
||||
}
|
||||
function selectEntry(entry: NavigationEntry) {
|
||||
menuSearchOpen.value = false
|
||||
router.push(entry.path)
|
||||
}
|
||||
function selectTab(tab: WorkspaceTab) {
|
||||
router.push(tab.path)
|
||||
}
|
||||
function closeTab(tab: WorkspaceTab) {
|
||||
const wasActive = activeTabKey() === `${tab.screenId}:${tab.path}`
|
||||
workspace.close(tab)
|
||||
if (wasActive) {
|
||||
const next = workspace.tabs.at(-1)
|
||||
router.push(next ? next.path : '/home')
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
menuSearchOpen.value = true
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ks-app-shell">
|
||||
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
|
||||
<KsGlobalHeader :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" />
|
||||
<KsWorkspaceTabs :tabs="workspace.visibleTabs" :overflow-count="workspace.overflowCount" :active-key="activeTabKey()" @select="selectTab" @close="closeTab" @toggle-pin="workspace.togglePin" />
|
||||
<div class="ks-app-shell__body">
|
||||
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" @toggle-collapsed="collapsed = !collapsed" />
|
||||
<main id="ks-main" class="ks-app-shell__main" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<footer class="ks-app-shell__footer">RESEARCH_CANDIDATE_NOT_PRODUCTION</footer>
|
||||
<div class="ks-app-shell__version" data-testid="app-version" aria-label="애플리케이션 버전">v{{ appVersion }} · UI contract 4.0</div>
|
||||
<KsMenuSearch :open="menuSearchOpen" :entries="entries()" @close="menuSearchOpen = false" @select="selectEntry" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr auto; background: var(--ks-color-canvas); }
|
||||
.ks-app-shell__body { display: flex; min-height: 0; }
|
||||
.ks-app-shell__main { flex: 1; min-width: 0; padding: var(--ks-space-6); overflow: auto; }
|
||||
.ks-app-shell__footer { padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-border); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-app-shell__version { position: fixed; right: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: rgb(255 255 255 / 92%); color: var(--ks-color-text-muted); font-size: .7rem; box-shadow: var(--ks-shadow-sm); }
|
||||
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: var(--ks-color-surface); }
|
||||
.ks-skip:focus { top: var(--ks-space-2); }
|
||||
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } }
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ productName?: string; environment?: string; automationStatus?: string }>(), {
|
||||
productName: 'K-ArtSell Aegis',
|
||||
environment: 'IMPLEMENTATION_TEMPLATE',
|
||||
automationStatus: '투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF'
|
||||
})
|
||||
const emit = defineEmits<{ home: []; menuSearch: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="ks-global-header">
|
||||
<button type="button" class="ks-global-header__brand" @click="emit('home')">
|
||||
<strong>{{ productName }}</strong><small>{{ environment }}</small>
|
||||
</button>
|
||||
<button type="button" class="ks-global-header__search" aria-keyshortcuts="Control+K" @click="emit('menuSearch')">
|
||||
<span>메뉴명 · 화면코드 · 업무명 검색</span><kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
<div class="ks-global-header__status" role="status">{{ automationStatus }}</div>
|
||||
<slot name="actions" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-global-header { height: var(--ks-shell-header-height); display: flex; align-items: center; gap: var(--ks-space-4); padding: 0 var(--ks-space-4); background: var(--ks-color-neutral-950); color: var(--ks-color-text-on-dark); }
|
||||
.ks-global-header__brand { display: grid; gap: 0; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.ks-global-header__brand small { color: #cbd5e1; font-size: var(--ks-font-caption); }
|
||||
.ks-global-header__search { flex: 1; max-width: 28rem; height: 2rem; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-2); padding: 0 var(--ks-space-3); border: 1px solid var(--ks-color-neutral-700); border-radius: var(--ks-radius-sm); background: var(--ks-color-neutral-800); color: var(--ks-color-neutral-300); }
|
||||
.ks-global-header__search kbd { font-size: var(--ks-font-caption); border: 1px solid var(--ks-color-neutral-600); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-1); }
|
||||
.ks-global-header__status { margin-left: auto; padding: var(--ks-space-1) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; font-size: var(--ks-font-caption); white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import KsDialog from '../ui/components/KsDialog.vue'
|
||||
import type { NavigationEntry } from './navigationCatalog'
|
||||
|
||||
const props = defineProps<{ open: boolean; entries: NavigationEntry[] }>()
|
||||
const emit = defineEmits<{ close: []; select: [entry: NavigationEntry] }>()
|
||||
|
||||
const query = ref('')
|
||||
const activeIndex = ref(0)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const results = computed<NavigationEntry[]>(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
const source = q
|
||||
? props.entries.filter(entry => entry.title.toLowerCase().includes(q) || entry.screenId.toLowerCase().includes(q) || entry.module.toLowerCase().includes(q))
|
||||
: props.entries
|
||||
return source.slice(0, 20)
|
||||
})
|
||||
|
||||
watch(() => props.open, async value => {
|
||||
if (!value) return
|
||||
query.value = ''
|
||||
activeIndex.value = 0
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
watch(results, () => { activeIndex.value = 0 })
|
||||
|
||||
function move(delta: number) {
|
||||
if (!results.value.length) return
|
||||
activeIndex.value = (activeIndex.value + delta + results.value.length) % results.value.length
|
||||
}
|
||||
function selectActive() {
|
||||
const entry = results.value[activeIndex.value]
|
||||
if (entry) emit('select', entry)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KsDialog :visible="props.open" title="메뉴 검색" modal closable @update:visible="value => { if (!value) emit('close') }">
|
||||
<div class="ks-menu-search" role="presentation" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="selectActive" @keydown.esc="emit('close')">
|
||||
<input ref="inputRef" v-model="query" type="text" placeholder="메뉴명 · 화면코드 · 업무명 검색" aria-label="메뉴 검색" />
|
||||
<ul role="listbox" aria-label="검색 결과">
|
||||
<li
|
||||
v-for="(entry, index) in results"
|
||||
:key="entry.screenId"
|
||||
role="option"
|
||||
:aria-selected="index === activeIndex"
|
||||
:class="{ active: index === activeIndex }"
|
||||
@mouseenter="activeIndex = index"
|
||||
@click="emit('select', entry)"
|
||||
>
|
||||
<span>{{ entry.title }}</span><small>{{ entry.module }} · {{ entry.screenId }}</small>
|
||||
</li>
|
||||
<li v-if="!results.length" class="empty">검색 결과가 없습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</KsDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-menu-search { display: grid; gap: var(--ks-space-2); min-width: 24rem; }
|
||||
.ks-menu-search input { height: var(--ks-control-height); padding: 0 var(--ks-space-3); border: 1px solid var(--ks-color-border-strong); border-radius: var(--ks-radius-sm); font-size: var(--ks-font-body); }
|
||||
.ks-menu-search ul { list-style: none; margin: 0; padding: 0; max-height: 20rem; overflow-y: auto; display: grid; gap: 1px; }
|
||||
.ks-menu-search li { display: flex; justify-content: space-between; gap: var(--ks-space-2); padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); cursor: pointer; }
|
||||
.ks-menu-search li.active { background: var(--ks-color-neutral-100); }
|
||||
.ks-menu-search li small { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); white-space: nowrap; }
|
||||
.ks-menu-search li.empty { color: var(--ks-color-text-muted); cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { NavigationEntry, NavigationSection } from './navigationCatalog'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sections: NavigationSection[]
|
||||
favorites?: NavigationEntry[]
|
||||
activePath?: string
|
||||
collapsed?: boolean
|
||||
}>(), { favorites: () => [], activePath: '', collapsed: false })
|
||||
const emit = defineEmits<{ toggleCollapsed: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="ks-side-nav" :class="{ 'ks-side-nav--collapsed': props.collapsed }" aria-label="주요 메뉴">
|
||||
<button type="button" class="ks-side-nav__toggle" :aria-expanded="!props.collapsed" @click="emit('toggleCollapsed')">
|
||||
{{ props.collapsed ? '»' : '« 접기' }}
|
||||
</button>
|
||||
<template v-if="!props.collapsed">
|
||||
<section v-if="favorites.length" class="ks-side-nav__section">
|
||||
<h2>즐겨찾기</h2>
|
||||
<RouterLink v-for="entry in favorites" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }">★ {{ entry.title }}</RouterLink>
|
||||
</section>
|
||||
<section v-for="section in sections" :key="section.module" class="ks-side-nav__section">
|
||||
<h2>{{ section.module }}</h2>
|
||||
<RouterLink v-for="entry in section.entries" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }">{{ entry.title }}</RouterLink>
|
||||
</section>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-side-nav { width: var(--ks-shell-sidenav-width); flex-shrink: 0; display: grid; gap: var(--ks-space-3); align-content: start; padding: var(--ks-space-3) var(--ks-space-2); border-right: 1px solid var(--ks-color-border); background: var(--ks-color-surface); overflow-y: auto; }
|
||||
.ks-side-nav--collapsed { width: var(--ks-shell-sidenav-collapsed); padding: var(--ks-space-3) var(--ks-space-1); }
|
||||
.ks-side-nav__toggle { justify-self: end; border: 0; background: transparent; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); cursor: pointer; }
|
||||
.ks-side-nav__section { display: grid; gap: var(--ks-space-1); }
|
||||
.ks-side-nav__section h2 { margin: 0 var(--ks-space-2); font-size: var(--ks-font-caption); font-weight: 600; color: var(--ks-color-text-muted); text-transform: uppercase; }
|
||||
.ks-side-nav__section a { padding: var(--ks-space-2) var(--ks-space-2); border-radius: var(--ks-radius-sm); text-decoration: none; color: var(--ks-color-text); font-size: var(--ks-font-body); }
|
||||
.ks-side-nav__section a.active, .ks-side-nav__section a.router-link-active { background: var(--ks-color-neutral-100); font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import KsDialog from '../ui/components/KsDialog.vue'
|
||||
import type { WorkspaceTab } from './workspaceStore'
|
||||
|
||||
const props = defineProps<{ tabs: WorkspaceTab[]; overflowCount?: number; activeKey?: string | null }>()
|
||||
const emit = defineEmits<{ select: [tab: WorkspaceTab]; close: [tab: WorkspaceTab]; togglePin: [tab: WorkspaceTab] }>()
|
||||
|
||||
const pendingClose = ref<WorkspaceTab | null>(null)
|
||||
|
||||
function requestClose(tab: WorkspaceTab) {
|
||||
if (tab.dirty) pendingClose.value = tab
|
||||
else emit('close', tab)
|
||||
}
|
||||
function confirmDiscard() {
|
||||
if (pendingClose.value) emit('close', pendingClose.value)
|
||||
pendingClose.value = null
|
||||
}
|
||||
function cancelClose() {
|
||||
pendingClose.value = null
|
||||
}
|
||||
function keyOf(tab: WorkspaceTab): string {
|
||||
return `${tab.screenId}:${tab.path}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="ks-workspace-tabs" :class="{ 'ks-workspace-tabs--empty': !props.tabs.length }" aria-label="열린 업무">
|
||||
<div
|
||||
v-for="tab in props.tabs"
|
||||
:key="keyOf(tab)"
|
||||
class="ks-workspace-tabs__tab"
|
||||
:class="{ active: keyOf(tab) === props.activeKey }"
|
||||
>
|
||||
<button type="button" class="select" @click="emit('select', tab)">
|
||||
<span v-if="tab.dirty" class="dot" aria-hidden="true">●</span>
|
||||
<span class="title">{{ tab.title }}</span>
|
||||
</button>
|
||||
<button type="button" class="pin" :aria-pressed="tab.pinned" :aria-label="tab.pinned ? '고정 해제' : '탭 고정'" @click="emit('togglePin', tab)">{{ tab.pinned ? '📌' : '📍' }}</button>
|
||||
<button type="button" class="close" aria-label="탭 닫기" @click="requestClose(tab)">×</button>
|
||||
</div>
|
||||
<span v-if="(props.overflowCount ?? 0) > 0" class="ks-workspace-tabs__overflow">더보기 {{ props.overflowCount }}</span>
|
||||
|
||||
<KsDialog :visible="Boolean(pendingClose)" title="저장하지 않은 변경사항" modal closable @update:visible="value => { if (!value) cancelClose() }">
|
||||
<p>저장하지 않은 변경사항이 있습니다. 계속 진행하면 변경 내용이 사라집니다.</p>
|
||||
<template #footer>
|
||||
<button type="button" @click="cancelClose">계속 편집</button>
|
||||
<button type="button" @click="confirmDiscard">변경 버리기</button>
|
||||
</template>
|
||||
</KsDialog>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-workspace-tabs { height: var(--ks-shell-tabs-height); display: flex; align-items: stretch; gap: 1px; padding: 0 var(--ks-space-2); border-bottom: 1px solid var(--ks-color-border); background: var(--ks-color-surface); overflow-x: auto; }
|
||||
.ks-workspace-tabs--empty { height: 0; padding: 0; border-bottom: 0; overflow: hidden; }
|
||||
.ks-workspace-tabs__tab { display: flex; align-items: center; gap: var(--ks-space-1); padding: 0 var(--ks-space-1); border-bottom: 2px solid transparent; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); white-space: nowrap; }
|
||||
.ks-workspace-tabs__tab.active { color: var(--ks-color-text); border-bottom-color: var(--ks-color-action); font-weight: 600; }
|
||||
.ks-workspace-tabs__tab .select { display: flex; align-items: center; gap: var(--ks-space-1); border: 0; background: transparent; color: inherit; font: inherit; padding: 0 var(--ks-space-1); cursor: pointer; }
|
||||
.ks-workspace-tabs__tab .dot { color: var(--ks-color-warning); }
|
||||
.ks-workspace-tabs__tab .pin, .ks-workspace-tabs__tab .close { border: 0; background: transparent; color: inherit; padding: 0 var(--ks-space-1); cursor: pointer; }
|
||||
.ks-workspace-tabs__overflow { align-self: center; padding: 0 var(--ks-space-2); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface NavigationRouteLike {
|
||||
path: string
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface NavigationEntry {
|
||||
screenId: string
|
||||
path: string
|
||||
module: string
|
||||
section: string
|
||||
title: string
|
||||
order: number
|
||||
favoriteAllowed: boolean
|
||||
internalOnly: boolean
|
||||
}
|
||||
|
||||
export interface NavigationSection {
|
||||
module: string
|
||||
entries: NavigationEntry[]
|
||||
}
|
||||
|
||||
function toEntry(route: NavigationRouteLike): NavigationEntry | null {
|
||||
const meta = route.meta
|
||||
if (!meta || typeof meta.screenId !== 'string' || typeof meta.module !== 'string') return null
|
||||
if (meta.module === 'Home') return null
|
||||
return {
|
||||
screenId: meta.screenId,
|
||||
path: route.path,
|
||||
module: meta.module,
|
||||
section: typeof meta.section === 'string' ? meta.section : meta.module,
|
||||
title: typeof meta.title === 'string' ? meta.title : route.path,
|
||||
order: typeof meta.order === 'number' ? meta.order : 0,
|
||||
favoriteAllowed: meta.favoriteAllowed !== false,
|
||||
internalOnly: meta.internalOnly === true
|
||||
}
|
||||
}
|
||||
|
||||
/** router.getRoutes()의 flat route 목록에서 즐겨찾기/검색/사이드바가 공유하는 단일 카탈로그를 파생한다. */
|
||||
export function buildNavigationEntries(routes: readonly NavigationRouteLike[]): NavigationEntry[] {
|
||||
const entries = routes.map(toEntry).filter((entry): entry is NavigationEntry => entry !== null)
|
||||
return entries.sort((a, b) => a.module.localeCompare(b.module) || a.order - b.order)
|
||||
}
|
||||
|
||||
export function groupByModule(entries: readonly NavigationEntry[]): NavigationSection[] {
|
||||
const byModule = new Map<string, NavigationEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const list = byModule.get(entry.module) ?? []
|
||||
list.push(entry)
|
||||
byModule.set(entry.module, list)
|
||||
}
|
||||
return [...byModule.entries()].map(([module, list]) => ({ module, entries: [...list].sort((a, b) => a.order - b.order) }))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface RecentNavigation {
|
||||
screenId: string
|
||||
path: string
|
||||
visitedAt: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ks.shell.screenPreference.v1'
|
||||
const MAX_RECENTS = 10
|
||||
const MAX_FAVORITES = 20
|
||||
|
||||
interface PersistedShape {
|
||||
favoriteScreenIds: string[]
|
||||
recents: RecentNavigation[]
|
||||
}
|
||||
|
||||
function readPersisted(): PersistedShape {
|
||||
if (typeof window === 'undefined') return { favoriteScreenIds: [], recents: [] }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { favoriteScreenIds: [], recents: [] }
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedShape>
|
||||
return {
|
||||
favoriteScreenIds: Array.isArray(parsed.favoriteScreenIds) ? parsed.favoriteScreenIds.slice(0, MAX_FAVORITES) : [],
|
||||
recents: Array.isArray(parsed.recents) ? parsed.recents.slice(0, MAX_RECENTS) : []
|
||||
}
|
||||
} catch {
|
||||
return { favoriteScreenIds: [], recents: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export const useScreenPreferenceStore = defineStore('ks-screen-preference', {
|
||||
state: () => readPersisted(),
|
||||
actions: {
|
||||
persist() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ favoriteScreenIds: this.favoriteScreenIds, recents: this.recents }))
|
||||
},
|
||||
toggleFavorite(screenId: string) {
|
||||
const index = this.favoriteScreenIds.indexOf(screenId)
|
||||
if (index >= 0) this.favoriteScreenIds.splice(index, 1)
|
||||
else this.favoriteScreenIds = [screenId, ...this.favoriteScreenIds].slice(0, MAX_FAVORITES)
|
||||
this.persist()
|
||||
},
|
||||
isFavorite(screenId: string): boolean {
|
||||
return this.favoriteScreenIds.includes(screenId)
|
||||
},
|
||||
recordVisit(screenId: string, path: string) {
|
||||
const withoutCurrent = this.recents.filter(entry => entry.screenId !== screenId)
|
||||
this.recents = [{ screenId, path, visitedAt: new Date().toISOString() }, ...withoutCurrent].slice(0, MAX_RECENTS)
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import KsAppShell from '../KsAppShell.vue'
|
||||
|
||||
function buildTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/home' },
|
||||
{ path: '/home', component: { template: '<div>home</div>' }, meta: { screenId: 'SCR-000', module: 'Home', title: '홈' } },
|
||||
{ path: '/research/sell-decision', component: { template: '<div>sell</div>' }, meta: { screenId: 'SCR-002', module: 'Research', section: 'Research', title: '매도 의사결정', order: 1, favoriteAllowed: true } }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('KsAppShell contract', () => {
|
||||
it('provides skip navigation, header, side navigation, and workspace tab landmarks', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = buildTestRouter()
|
||||
router.push('/research/sell-decision')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(KsAppShell, { global: { plugins: [pinia, router], stubs: { KsDialog: true } }, slots: { default: '<p>화면 내용</p>' } })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.get('.ks-skip').attributes('href')).toBe('#ks-main')
|
||||
expect(wrapper.find('header.ks-global-header').exists()).toBe(true)
|
||||
expect(wrapper.get('aside').attributes('aria-label')).toBe('주요 메뉴')
|
||||
expect(wrapper.get('nav.ks-workspace-tabs').attributes('aria-label')).toBe('열린 업무')
|
||||
expect(wrapper.get('main').attributes('tabindex')).toBe('-1')
|
||||
expect(wrapper.text()).toContain('화면 내용')
|
||||
expect(wrapper.text()).toContain('RESEARCH_CANDIDATE_NOT_PRODUCTION')
|
||||
})
|
||||
|
||||
it('opens a workspace tab for the active route and records it as a recent screen', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = buildTestRouter()
|
||||
router.push('/research/sell-decision')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(KsAppShell, { global: { plugins: [pinia, router], stubs: { KsDialog: true } } })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('매도 의사결정')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildNavigationEntries, groupByModule } from '../navigationCatalog'
|
||||
|
||||
describe('navigation catalogue', () => {
|
||||
it('includes the read-only component catalogue in the Design System menu', () => {
|
||||
const entries = buildNavigationEntries([
|
||||
{
|
||||
path: '/internal/ui-standard',
|
||||
meta: {
|
||||
screenId: 'SCR-DEV-001',
|
||||
module: 'Design System',
|
||||
section: 'Design System',
|
||||
title: '컴포넌트 확인',
|
||||
order: 1,
|
||||
favoriteAllowed: false,
|
||||
internalOnly: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/internal/wbs',
|
||||
meta: { screenId: 'SCR-DEV-002', module: 'Internal', title: 'WBS 작업공간', internalOnly: true }
|
||||
}
|
||||
])
|
||||
|
||||
expect(groupByModule(entries).find(section => section.module === 'Design System')?.entries).toEqual([
|
||||
expect.objectContaining({ path: '/internal/ui-standard', title: '컴포넌트 확인', internalOnly: false })
|
||||
])
|
||||
expect(entries.find(entry => entry.path === '/internal/wbs')?.internalOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface WorkspaceTab {
|
||||
screenId: string
|
||||
path: string
|
||||
title: string
|
||||
pinned: boolean
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_TABS = 10
|
||||
|
||||
function tabKey(screenId: string, path: string): string {
|
||||
return `${screenId}:${path}`
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = defineStore('ks-workspace', {
|
||||
state: () => ({ tabs: [] as WorkspaceTab[], activeKey: null as string | null }),
|
||||
getters: {
|
||||
visibleTabs: state => state.tabs.slice(0, MAX_VISIBLE_TABS),
|
||||
overflowTabs: state => state.tabs.slice(MAX_VISIBLE_TABS),
|
||||
overflowCount: state => Math.max(state.tabs.length - MAX_VISIBLE_TABS, 0)
|
||||
},
|
||||
actions: {
|
||||
/** 화면 진입 시 호출. 이미 열려 있으면 새 가상 상태를 만들지 않고 activeKey만 갱신한다. */
|
||||
open(screenId: string, path: string, title: string) {
|
||||
const key = tabKey(screenId, path)
|
||||
if (!this.tabs.some(tab => tabKey(tab.screenId, tab.path) === key)) {
|
||||
this.tabs.push({ screenId, path, title, pinned: false, dirty: false })
|
||||
}
|
||||
this.activeKey = key
|
||||
},
|
||||
/** dirty 여부와 무관하게 실제로 탭을 제거한다. dirty guard 확인은 호출자(KsWorkspaceTabs) 책임. */
|
||||
close(tab: WorkspaceTab) {
|
||||
const key = tabKey(tab.screenId, tab.path)
|
||||
this.tabs = this.tabs.filter(t => tabKey(t.screenId, t.path) !== key)
|
||||
if (this.activeKey === key) {
|
||||
const last = this.tabs.at(-1)
|
||||
this.activeKey = last ? tabKey(last.screenId, last.path) : null
|
||||
}
|
||||
},
|
||||
togglePin(tab: WorkspaceTab) {
|
||||
const found = this.tabs.find(t => t.screenId === tab.screenId && t.path === tab.path)
|
||||
if (found) found.pinned = !found.pinned
|
||||
},
|
||||
setDirty(screenId: string, path: string, dirty: boolean) {
|
||||
const found = this.tabs.find(t => t.screenId === screenId && t.path === path)
|
||||
if (found) found.dirty = dirty
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
/* PrimeVue v4 is intentionally installed in unstyled mode. Vendor DOM classes stay in this adapter boundary. */
|
||||
.p-dialog-mask { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: var(--ks-space-4); background: rgb(15 23 42 / 48%); }
|
||||
.p-dialog.ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
|
||||
.p-dialog.ks-dialog .p-dialog-header, .p-dialog.ks-dialog .p-dialog-content, .p-dialog.ks-dialog .p-dialog-footer { padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog .p-dialog-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--ks-color-neutral-200); font-weight: 700; }
|
||||
.p-dialog.ks-dialog .p-dialog-footer { display: flex; justify-content: flex-end; gap: var(--ks-space-2); border-top: 1px solid var(--ks-color-neutral-200); }
|
||||
/* PrimeVue 4.5.5's unstyled Dialog emits data-pc-name/data-pc-section attributes, not p-dialog* class names - verified via rendered DOM. */
|
||||
[data-pc-section="mask"]:has(> [data-pc-name="dialog"]) { background: rgb(15 23 42 / 48%); padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog, [data-pc-name="dialog"][data-pc-section="root"].ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
|
||||
.p-dialog.ks-dialog .p-dialog-header, .p-dialog.ks-dialog .p-dialog-content, .p-dialog.ks-dialog .p-dialog-footer,
|
||||
.ks-dialog [data-pc-section="header"], .ks-dialog [data-pc-section="content"], .ks-dialog [data-pc-section="footer"] { padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog .p-dialog-header, .ks-dialog [data-pc-section="header"] { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--ks-color-neutral-200); font-weight: 700; }
|
||||
.p-dialog.ks-dialog .p-dialog-footer, .ks-dialog [data-pc-section="footer"] { display: flex; justify-content: flex-end; gap: var(--ks-space-2); border-top: 1px solid var(--ks-color-neutral-200); }
|
||||
.p-select-overlay { z-index: 1100; min-width: 12rem; overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-sm); background: #fff; box-shadow: var(--ks-shadow-md); }
|
||||
.p-select-list { margin: 0; padding: var(--ks-space-1); list-style: none; }
|
||||
.p-select-option { padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); cursor: pointer; }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
import FieldShell from './FieldShell.vue'
|
||||
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; currency?: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number }>(), { currency: 'KRW' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template>
|
||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
||||
<span class="ks-money-field">
|
||||
<component
|
||||
:is="adapter.components.NumberField"
|
||||
:input-id="field.inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="field.invalid"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="0"
|
||||
:aria-describedby="field.describedBy"
|
||||
:aria-required="field.required || undefined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<span class="ks-money-field__currency" aria-hidden="true">{{ props.currency }}</span>
|
||||
</span>
|
||||
</FieldShell>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-money-field { display: inline-flex; align-items: center; gap: var(--ks-space-2); }
|
||||
.ks-money-field__currency { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
import FieldShell from './FieldShell.vue'
|
||||
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; unit?: string; availableQuantity?: number; allowNegative?: boolean; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string }>(), { unit: '주', allowNegative: false })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
|
||||
const exceedsAvailable = computed(() =>
|
||||
props.modelValue != null && props.availableQuantity != null && props.modelValue > props.availableQuantity
|
||||
)
|
||||
const effectiveError = computed(() => props.error ?? (exceedsAvailable.value ? `가용 수량(${props.availableQuantity} ${props.unit})을 초과했습니다.` : undefined))
|
||||
const effectiveHelp = computed(() => props.availableQuantity != null ? `가용 ${props.availableQuantity} ${props.unit}` : props.help)
|
||||
</script>
|
||||
<template>
|
||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="effectiveError" :help="effectiveHelp" v-slot="field">
|
||||
<span class="ks-quantity-field">
|
||||
<component
|
||||
:is="adapter.components.NumberField"
|
||||
:input-id="field.inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="field.invalid"
|
||||
:min="allowNegative ? undefined : 0"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="0"
|
||||
:aria-describedby="field.describedBy"
|
||||
:aria-required="field.required || undefined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<span class="ks-quantity-field__unit" aria-hidden="true">{{ props.unit }}</span>
|
||||
</span>
|
||||
</FieldShell>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-quantity-field { display: inline-flex; align-items: center; gap: var(--ks-space-2); }
|
||||
.ks-quantity-field__unit { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); font-weight: 600; }
|
||||
</style>
|
||||
@@ -6,6 +6,8 @@ export { default as KsMultiSelect } from './KsMultiSelect.vue'
|
||||
export { default as KsCheckbox } from './KsCheckbox.vue'
|
||||
export { default as KsDateField } from './KsDateField.vue'
|
||||
export { default as KsNumberField } from './KsNumberField.vue'
|
||||
export { default as KsMoneyField } from './KsMoneyField.vue'
|
||||
export { default as KsQuantityField } from './KsQuantityField.vue'
|
||||
export { default as KsDialog } from './KsDialog.vue'
|
||||
export { default as KsStatusTag } from './KsStatusTag.vue'
|
||||
export { default as KsInlineMessage } from './KsInlineMessage.vue'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { uiAdapterKey } from '../../adapter/contracts'
|
||||
import { nativeUiAdapter } from '../../adapter/native'
|
||||
import KsMoneyField from '../KsMoneyField.vue'
|
||||
import KsQuantityField from '../KsQuantityField.vue'
|
||||
|
||||
const globalProvide = { global: { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } } }
|
||||
|
||||
describe('KsMoneyField', () => {
|
||||
it('connects the label, shows the currency code, and emits numeric updates', async () => {
|
||||
const wrapper = mount(KsMoneyField, { props: { modelValue: 1250000, label: '단가' }, ...globalProvide })
|
||||
expect(wrapper.get('label').text()).toContain('단가')
|
||||
expect(wrapper.text()).toContain('KRW')
|
||||
await wrapper.get('input').setValue('2000000')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([2000000])
|
||||
})
|
||||
|
||||
it('accepts a non-KRW currency code', () => {
|
||||
const wrapper = mount(KsMoneyField, { props: { modelValue: 100, label: '금액', currency: 'USD' }, ...globalProvide })
|
||||
expect(wrapper.text()).toContain('USD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('KsQuantityField', () => {
|
||||
it('shows the available quantity as help text when under the limit', () => {
|
||||
const wrapper = mount(KsQuantityField, { props: { modelValue: 5, label: '주문수량', unit: 'EA', availableQuantity: 8 }, ...globalProvide })
|
||||
expect(wrapper.text()).toContain('가용 8 EA')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('raises an accessible error when the value exceeds the available quantity', () => {
|
||||
const wrapper = mount(KsQuantityField, { props: { modelValue: 10, label: '주문수량', unit: 'EA', availableQuantity: 8 }, ...globalProvide })
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('가용 수량(8 EA)을 초과했습니다.')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
export const screenTemplateCatalogue = Object.freeze([
|
||||
{ id: 'T01', name: '검색·목록형 CRUD', component: 'SearchListCrudPage', intendedUse: '상품·고객·권한·데이터 Run·추천 검토함', mandatoryStates: ['READY', 'LOADING', 'EMPTY', 'WARN', 'ERROR', 'UNAUTHORIZED', 'FORBIDDEN', 'PARTIAL'], mandatoryEvidence: ['filter-url', 'permission', 'export-auth'], antiPatterns: ['client-side-all-data', 'unbounded-export'] },
|
||||
{ id: 'T02', name: '상세 조회형', component: 'DetailReadPage', intendedUse: '투자제안·상품·포트폴리오·백테스트 결과', mandatoryStates: ['LOADING', 'WARN', 'EXPIRED', 'READONLY', 'UNAUTHORIZED'], mandatoryEvidence: ['as-of', 'version-set', 'audit'], antiPatterns: ['mutable-evidence', 'hidden-version'] },
|
||||
{ id: 'T03', name: '등록·편집 Form', component: 'EditFormPage', intendedUse: '고객·IPS·비용표·권한·설정', mandatoryStates: ['DIRTY', 'CONFLICT', 'PROCESSING', 'ERROR'], mandatoryEvidence: ['zod', 'if-match', 'idempotency-key'], antiPatterns: ['silent-overwrite', 'pinia-form-cache'] },
|
||||
{ id: 'T04', name: 'Master-Detail', component: 'MasterDetailCrudPage', intendedUse: '고객-IPS·추천-항목·Watch-Stage·대사 Run-Break', mandatoryStates: ['LOADING', 'EMPTY', 'DIRTY', 'PARTIAL', 'CONFLICT'], mandatoryEvidence: ['route-selection', 'unsaved-guard', 'version'], antiPatterns: ['selection-only-local', 'detail-n-plus-one'] },
|
||||
{ id: 'T05', name: '검토·승인 Workbench', component: 'ApprovalWorkbenchPage', intendedUse: '추천·모델·정정·대사 maker-checker', mandatoryStates: ['WARN', 'EXPIRED', 'CONFLICT', 'PROCESSING', 'READONLY'], mandatoryEvidence: ['maker-checker', 'reason', 'warning-ack'], antiPatterns: ['self-approval', 'approval-without-evidence'] },
|
||||
{ id: 'T06', name: '단계 Wizard', component: 'StepWizardPage', intendedUse: '고객 온보딩·IPS·리밸런싱·Backfill', mandatoryStates: ['DIRTY', 'ERROR', 'PROCESSING', 'READONLY'], mandatoryEvidence: ['resume', 'branch', 'impact-revalidation'], antiPatterns: ['single-huge-form', 'skip-validation'] },
|
||||
{ id: 'T07', name: 'Dashboard·Scorecard', component: 'ScorecardDashboardPage', intendedUse: '고객 대시보드·일평가·운영 SLO', mandatoryStates: ['LOADING', 'EMPTY', 'WARN', 'PARTIAL'], mandatoryEvidence: ['metric-definition', 'sample-size', 'table-alternative'], antiPatterns: ['chart-only', 'metric-definition-hidden'] },
|
||||
{ id: 'T08', name: 'Batch·데이터 운영', component: 'BatchOperationsPageV2', intendedUse: '수집·Feature·추천·평가·Backfill', mandatoryStates: ['PROCESSING', 'WARN', 'ERROR', 'PARTIAL', 'READONLY'], mandatoryEvidence: ['job-run', 'watermark', 'replay-scope'], antiPatterns: ['blind-retry', 'overwrite-reprocess'] },
|
||||
{ id: 'T09', name: '대사·예외 처리', component: 'ReconciliationExceptionPage', intendedUse: 'KIS/원장 대사·데이터 격리·DQ 예외', mandatoryStates: ['WARN', 'CONFLICT', 'PROCESSING', 'READONLY'], mandatoryEvidence: ['before-after', 'correction', 'audit'], antiPatterns: ['direct-db-fix', 'delete-break'] },
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN', 'EXPIRED', 'READONLY', 'CONFLICT'], mandatoryEvidence: ['same-dataset-cost', 'gate-pack', 'rollback'], antiPatterns: ['auto-promotion', 'different-cohort-comparison'] }
|
||||
]);
|
||||
export function getScreenTemplate(id) {
|
||||
const template = screenTemplateCatalogue.find(x => x.id === id);
|
||||
if (!template)
|
||||
throw new Error(`Unknown screen template: ${id}`);
|
||||
return template;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ScreenTemplateId = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09' | 'T10'
|
||||
export type ScreenTemplateId = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09' | 'T10' | 'T11' | 'T12'
|
||||
|
||||
export interface ScreenTemplateDefinition {
|
||||
id: ScreenTemplateId
|
||||
@@ -20,7 +20,9 @@ export const screenTemplateCatalogue: readonly ScreenTemplateDefinition[] = Obje
|
||||
{ id: 'T07', name: 'Dashboard·Scorecard', component: 'ScorecardDashboardPage', intendedUse: '고객 대시보드·일평가·운영 SLO', mandatoryStates: ['LOADING','EMPTY','WARN','PARTIAL'], mandatoryEvidence: ['metric-definition','sample-size','table-alternative'], antiPatterns: ['chart-only','metric-definition-hidden'] },
|
||||
{ id: 'T08', name: 'Batch·데이터 운영', component: 'BatchOperationsPageV2', intendedUse: '수집·Feature·추천·평가·Backfill', mandatoryStates: ['PROCESSING','WARN','ERROR','PARTIAL','READONLY'], mandatoryEvidence: ['job-run','watermark','replay-scope'], antiPatterns: ['blind-retry','overwrite-reprocess'] },
|
||||
{ id: 'T09', name: '대사·예외 처리', component: 'ReconciliationExceptionPage', intendedUse: 'KIS/원장 대사·데이터 격리·DQ 예외', mandatoryStates: ['WARN','CONFLICT','PROCESSING','READONLY'], mandatoryEvidence: ['before-after','correction','audit'], antiPatterns: ['direct-db-fix','delete-break'] },
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN','EXPIRED','READONLY','CONFLICT'], mandatoryEvidence: ['same-dataset-cost','gate-pack','rollback'], antiPatterns: ['auto-promotion','different-cohort-comparison'] }
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN','EXPIRED','READONLY','CONFLICT'], mandatoryEvidence: ['same-dataset-cost','gate-pack','rollback'], antiPatterns: ['auto-promotion','different-cohort-comparison'] },
|
||||
{ id: 'T11', name: '대량 입력(Fast Grid Entry)', component: 'FastEntryGridPage', intendedUse: '임계값·파라미터·비용표 등 다건 일괄 입력', mandatoryStates: ['DIRTY','PROCESSING','ERROR','PARTIAL'], mandatoryEvidence: ['cell-level-validation','paste-audit','idempotency-key'], antiPatterns: ['silent-bulk-overwrite','unbounded-paste'] },
|
||||
{ id: 'T12', name: '작업 큐(Work Queue)', component: 'WorkQueuePage', intendedUse: 'SLA 큐 깊이·DQ 격리·대사 Break 등 예외구동 작업목록', mandatoryStates: ['LOADING','EMPTY','WARN','PARTIAL'], mandatoryEvidence: ['queue-depth-source','exception-count-definition'], antiPatterns: ['chart-only-no-actionable-list'] }
|
||||
])
|
||||
|
||||
export function getScreenTemplate(id: ScreenTemplateId): ScreenTemplateDefinition {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getScreenTemplate, screenTemplateCatalogue } from '../catalogue';
|
||||
describe('screen template catalogue', () => {
|
||||
it('defines all ten screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(10);
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(10);
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(10);
|
||||
});
|
||||
it('requires evidence and anti-pattern declarations for every template', () => {
|
||||
for (const template of screenTemplateCatalogue) {
|
||||
expect(template.mandatoryEvidence.length).toBeGreaterThan(0);
|
||||
expect(template.mandatoryStates.length).toBeGreaterThan(0);
|
||||
expect(template.antiPatterns.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
it('resolves a template by stable ID', () => {
|
||||
expect(getScreenTemplate('T05').component).toBe('ApprovalWorkbenchPage');
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import { getScreenTemplate, screenTemplateCatalogue } from '../catalogue'
|
||||
import type { StandardScreenState } from '../../contracts/screenContract'
|
||||
|
||||
describe('screen template catalogue', () => {
|
||||
it('defines all ten screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(10)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(10)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(10)
|
||||
it('defines all twelve screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(12)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(12)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(12)
|
||||
})
|
||||
|
||||
it('requires evidence and anti-pattern declarations for every template', () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="grid"/></div><div v-if="$slots.validationSummary" class="ks-card ks-section"><slot name="validationSummary"/></div></section></StandardScreenBoundary><template #summary><slot name="total"/></template></PageLayout></template>
|
||||
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="quickActions"/></template><template #summary><slot name="workSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section class="ks-stack"><div v-if="$slots.exceptionSummary" class="ks-card ks-section"><slot name="exceptionSummary"/></div><div class="ks-card ks-section"><slot name="queue"/></div></section></StandardScreenBoundary></PageLayout></template>
|
||||
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
|
||||
@@ -8,4 +8,6 @@ export { default as ScorecardDashboardPage } from './ScorecardDashboardPage.vue'
|
||||
export { default as BatchOperationsPageV2 } from './BatchOperationsPageV2.vue'
|
||||
export { default as ReconciliationExceptionPage } from './ReconciliationExceptionPage.vue'
|
||||
export { default as VersionGovernancePage } from './VersionGovernancePage.vue'
|
||||
export { default as FastEntryGridPage } from './FastEntryGridPage.vue'
|
||||
export { default as WorkQueuePage } from './WorkQueuePage.vue'
|
||||
export { default as StandardScreenBoundary } from './StandardScreenBoundary.vue'
|
||||
|
||||
Reference in New Issue
Block a user