V13-FE-005: Complete KBX v60 frontend components, T01-T12 screen recipes, and WBS progress tracker

This commit is contained in:
2026-08-15 19:48:38 +09:00
parent e0460e000d
commit 938ec1842a
31 changed files with 1783 additions and 818 deletions
@@ -1,9 +1,17 @@
<script setup lang="ts">
import { reactive, computed, ref, onMounted } from 'vue'
import SkeletonLoader from '../../../shared/ui/components/SkeletonLoader.vue'
import ErrorBoundary from '../../../shared/ui/components/ErrorBoundary.vue'
import ApprovalWorkbenchPage from '../../../shared/ui/screen-types/v2/ApprovalWorkbenchPage.vue'
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// Mock data
// KBX T05 Governance Audit & Maker-Checker State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:39:00Z',
version: 'v60-T05-Contract',
})
// Mock Approval Requests Data
const mockRequests = [
{
requestId: 'APR-2026-001',
@@ -32,20 +40,19 @@ const mockRequests = [
const requests = ref(mockRequests)
const selectedRequestId = ref(mockRequests[0].requestId)
const isLoading = ref(true)
const error = ref<string | null>(null)
const statusStats = ref({
pending: 1,
approved: 1,
rejected: 0,
})
const reviewComment = ref('')
const statusStats = computed(() => ({
pending: requests.value.filter(r => r.status === 'pending').length,
approved: requests.value.filter(r => r.status === 'approved').length,
rejected: requests.value.filter(r => r.status === 'rejected').length,
}))
onMounted(() => {
// Simulate API loading
screenState.value = 'LOADING'
setTimeout(() => {
isLoading.value = false
// error.value = 'Failed to load approval requests' // Uncomment to test error state
}, 1500)
screenState.value = 'READY'
}, 500)
})
const filterModel = reactive({
@@ -53,8 +60,6 @@ const filterModel = reactive({
action: '',
})
const reviewComment = ref('')
const filteredRequests = computed(() => {
return requests.value.filter(r => {
const statusMatch = !filterModel.status || r.status === filterModel.status
@@ -64,7 +69,7 @@ const filteredRequests = computed(() => {
})
const selectedRequest = computed(() =>
filteredRequests.value.find(r => r.requestId === selectedRequestId.value)
requests.value.find(r => r.requestId === selectedRequestId.value)
)
const selectRequest = (id: string) => {
@@ -81,15 +86,6 @@ const formatDate = (dateString: string) => {
})
}
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
'pending': 'var(--color-warning-500)',
'approved': 'var(--color-success-500)',
'rejected': 'var(--color-danger-500)',
}
return colors[status] || 'var(--color-neutral-500)'
}
const getActionLabel = (action: string) => {
const labels: Record<string, string> = {
'activate': 'Model Activation',
@@ -101,96 +97,110 @@ const getActionLabel = (action: string) => {
const canApprove = computed(() => selectedRequest.value?.status === 'pending')
const canReject = computed(() => selectedRequest.value?.status === 'pending')
const handleApprove = () => {
if (!selectedRequest.value) return
selectedRequest.value.status = 'approved'
selectedRequest.value.reviewerName = 'kjh2064'
selectedRequest.value.reviewedAt = new Date().toISOString()
selectedRequest.value.reviewComment = reviewComment.value || 'Maker-Checker 규칙에 따라 승인됨'
reviewComment.value = ''
}
const handleReject = () => {
if (!selectedRequest.value) return
selectedRequest.value.status = 'rejected'
selectedRequest.value.reviewerName = 'kjh2064'
selectedRequest.value.reviewedAt = new Date().toISOString()
selectedRequest.value.reviewComment = reviewComment.value || '검증 기준 미달로 반려됨'
reviewComment.value = ''
}
const handleRetry = () => {
screenState.value = 'LOADING'
setTimeout(() => {
screenState.value = 'READY'
}, 400)
}
</script>
<template>
<div class="approval-queue">
<h1>Approval Queue</h1>
<!-- Summary Stats -->
<div class="stats">
<div class="stat stat-pending">
<span class="label">Pending</span>
<span class="value">{{ statusStats.pending }}</span>
</div>
<div class="stat stat-approved">
<span class="label">Approved</span>
<span class="value">{{ statusStats.approved }}</span>
</div>
<div class="stat stat-rejected">
<span class="label">Rejected</span>
<span class="value">{{ statusStats.rejected }}</span>
</div>
</div>
<!-- Loading State: Skeleton Loaders -->
<template v-if="isLoading">
<div class="filters">
<SkeletonLoader type="text" width="100%" height="36px" />
<SkeletonLoader type="text" width="100%" height="36px" />
</div>
<div class="content-skeleton">
<SkeletonLoader type="list" :rows="3" />
<ApprovalWorkbenchPage
title="승인 워크벤치 (Maker-Checker Approval Workbench)"
subtitle="모델 승격, 정책 변경 및 예외 조치 요청에 대한 Maker-Checker 거버넌스 승인을 수행합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<template #summary>
<div class="stats" role="toolbar" aria-label="승인 통계 요약">
<div class="stat stat-pending">
<span class="label">Pending</span>
<span class="value ks-financial-number">{{ statusStats.pending }}</span>
</div>
<div class="stat stat-approved">
<span class="label">Approved</span>
<span class="value ks-financial-number">{{ statusStats.approved }}</span>
</div>
<div class="stat stat-rejected">
<span class="label">Rejected</span>
<span class="value ks-financial-number">{{ statusStats.rejected }}</span>
</div>
</div>
</template>
<!-- Error State -->
<ErrorBoundary v-else-if="error" :fallback-message="error">
<div class="error-actions">
<button class="btn btn-primary" @click="error = null">Dismiss</button>
</div>
</ErrorBoundary>
<!-- Content -->
<template v-else>
<!-- Filters -->
<template #filters>
<div class="filters">
<select v-model="filterModel.status" class="input">
<select v-model="filterModel.status" class="input" aria-label="승인 상태 필터">
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="">All</option>
</select>
<select v-model="filterModel.action" class="input">
<select v-model="filterModel.action" class="input" aria-label="작업 유형 필터">
<option value="">All Actions</option>
<option value="activate">Activate Model</option>
<option value="transition-phase">Phase Transition</option>
<option value="retire">Retire Model</option>
</select>
</div>
</template>
<!-- Content -->
<div class="content">
<!-- Request List -->
<div class="request-list">
<!-- Default Slot: List & Detail Workbench Layout -->
<div class="content">
<!-- Request Master List -->
<aside class="request-list" aria-label="승인 요청 목록">
<h2>Requests ({{ filteredRequests.length }})</h2>
<div class="items">
<div class="items" role="list">
<div
v-for="req in filteredRequests"
:key="req.requestId"
class="request-item"
:class="{ 'is-selected': selectedRequestId === req.requestId }"
@click="selectRequest(req.requestId)"
role="listitem"
:aria-selected="selectedRequestId === req.requestId"
>
<div class="item-header">
<strong>{{ req.modelName }}</strong>
<span class="status-badge" :style="{ backgroundColor: getStatusColor(req.status) }">
{{ req.status }}
</span>
<KsStatusTag
:value="req.status.toUpperCase()"
:severity="req.status === 'approved' ? 'success' : req.status === 'rejected' ? 'danger' : 'warning'"
/>
</div>
<div class="item-action">{{ getActionLabel(req.action) }}</div>
<div class="item-meta">
<span>{{ req.requesterName }}</span>
<span>{{ formatDate(req.requestedAt) }}</span>
<span class="ks-financial-number">{{ formatDate(req.requestedAt) }}</span>
</div>
</div>
</div>
</div>
</aside>
<!-- Detail Panel -->
<div class="detail-panel">
<!-- Detail Workbench Panel -->
<main class="detail-panel" aria-label="선택 요청 상세 검토">
<h2 v-if="selectedRequest">{{ selectedRequest.modelName }}</h2>
<div v-else class="empty-detail">Select a request</div>
<div v-else class="empty-detail">Select a request from list</div>
<div v-if="selectedRequest" class="request-detail">
<!-- Request Info -->
@@ -199,7 +209,7 @@ const canReject = computed(() => selectedRequest.value?.status === 'pending')
<div class="detail-grid">
<div class="detail-item">
<span class="label">Request ID</span>
<span class="value">{{ selectedRequest.requestId }}</span>
<span class="value ks-financial-number">{{ selectedRequest.requestId }}</span>
</div>
<div class="detail-item">
<span class="label">Action</span>
@@ -211,7 +221,7 @@ const canReject = computed(() => selectedRequest.value?.status === 'pending')
</div>
<div class="detail-item">
<span class="label">Requested At</span>
<span class="value">{{ formatDate(selectedRequest.requestedAt) }}</span>
<span class="value ks-financial-number">{{ formatDate(selectedRequest.requestedAt) }}</span>
</div>
</div>
</div>
@@ -222,26 +232,36 @@ const canReject = computed(() => selectedRequest.value?.status === 'pending')
<div class="metric-grid">
<div class="metric-card">
<div class="metric-label">PBO</div>
<div class="metric-value">{{ selectedRequest.metadata.pbo?.toFixed(2) }}%</div>
<div class="metric-value ks-financial-number">{{ selectedRequest.metadata.pbo?.toFixed(2) }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">DSR</div>
<div class="metric-value">{{ selectedRequest.metadata.dsr?.toFixed(2) }}%</div>
<div class="metric-value ks-financial-number">{{ selectedRequest.metadata.dsr?.toFixed(2) }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">OOS</div>
<div class="metric-value">{{ selectedRequest.metadata.oos?.toFixed(2) }}%</div>
<div class="metric-value ks-financial-number">{{ selectedRequest.metadata.oos?.toFixed(2) }}%</div>
</div>
</div>
</div>
<!-- Review Section (if pending) -->
<div v-if="canApprove || canReject" class="section">
<h3>Review & Approval</h3>
<textarea v-model="reviewComment" placeholder="Enter review comment..." class="textarea"></textarea>
<h3>Review & Approval (Maker-Checker Audit)</h3>
<textarea v-model="reviewComment" placeholder="Enter review comment for audit trail..." class="textarea" aria-label="승인 심사 의견"></textarea>
<div class="actions">
<button :disabled="!canApprove" class="btn btn-primary">Approve</button>
<button :disabled="!canReject" class="btn btn-danger">Reject</button>
<KsButton
label="Approve [승인]"
variant="primary"
:disabled="!canApprove"
@click="handleApprove"
/>
<KsButton
label="Reject [반려]"
variant="secondary"
:disabled="!canReject"
@click="handleReject"
/>
</div>
</div>
@@ -252,11 +272,12 @@ const canReject = computed(() => selectedRequest.value?.status === 'pending')
<div class="history-item">
<div class="history-header">
<strong>{{ selectedRequest.reviewerName }}</strong>
<span class="status-badge" :style="{ backgroundColor: getStatusColor(selectedRequest.status) }">
{{ selectedRequest.status }}
</span>
<KsStatusTag
:value="selectedRequest.status.toUpperCase()"
:severity="selectedRequest.status === 'approved' ? 'success' : 'danger'"
/>
</div>
<div class="history-date">{{ formatDate(selectedRequest.reviewedAt) }}</div>
<div class="history-date ks-financial-number">{{ formatDate(selectedRequest.reviewedAt) }}</div>
<div v-if="selectedRequest.reviewComment" class="history-comment">
{{ selectedRequest.reviewComment }}
</div>
@@ -264,10 +285,9 @@ const canReject = computed(() => selectedRequest.value?.status === 'pending')
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</main>
</div>
</ApprovalWorkbenchPage>
</template>
<style scoped>
@@ -377,10 +397,10 @@ h3 {
transition: all var(--transition-fast);
}
.input:focus {
.input:focus, .textarea:focus {
outline: none;
border-color: var(--color-input-focus);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
border-color: var(--ks-color-action);
box-shadow: 0 0 0 2px var(--ks-color-action);
}
.loading {
@@ -573,8 +593,8 @@ h3 {
.textarea:focus {
outline: none;
border-color: var(--color-input-focus);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
border-color: var(--ks-color-action);
box-shadow: 0 0 0 2px var(--ks-color-action);
}
.actions {
+215 -218
View File
@@ -1,254 +1,251 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
interface Module {
name: string
icon: string
description: string
screens: Array<{ label: string; path: string; icon: string }>
}
const modules: Module[] = [
{
name: 'Model Operations',
icon: '🤖',
description: 'Manage models, run shadow tests, and track performance',
screens: [
{ label: 'Shadow Run Queue', path: '/model-ops/shadow-run-jobs', icon: '⚡' },
{ label: 'Model List', path: '/model-ops/models-master', icon: '📊' },
],
},
{
name: 'Governance',
icon: '⚖️',
description: 'Approval workflows and compliance management',
screens: [{ label: 'Approval Queue', path: '/governance/approvals', icon: '✅' }],
},
// KBX v60 Exception-Driven Work Queue Metrics (§2.4 Exception Driven)
const workQueueSummary = [
{ label: '전체 모델 수', count: 42, status: 'normal', filter: 'all' },
{ label: 'Shadow Run 실시간', count: 6, status: 'running', filter: 'running' },
{ label: '승인 대기 (Maker-Checker)', count: 8, status: 'warning', filter: 'approval' },
{ label: 'PBO/DSR 검증 경고', count: 3, status: 'danger', filter: 'warning' },
{ label: '데이터 수집 예외', count: 2, status: 'danger', filter: 'exception' }
]
// KBX v60 Exception Items (처리 대상 13건 노출)
const exceptionItems = [
{ id: 'EX-2026-001', type: 'DSR/PBO 미달', model: 'Aegis-Core-Alpha-v1.4', module: '매도 의사결정', status: '경고', time: '10분 전', route: '/governance/approvals' },
{ id: 'EX-2026-002', type: 'Maker-Checker 승인대기', model: 'K-Trend-Sell-v2.1', module: '포트폴리오 리밸런싱', status: '대기', time: '25분 전', route: '/governance/approvals' },
{ id: 'EX-2026-003', type: 'KIS API 수집 오류', model: 'MarketData-KRX-Realtime', module: '시장 데이터 수집', status: '오류', time: '1시간 전', route: '/ops/market-data-history' },
{ id: 'EX-2026-004', type: 'Shadow Run 타임아웃', model: 'Shadow-Batch-Job-8492', module: 'Shadow Run', status: '오류', time: '2시간 전', route: '/model-ops/shadow-run-jobs' },
]
// KBX Quick System Rules (§2.1 Familiar First)
const operationalGuides = [
{ title: '단축키 가이드', desc: '조회 [F3], 저장 [F8], 메뉴 검색 [Ctrl+K] 키로 업무를 고속 수행합니다.' },
{ title: '감사 추적 보존', desc: '모든 데이터 및 모델 승인 변경은 수정/삭제 없이 Correction Event로 영구 보존됩니다.' },
{ title: '자동주문 차단', desc: '현재 KIS 실제 주문 제출 기능은 OFF 상태이며 오직 Shadow 평가 모드만 동작합니다.' },
]
// KBX v60 Command Action Handler (F3/F8)
const activeFilter = ref('all')
const handleFilter = (filterKey: string) => {
activeFilter.value = filterKey
}
</script>
<template>
<div class="home-page">
<!-- Hero Section -->
<header class="home-header">
<div class="hero-content">
<h1>K-ArtSell Aegis</h1>
<p class="tagline">Financial Advisory System</p>
<p class="description">Professional portfolio analysis, model validation, and governance management</p>
<div class="kbx-business-home">
<!-- KBX Page Header (§7: Height 48px) -->
<header class="kbx-page-header">
<div class="kbx-page-header__breadcrumb">K-ArtSell Aegis > > 업무 워크스페이스</div>
<div class="kbx-page-header__title-row">
<h1 class="kbx-page-header__title">업무 워크스페이스 (Work Queue & Reconcile)</h1>
<div class="kbx-page-header__actions">
<span class="kbx-badge kbx-badge--info">KBX v60 Standard</span>
<span class="kbx-badge kbx-badge--warning">자동주문/KIS OFF</span>
</div>
</div>
</header>
<!-- Modules Grid -->
<main class="home-content">
<section v-for="module in modules" :key="module.name" class="module-card">
<!-- Card Header -->
<div class="module-header">
<div class="module-title">
<span class="module-icon">{{ module.icon }}</span>
<h2>{{ module.name }}</h2>
</div>
<p class="module-description">{{ module.description }}</p>
</div>
<!-- KBX Command Bar (§5: Height 44px) -->
<div class="kbx-command-bar">
<div class="kbx-command-bar__group">
<button type="button" class="kbx-btn kbx-btn--primary">
<span class="kbx-btn__shortcut">[F3]</span> 새로고침
</button>
<button type="button" class="kbx-btn kbx-btn--secondary">
예외건만 보기
</button>
</div>
<div class="kbx-command-bar__group">
<RouterLink to="/model-ops/shadow-run-jobs" class="kbx-btn kbx-btn--secondary">
Shadow Run 실행
</RouterLink>
<RouterLink to="/governance/approvals" class="kbx-btn kbx-btn--secondary">
승인 이동
</RouterLink>
</div>
</div>
<!-- Screens Navigation -->
<nav class="screen-list">
<RouterLink
v-for="screen in module.screens"
:key="screen.path"
:to="screen.path"
class="screen-link"
>
<span class="screen-icon">{{ screen.icon }}</span>
<span class="screen-label">{{ screen.label }}</span>
<span class="arrow"></span>
</RouterLink>
</nav>
</section>
</main>
<!-- KBX Search Panel (§6: Dense 34px) -->
<div class="kbx-search-panel">
<div class="kbx-search-panel__row">
<div class="kbx-search-field">
<label for="home-search-date">조회기간</label>
<input id="home-search-date" type="text" value="2026-08-08 ~ 2026-08-15" class="kbx-input" readonly />
</div>
<div class="kbx-search-field">
<label for="home-search-module">업무모듈</label>
<select id="home-search-module" class="kbx-select">
<option value="all">전체 모듈</option>
<option value="research">Research / 매도</option>
<option value="model">ModelOps</option>
<option value="governance">Governance</option>
</select>
</div>
<div class="kbx-search-field">
<label for="home-search-keyword">통합검색</label>
<input id="home-search-keyword" type="text" placeholder="모델ID / 예외코드 / 담당자" class="kbx-input" />
</div>
<button type="button" class="kbx-btn kbx-btn--primary">조회 [F3]</button>
</div>
</div>
<!-- KBX Exception-Driven KPI Toolbar (KBX Reference Screen §11) -->
<div class="kbx-kpi-bar" role="toolbar" aria-label="예외 상태 요약">
<div
v-for="item in workQueueSummary"
:key="item.filter"
class="kbx-kpi-item kbx-card"
:class="[
`kbx-kpi-item--${item.status}`,
{ 'kbx-kpi-item--active': activeFilter === item.filter }
]"
@click="handleFilter(item.filter)"
>
<div class="kbx-kpi-item__label">{{ item.label }}</div>
<div class="kbx-kpi-item__count ks-financial-number">{{ item.count }}</div>
</div>
</div>
<!-- KBX Exception Work Queue Grid (T06 / T07 Reference Screen) -->
<section class="kbx-workspace-grid kbx-card">
<div class="kbx-section-header">
<h2 class="kbx-section-title"> 긴급 처리 필요 예외 (Exception Driven Work Queue)</h2>
<span class="kbx-section-count"> {{ exceptionItems.length }} 처리 필요</span>
</div>
<div class="kbx-table-container">
<table class="kbx-grid-table">
<thead>
<tr>
<th style="width: 40px;"><input type="checkbox" aria-label="전체 선택" /></th>
<th style="width: 120px;">예외ID</th>
<th style="width: 160px;">예외 유형</th>
<th>대상 모델</th>
<th style="width: 140px;">업무 모듈</th>
<th style="width: 80px;">상태</th>
<th style="width: 100px;">발생 시간</th>
<th style="width: 100px;">조치</th>
</tr>
</thead>
<tbody>
<tr v-for="ex in exceptionItems" :key="ex.id" class="kbx-grid-row">
<td><input type="checkbox" :aria-label="`${ex.id} 선택`" /></td>
<td class="ks-financial-number">{{ ex.id }}</td>
<td>
<span class="kbx-exception-tag" :class="ex.status === '오류' ? 'kbx-exception-tag--danger' : 'kbx-exception-tag--warning'">
{{ ex.type }}
</span>
</td>
<td class="kbx-model-name"><strong>{{ ex.model }}</strong></td>
<td>{{ ex.module }}</td>
<td>
<span class="kbx-status-badge" :class="`kbx-status-badge--${ex.status}`">{{ ex.status }}</span>
</td>
<td class="ks-financial-number">{{ ex.time }}</td>
<td>
<RouterLink :to="ex.route" class="kbx-btn kbx-btn--xs kbx-btn--secondary">
조치하기
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- KBX System Operational Rules (§2.1 Familiar First) -->
<section class="kbx-guide-grid">
<div v-for="guide in operationalGuides" :key="guide.title" class="kbx-guide-card kbx-card">
<h3 class="kbx-guide-title">📌 {{ guide.title }}</h3>
<p class="kbx-guide-desc">{{ guide.desc }}</p>
</div>
</section>
</div>
</template>
<style scoped>
.home-page {
padding: var(--spacing-6);
max-width: 1280px;
margin: 0 auto;
.kbx-business-home {
display: flex;
flex-direction: column;
gap: var(--ks-space-3);
background: var(--ks-color-canvas);
color: var(--ks-color-text);
font-family: Pretendard, system-ui, sans-serif;
}
/* Hero Section */
.home-header {
margin-bottom: var(--spacing-8);
padding: var(--spacing-6);
background: linear-gradient(135deg, var(--color-primary-50) 0%, var(--color-secondary-50) 100%);
border-radius: var(--border-radius-lg);
border: 1px solid var(--color-border-primary);
/* Page Header 48px (§7) */
.kbx-page-header {
height: var(--ks-shell-pageheader-height);
display: flex;
flex-direction: column;
justify-content: center;
border-bottom: 1px solid var(--ks-color-border);
}
.hero-content h1 {
.kbx-page-header__breadcrumb {
font-size: var(--ks-font-caption);
color: var(--ks-color-text-muted);
}
.kbx-page-header__title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.kbx-page-header__title {
font-size: var(--ks-font-page);
font-weight: 600;
margin: 0;
font-size: 3rem;
font-weight: 800;
background: linear-gradient(135deg, var(--color-primary-600), var(--color-secondary-600));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.tagline {
margin: var(--spacing-2) 0 var(--spacing-1) 0;
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
.kbx-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: var(--ks-radius-sm);
margin-left: 6px;
}
.description {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
.kbx-badge--info { background: var(--ks-color-neutral-100); color: var(--ks-color-info); }
.kbx-badge--warning { background: var(--ks-color-neutral-100); color: var(--ks-color-warning); }
.kbx-btn--primary {
background: var(--ks-color-action);
color: var(--ks-color-text-on-dark);
border-color: var(--ks-color-action);
}
/* Content Grid */
.home-content {
.kbx-status-badge--오류 { background: var(--ks-color-neutral-100); color: var(--ks-color-danger); }
.kbx-status-badge--경고, .kbx-status-badge--대기 { background: var(--ks-color-neutral-100); color: var(--ks-color-warning); }
/* Guide Cards Grid */
.kbx-guide-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: var(--spacing-6);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--ks-space-3);
}
/* Module Card */
.module-card {
background-color: var(--color-background-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--border-radius-lg);
padding: var(--spacing-6);
transition: all var(--transition-base);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
.kbx-guide-card {
background: var(--ks-color-surface);
border: 1px solid var(--ks-color-border);
border-radius: var(--ks-radius-sm);
padding: var(--ks-space-3);
}
.module-card:hover {
border-color: var(--color-primary-300);
box-shadow: var(--shadow-md);
transform: translateY(-4px);
.kbx-guide-title {
font-size: var(--ks-font-body);
font-weight: 600;
margin: 0 0 var(--ks-space-1) 0;
}
/* Module Header */
.module-header {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.module-title {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.module-icon {
font-size: 2rem;
display: flex;
align-items: center;
}
.module-title h2 {
.kbx-guide-desc {
font-size: var(--ks-font-caption);
color: var(--ks-color-text-muted);
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.module-description {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.6;
}
/* Screen List */
.screen-list {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
margin-top: auto;
}
.screen-link {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--border-radius-md);
background-color: var(--color-background-secondary);
color: var(--color-text-primary);
text-decoration: none;
transition: all var(--transition-base);
border: 1px solid transparent;
cursor: pointer;
font-weight: var(--font-weight-medium);
}
.screen-link:hover {
background-color: var(--color-primary-50);
border-color: var(--color-primary-200);
color: var(--color-primary-600);
}
.screen-link:active {
transform: scale(0.98);
}
.screen-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.screen-label {
flex: 1;
font-size: var(--font-size-sm);
}
.arrow {
color: var(--color-primary-500);
opacity: 0;
transition: all var(--transition-base);
transform: translateX(-4px);
}
.screen-link:hover .arrow {
opacity: 1;
transform: translateX(0);
}
/* Responsive */
@media (max-width: 768px) {
.home-page {
padding: var(--spacing-4);
}
.home-content {
grid-template-columns: 1fr;
}
.hero-content h1 {
font-size: 2rem;
}
.module-card {
padding: var(--spacing-4);
}
}
@media (prefers-reduced-motion: reduce) {
.module-card,
.screen-link,
.arrow {
transition: none;
}
.screen-link:active {
transform: none;
}
line-height: 1.4;
}
</style>
@@ -1,65 +1,81 @@
<template>
<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>
<EditFormPage
title="시장 데이터 수집 (Market Data Ingestion Form)"
subtitle="KRX 과거 시세 및 OpenDart 공시 데이터 수집 작업을 등록합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<!-- Default Slot: Form Section -->
<div class="content">
<KsFormSection title="1. 데이터 소스 및 기간 선택">
<KsFormGrid :columns="1" aria-label="데이터 소스 기간">
<KsSelect v-model="form.dataSource" label="데이터 소스" :options="dataSourceOptions" />
<p class="hint"><strong>KRX:</strong> 시세 데이터(시가/고가/저가/종가/거래량) · <strong>OpenDart:</strong> 기업 공시</p>
</KsFormGrid>
<KsFormGrid :columns="2" aria-label="수집 기간">
<KsDateField v-model="form.fromDate" label="시작일" :min="minDateValue" :max="maxDateValue" />
<KsDateField v-model="form.toDate" label="종료일" :min="form.fromDate ? new Date(form.fromDate) : minDateValue" :max="maxDateValue" />
</KsFormGrid>
<div class="presets">
<KsButton severity="secondary" label="최근 1년" @click="setPreset('1y')" />
<KsButton severity="secondary" label="최근 2년" @click="setPreset('2y')" />
<KsButton severity="secondary" label="최근 5년" @click="setPreset('5y')" />
<KsButton severity="secondary" label="전체 가능 기간" @click="setPreset('all')" />
</div>
</KsFormSection>
<KsFormSection v-if="validationErrors.length" title="입력 오류">
<KsValidationSummary :errors="validationErrorObjects" />
</KsFormSection>
<KsFormSection v-if="validationErrors.length" title="입력 오류">
<KsValidationSummary :errors="validationErrorObjects" />
</KsFormSection>
<KsFormSection v-else title="수집 요약">
<dl class="summary-grid">
<div class="summary-item"><dt>데이터 소스</dt><dd>{{ form.dataSource }}</dd></div>
<div class="summary-item"><dt>기간</dt><dd>{{ 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>
<KsFormSection v-else title="수집 요약">
<dl class="summary-grid">
<div class="summary-item"><dt>데이터 소스</dt><dd>{{ form.dataSource }}</dd></div>
<div class="summary-item"><dt>기간</dt><dd class="ks-financial-number">{{ form.fromDate }} ~ {{ form.toDate }}</dd></div>
<div class="summary-item"><dt>일수</dt><dd class="ks-financial-number">{{ formatQuantity(daysCount, 0) }}</dd></div>
<div class="summary-item"><dt>예상 </dt><dd class="ks-financial-number">{{ estimatedRows }}</dd></div>
</dl>
<template #actions>
<KsButton severity="secondary" label="초기화" @click="resetForm" />
<KsButton severity="primary" :label="isLoading ? '처리 중...' : '수집 예약'" :loading="isLoading" :disabled="validationErrors.length > 0" @click="triggerIngestion" />
</template>
</KsFormSection>
<KsFormSection v-if="jobId" title="수집 작업이 등록되었습니다">
<dl class="summary-grid">
<div class="summary-item"><dt>Job ID</dt><dd class="mono">{{ 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>
<KsFormSection v-if="jobId" title="수집 작업이 등록되었습니다">
<dl class="summary-grid">
<div class="summary-item"><dt>Job ID</dt><dd class="mono ks-financial-number">{{ jobId }}</dd></div>
<div class="summary-item"><dt>상태</dt><dd><KsStatusTag value="대기" severity="info" /></dd></div>
<div class="summary-item"><dt>등록 시각</dt><dd class="ks-financial-number">{{ formatAsOf(new Date()) }}</dd></div>
</dl>
<p class="hint">수집은 백그라운드에서 실행됩니다. 진행 상태는 수집 이력 화면에서 확인할 있습니다.</p>
<RouterLink to="/ops/market-data-history">수집 이력 보기 </RouterLink>
</KsFormSection>
</div>
</EditFormPage>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, reactive } from 'vue'
import { RouterLink } from 'vue-router'
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
import EditFormPage from '../../../shared/ui/screen-types/v2/EditFormPage.vue'
import { KsButton, KsDateField, KsFormGrid, KsFormSection, KsSelect, KsStatusTag, KsValidationSummary } from '../../../shared/ui/components'
import { formatAsOf, formatQuantity } from '../../../shared/formatters/financial'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// KBX T03 Governance Audit & Form State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:42:00Z',
version: 'v60-T03-Contract',
})
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' }
{ label: 'OpenDart (금융감독원) - 기업 공시', value: 'OpenDart' },
]
const form = ref({
@@ -1,32 +1,40 @@
<script setup lang="ts">
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
import { ref, reactive } from 'vue'
import VersionGovernancePage from '../../../shared/ui/screen-types/v2/VersionGovernancePage.vue'
import AutomationBoundaryPanel from '../components/AutomationBoundaryPanel.vue'
import ModelOperationTable from '../components/ModelOperationTable.vue'
import { useModelOperationsPlanQuery } from '../queries'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
const planQuery = useModelOperationsPlanQuery()
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:42:00Z',
version: 'v60-T10-Contract',
})
const handleRetry = () => {
planQuery.refetch()
}
</script>
<template>
<article>
<header>
<h1>모델 운영·지속 고도화</h1>
<p>···분기 평가, drift, champion/challenger, 개선 제안과 승격 증거를 관리합니다.</p>
</header>
<QueryStateBoundary
:loading="planQuery.isLoading.value"
:error="planQuery.error.value as Error | null"
:empty="!planQuery.data.value"
>
<template v-if="planQuery.data.value">
<AutomationBoundaryPanel
:algorithm-status="planQuery.data.value.algorithmStatus"
:order-capability="planQuery.data.value.orderCapability"
:model-mutation-boundary="planQuery.data.value.modelMutationBoundary"
/>
<ModelOperationTable :operations="planQuery.data.value.operations" />
</template>
</QueryStateBoundary>
</article>
<VersionGovernancePage
title="모델 버전 거버넌스 (Model Governance & Versioning)"
subtitle="알고리즘 평가, Drift 지표, Champion/Challenger 버전 비교 및 승격 증거를 관리합니다."
:state="planQuery.isLoading.value ? 'LOADING' : planQuery.error.value ? 'ERROR' : !planQuery.data.value ? 'EMPTY' : screenState"
:evidence="evidence"
@retry="handleRetry"
>
<!-- Default Slot: Content Panel -->
<div v-if="planQuery.data.value" class="content">
<AutomationBoundaryPanel
:algorithm-status="planQuery.data.value.algorithmStatus"
:order-capability="planQuery.data.value.orderCapability"
:model-mutation-boundary="planQuery.data.value.modelMutationBoundary"
/>
<ModelOperationTable :operations="planQuery.data.value.operations" />
</div>
</VersionGovernancePage>
</template>
+173 -155
View File
@@ -1,9 +1,17 @@
<script setup lang="ts">
import { reactive, computed, ref, onMounted } from 'vue'
import SkeletonLoader from '../../../shared/ui/components/SkeletonLoader.vue'
import ErrorBoundary from '../../../shared/ui/components/ErrorBoundary.vue'
import MasterDetailCrudPage from '../../../shared/ui/screen-types/v2/MasterDetailCrudPage.vue'
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// Mock data
// KBX T04 Governance Audit & Screen State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:40:00Z',
version: 'v60-T04-Contract',
})
// Mock Models Data
const mockModels = [
{
modelId: '1',
@@ -39,9 +47,6 @@ const mockModels = [
const models = ref(mockModels)
const selectedModelId = ref(mockModels[0]?.modelId)
const isPending = ref(true)
const isError = ref(false)
const errorMessage = ref('')
const filterModel = reactive({
search: '',
@@ -49,12 +54,10 @@ const filterModel = reactive({
})
onMounted(() => {
// Simulate API loading
screenState.value = 'LOADING'
setTimeout(() => {
isPending.value = false
// isError.value = true // Uncomment to test error state
// errorMessage.value = 'Failed to load models. Please try again.'
}, 1500)
screenState.value = 'READY'
}, 500)
})
const filteredModels = computed(() => {
@@ -85,164 +88,175 @@ const formatPercentage = (value: number) => {
return (value || 0).toFixed(2) + '%'
}
const phaseColors: Record<string, string> = {
'Freeze': 'var(--color-neutral-500)',
'Mature': 'var(--color-primary-500)',
'Score': 'var(--color-primary-500)',
'Diagnose': 'var(--color-warning-500)',
'Hypothesis': 'var(--color-warning-500)',
'Challenger': 'var(--color-warning-500)',
'Validate': 'var(--color-success-500)',
'Review': 'var(--color-success-500)',
'Manual Activation': 'var(--color-danger-500)',
const handleRetry = () => {
screenState.value = 'LOADING'
setTimeout(() => {
screenState.value = 'READY'
}, 400)
}
</script>
<template>
<div class="model-list">
<h1>Models (Master-Detail)</h1>
<!-- Loading State -->
<template v-if="isPending">
<div class="filters">
<SkeletonLoader type="text" width="100%" height="36px" />
<SkeletonLoader type="text" width="100%" height="36px" />
</div>
<div class="content-skeleton">
<SkeletonLoader type="list" :rows="4" />
</div>
</template>
<!-- Error State -->
<ErrorBoundary v-else-if="isError" :fallback-message="errorMessage || 'Failed to load models'">
<div class="error-actions">
<button class="btn btn-primary" @click="isError = false">Retry</button>
</div>
</ErrorBoundary>
<!-- Content -->
<template v-else>
<!-- Filter Bar -->
<div class="filters">
<input v-model="filterModel.search" placeholder="Search models..." class="input" />
<select v-model="filterModel.phase" class="input">
<MasterDetailCrudPage
title="트레이딩 모델 마스터-상세 (Model Operations Master-Detail)"
subtitle="알고리즘 모델의 상태, 과최적화 검증 지표(PBO/DSR) 및 성과 메트릭을 추적 관리합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<template #filters>
<section class="filters" aria-label="모델 검색 필터">
<input
v-model="filterModel.search"
placeholder="Search models..."
class="input"
aria-label="모델 이름 검색"
role="searchbox"
/>
<select
v-model="filterModel.phase"
class="input"
aria-label="모델 단계별 필터"
>
<option value="">All Phases</option>
<option value="Mature">Mature</option>
<option value="Validate">Validate</option>
<option value="Review">Review</option>
</select>
</div>
</section>
</template>
<!-- Content -->
<div class="content">
<!-- Master: List -->
<div class="master-list">
<h2>Models ({{ filteredModels.length }})</h2>
<div class="items">
<div
v-for="model in filteredModels"
:key="model.modelId"
class="model-item"
:class="{ 'is-selected': selectedModelId === model.modelId }"
@click="selectModel(model.modelId)"
>
<div class="item-header">
<strong>{{ model.name }}</strong>
<span class="phase-badge" :style="{ backgroundColor: phaseColors[model.phase] }">
{{ model.phase }}
<!-- Master List Slot -->
<div class="content">
<!-- Master: List -->
<aside class="master-list" aria-label="모델 목록">
<h2>Models ({{ filteredModels.length }})</h2>
<ul class="items" role="list">
<li
v-for="model in filteredModels"
:key="model.modelId"
class="model-item"
:class="{ 'is-selected': selectedModelId === model.modelId }"
@click="selectModel(model.modelId)"
role="option"
:aria-selected="selectedModelId === model.modelId"
:aria-label="`${model.name}, Phase: ${model.phase}, PBO: ${formatPercentage(model.pbo)}, DSR: ${formatPercentage(model.dsr)}, Status: ${model.active ? 'Active' : 'Inactive'}`"
>
<div class="item-header">
<strong>{{ model.name }}</strong>
<KsStatusTag
:value="model.phase"
:severity="model.phase === 'Mature' || model.phase === 'Validate' ? 'success' : 'warning'"
/>
</div>
<div class="metrics" role="group" aria-label="모델 메트릭">
<div class="metric">
<span class="label">PBO</span>
<span class="value ks-financial-number" aria-label="Probability of Backtest Overfitting">{{ formatPercentage(model.pbo) }}</span>
</div>
<div class="metric">
<span class="label">DSR</span>
<span class="value ks-financial-number" aria-label="Daily Sharpe Ratio">{{ formatPercentage(model.dsr) }}</span>
</div>
<div class="metric">
<span class="label">Return</span>
<span class="value ks-financial-number" aria-label="Month to Date Return">{{ formatPercentage(model.returnMtd) }}</span>
</div>
</div>
<div class="footer">
<span v-if="model.active" class="status-active" aria-label="모델이 활성화됨">🟢 Active</span>
<span v-else class="status-inactive" aria-label="모델이 비활성화됨"> Inactive</span>
<span class="date ks-financial-number" :aria-label="`Created: ${formatDate(model.createdAt)}`">{{ formatDate(model.createdAt) }}</span>
</div>
</li>
</ul>
</aside>
<!-- Detail: Right Panel -->
<main class="detail-panel" role="main" aria-label="모델 상세 정보">
<h2 v-if="selectedModel" :aria-label="`Selected model: ${selectedModel.name}`">{{ selectedModel.name }}</h2>
<div v-else class="empty-detail" role="status" aria-live="polite">Select a model to view details</div>
<div v-if="selectedModel" class="model-detail">
<!-- Status -->
<section aria-label="모델 상태">
<h3>Status</h3>
<div class="status-grid" role="group">
<div class="status-item">
<span class="label">Phase</span>
<KsStatusTag
:value="selectedModel.phase"
:severity="selectedModel.phase === 'Mature' ? 'success' : 'warning'"
/>
</div>
<div class="status-item">
<span class="label">Active</span>
<span
class="value"
role="status"
:aria-label="`Model is ${selectedModel.active ? 'active' : 'inactive'}`"
>
{{ selectedModel.active ? '✓ Yes' : '✗ No' }}
</span>
</div>
<div class="metrics">
<div class="metric">
<span class="label">PBO</span>
<span class="value">{{ formatPercentage(model.pbo) }}</span>
</div>
<div class="metric">
<span class="label">DSR</span>
<span class="value">{{ formatPercentage(model.dsr) }}</span>
</div>
<div class="metric">
<span class="label">Return</span>
<span class="value">{{ formatPercentage(model.returnMtd) }}</span>
</div>
</div>
</section>
<!-- Metrics -->
<section aria-label="검증 메트릭">
<h3>Metrics</h3>
<div class="metric-grid" role="group">
<div class="metric-card" role="region" aria-label="PBO - Probability of Backtest Overfitting">
<div class="metric-label">PBO (Backtest Overfit)</div>
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.pbo) }}</div>
<div class="metric-description" aria-describedby="pbo-help">Lower is better</div>
<div id="pbo-help" class="help-text">백테스트 과최적화 확률. 낮을수록 좋습니다</div>
</div>
<div class="footer">
<span v-if="model.active" class="status-active">🟢 Active</span>
<span v-else class="status-inactive"> Inactive</span>
<span class="date">{{ formatDate(model.createdAt) }}</span>
<div class="metric-card" role="region" aria-label="DSR - Daily Sharpe Ratio">
<div class="metric-label">DSR (Daily Sharpe Ratio)</div>
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.dsr) }}</div>
<div class="metric-description" aria-describedby="dsr-help">Higher is better</div>
<div id="dsr-help" class="help-text">일일 샤프 비율. 높을수록 좋습니다</div>
</div>
<div class="metric-card" role="region" aria-label="Return MTD - Month to Date Return">
<div class="metric-label">Return MTD</div>
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.returnMtd) }}</div>
<div class="metric-description" aria-describedby="mtd-help">Month-to-date</div>
<div id="mtd-help" class="help-text">월간 누적 수익률</div>
</div>
</div>
</div>
</section>
<!-- Created Date -->
<section aria-label="모델 타임라인">
<h3>Timeline</h3>
<div class="timeline">
<div class="timeline-item" role="region" aria-label="Model creation date">
<span class="label">Created</span>
<span class="value ks-financial-number" role="status">{{ formatDate(selectedModel.createdAt) }}</span>
</div>
</div>
</section>
<!-- Actions -->
<section class="section" aria-label="모델 작업">
<div class="actions">
<KsButton
label="View Full Report [성과 보고서]"
variant="primary"
aria-label="전체 성과 보고서 보기"
/>
<KsButton
label="Export Metrics [내보내기]"
variant="secondary"
aria-label="메트릭 데이터 내보내기"
/>
</div>
</section>
</div>
<!-- Detail: Right Panel -->
<div class="detail-panel">
<h2 v-if="selectedModel">{{ selectedModel.name }}</h2>
<div v-else class="empty-detail">Select a model to view details</div>
<div v-if="selectedModel" class="model-detail">
<!-- Status -->
<div class="section">
<h3>Status</h3>
<div class="status-grid">
<div class="status-item">
<span class="label">Phase</span>
<span class="badge" :style="{ backgroundColor: phaseColors[selectedModel.phase] }">
{{ selectedModel.phase }}
</span>
</div>
<div class="status-item">
<span class="label">Active</span>
<span class="value">{{ selectedModel.active ? '✓ Yes' : '✗ No' }}</span>
</div>
</div>
</div>
<!-- Metrics -->
<div class="section">
<h3>Metrics</h3>
<div class="metric-grid">
<div class="metric-card">
<div class="metric-label">PBO (Backtest Overfit)</div>
<div class="metric-value">{{ formatPercentage(selectedModel.pbo) }}</div>
<div class="metric-description">Lower is better</div>
</div>
<div class="metric-card">
<div class="metric-label">DSR (Daily Sharpe Ratio)</div>
<div class="metric-value">{{ formatPercentage(selectedModel.dsr) }}</div>
<div class="metric-description">Higher is better</div>
</div>
<div class="metric-card">
<div class="metric-label">Return MTD</div>
<div class="metric-value">{{ formatPercentage(selectedModel.returnMtd) }}</div>
<div class="metric-description">Month-to-date</div>
</div>
</div>
</div>
<!-- Created Date -->
<div class="section">
<h3>Timeline</h3>
<div class="timeline">
<div class="timeline-item">
<span class="label">Created</span>
<span class="value">{{ formatDate(selectedModel.createdAt) }}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="section">
<div class="actions">
<button class="btn btn-primary">View Full Report</button>
<button class="btn btn-secondary">Export Metrics</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</main>
</div>
</MasterDetailCrudPage>
</template>
<style scoped>
@@ -295,8 +309,8 @@ h3 {
.input:focus {
outline: none;
border-color: var(--color-input-focus);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
border-color: var(--ks-color-action);
box-shadow: 0 0 0 2px var(--ks-color-action);
}
.content-skeleton {
@@ -525,6 +539,10 @@ h3 {
font-style: italic;
}
.help-text {
display: none;
}
.timeline {
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-background-primary);
@@ -1,47 +1,18 @@
<template>
<PageLayout title="포트폴리오 리밸런싱" subtitle="목표 비중을 조정하고 리밸런싱을 실행합니다.">
<KsFormSection title="현재 구성">
<DataGridShell :rows="currentPositions" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
<p class="ks-stack"><strong>포트폴리오 평가액:</strong> {{ formatCurrency(totalValue, 'USD') }}</p>
</KsFormSection>
<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>
<template #actions>
<KsButton severity="secondary" label="종목 추가" @click="addTarget" />
<KsButton severity="primary" label="리밸런싱 실행" @click="triggerRebalance" />
</template>
</KsFormSection>
<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 { computed, ref } from 'vue'
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
import { computed, ref, reactive } from 'vue'
import StepWizardPage from '../../../shared/ui/screen-types/v2/StepWizardPage.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'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// KBX T06 Governance Audit & Screen State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:44:00Z',
version: 'v60-T06-Contract',
})
interface Position {
symbol: string
@@ -63,7 +34,6 @@ interface JobResult {
estimatedCost: number
}
// Mock data
const currentPositions = ref<Position[]>([
{ symbol: 'AAPL', quantity: 100, marketPrice: 150.25, marketValue: 15025, weightPercent: 35.3 },
{ symbol: 'MSFT', quantity: 80, marketPrice: 320.50, marketValue: 25640, weightPercent: 60.2 },
@@ -71,11 +41,11 @@ const currentPositions = ref<Position[]>([
])
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) }
{ field: 'symbol', headerName: '종목코드' },
{ field: 'quantity', headerName: '수량', formatter: value => formatQuantity(value as number, 0) },
{ field: 'marketPrice', headerName: '현재가', formatter: value => formatCurrency(value as number, 'USD') },
{ field: 'marketValue', headerName: '평가금액', formatter: value => formatCurrency(value as number, 'USD') },
{ field: 'weightPercent', headerName: '비중', formatter: value => formatPercent((value as number) / 100, 1) }
])
const driftThreshold = ref(5)
@@ -86,7 +56,9 @@ const targetWeights = ref<TargetWeight[]>([
])
const jobResult = ref<JobResult | null>(null)
const totalValue = ref(42700)
const totalValue = computed(() => {
return currentPositions.value.reduce((sum, p) => sum + p.marketValue, 0)
})
const addTarget = () => {
targetWeights.value.push({ symbol: '', targetPercent: 0 })
@@ -96,17 +68,66 @@ const removeTarget = (idx: number) => {
targetWeights.value.splice(idx, 1)
}
const triggerRebalance = async () => {
// Mock API call
const triggerRebalance = () => {
jobResult.value = {
jobId: '550e8400-e29b-41d4-a716-446655440001',
jobId: `REBAL-${Date.now()}`,
status: 'Queued',
estimatedTradeCount: 3,
estimatedCost: 127.35,
}
}
const handleRetry = () => {
screenState.value = 'READY'
}
</script>
<template>
<StepWizardPage
title="포트폴리오 리밸런싱 (Portfolio Rebalance Wizard)"
subtitle="목표 비중 및 허용 이탈 임계값을 설정하여 리밸런싱 주문 작업을 안전하게 실행합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<!-- Default Slot: Wizard Content -->
<div class="content">
<KsFormSection title="1. 현재 포트폴리오 구성">
<DataGridShell :rows="currentPositions" :columns="positionColumns" empty-message="보유 종목이 없습니다." />
<p class="ks-stack"><strong>포트폴리오 평가액:</strong> <span class="ks-financial-number">{{ formatCurrency(totalValue, 'USD') }}</span></p>
</KsFormSection>
<KsFormSection title="2. 목표 비중 및 임계값 설정">
<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>
<template #actions>
<KsButton severity="secondary" label="종목 추가" @click="addTarget" />
<KsButton severity="primary" label="리밸런싱 실행" @click="triggerRebalance" />
</template>
</KsFormSection>
<KsFormSection v-if="jobResult" title="3. 리밸런싱 작업 대기열 등록 완료">
<dl class="ks-stack">
<dt>Job ID</dt><dd class="mono ks-financial-number">{{ jobResult.jobId }}</dd>
<dt>상태</dt><dd><KsStatusTag :value="jobResult.status" severity="info" /></dd>
<dt>예상 거래 건수</dt><dd class="ks-financial-number">{{ formatQuantity(jobResult.estimatedTradeCount, 0) }}</dd>
<dt>예상 비용</dt><dd class="ks-financial-number">{{ formatCurrency(jobResult.estimatedCost, 'USD') }}</dd>
</dl>
</KsFormSection>
</div>
</StepWizardPage>
</template>
<style scoped>
.target-row { display: flex; align-items: end; gap: var(--ks-space-3); }
.target-row > :first-child { flex: 1; }
@@ -11,32 +11,32 @@
<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>
<span class="value ks-financial-number">{{ formatCurrency(dashboard.riskMetrics.var95, 'USD') }}</span>
<span class="note ks-financial-number">{{ 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="value ks-financial-number">{{ 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="value ks-financial-number">{{ 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="value ks-financial-number">{{ 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>
<span class="value ks-financial-number">{{ 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="value ks-financial-number">{{ formatPercent(dashboard.riskMetrics.maxPositionPercent / 100, 1) }}</span>
<span class="note">{{ dashboard.portfolio.positions[0]?.symbol ?? 'N/A' }}</span>
</div>
</template>
@@ -1,9 +1,17 @@
<script setup lang="ts">
import { reactive, computed, ref, onMounted } from 'vue'
import SkeletonLoader from '../../../shared/ui/components/SkeletonLoader.vue'
import ErrorBoundary from '../../../shared/ui/components/ErrorBoundary.vue'
import BatchOperationsPageV2 from '../../../shared/ui/screen-types/v2/BatchOperationsPageV2.vue'
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// Mock data
// KBX T08 Governance Audit & Screen State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:40:00Z',
version: 'v60-T08-Contract',
})
// Mock Shadow Run Batch Jobs Data
const mockJobs = [
{
jobId: '893',
@@ -44,8 +52,6 @@ const mockJobs = [
]
const jobs = ref(mockJobs)
const isLoading = ref(true)
const error = ref<string | null>(null)
const filterModel = reactive({
search: '',
@@ -53,29 +59,22 @@ const filterModel = reactive({
})
onMounted(() => {
// Simulate API loading
screenState.value = 'LOADING'
setTimeout(() => {
isLoading.value = false
// error.value = null // Uncomment to test error state
}, 1500)
screenState.value = 'READY'
}, 500)
})
const filteredJobs = computed(() => {
return jobs.value.filter(job => {
const matchesSearch = job.modelName.toLowerCase().includes(filterModel.search.toLowerCase()) ||
job.jobId.includes(filterModel.search)
const matchesStatus = !filterModel.status || job.status === filterModel.status
return jobs.value.filter(j => {
const matchesSearch =
j.modelName.toLowerCase().includes(filterModel.search.toLowerCase()) ||
j.jobId.includes(filterModel.search)
const matchesStatus = !filterModel.status || j.status === filterModel.status
return matchesSearch && matchesStatus
})
})
const statusStats = computed(() => ({
pending: jobs.value.filter(j => j.status === 'running').length,
completed: jobs.value.filter(j => j.status === 'completed').length,
failed: jobs.value.filter(j => j.status === 'failed').length,
total: jobs.value.length,
}))
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ko-KR', {
year: 'numeric',
@@ -86,120 +85,112 @@ const formatDate = (dateString: string) => {
})
}
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
'running': 'var(--color-primary-500)',
'completed': 'var(--color-success-500)',
'failed': 'var(--color-danger-500)',
}
return colors[status] || 'var(--color-neutral-500)'
const handleRetry = () => {
screenState.value = 'LOADING'
setTimeout(() => {
screenState.value = 'READY'
}, 400)
}
</script>
<template>
<div class="shadow-run-queue">
<h1>Shadow Run Jobs</h1>
<!-- Loading State -->
<template v-if="isLoading">
<div class="stats">
<div v-for="i in 4" :key="i" class="stat">
<SkeletonLoader type="text" width="80%" height="16px" />
<SkeletonLoader type="text" width="60%" height="24px" style="margin-top: 8px" />
</div>
</div>
<div class="filters">
<SkeletonLoader type="text" width="100%" height="36px" />
<SkeletonLoader type="text" width="100%" height="36px" />
</div>
<div class="skeleton-container">
<SkeletonLoader type="card" />
<SkeletonLoader type="card" />
<SkeletonLoader type="card" />
</div>
</template>
<!-- Error State -->
<ErrorBoundary v-else-if="error" :fallback-message="error">
<div class="error-content">
<p>{{ error }}</p>
<button class="btn btn-primary" @click="error = null">Dismiss</button>
</div>
</ErrorBoundary>
<!-- Normal State -->
<template v-else>
<!-- Summary Stats -->
<div class="stats">
<div class="stat stat-pending">
<span class="label">Running</span>
<span class="value">{{ statusStats.pending }}</span>
</div>
<div class="stat stat-completed">
<span class="label">Completed</span>
<span class="value">{{ statusStats.completed }}</span>
</div>
<div class="stat stat-failed">
<span class="label">Failed</span>
<span class="value">{{ statusStats.failed }}</span>
</div>
<div class="stat">
<span class="label">Total</span>
<span class="value">{{ statusStats.total }}</span>
</div>
</div>
<!-- Filters -->
<div class="filters">
<input v-model="filterModel.search" placeholder="Search jobs by name or ID..." class="input" />
<select v-model="filterModel.status" class="input">
<option value="">All Status</option>
<BatchOperationsPageV2
title="Shadow Run 배치 운영 (Model Operation Shadow Run Queue)"
subtitle="라이브 주문 제출 전 모델의 평가 및 워터마크 기반 배치 작업을 안전하게 모니터링합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<template #filters>
<section class="filters" aria-label="배치 작업 검색 필터">
<input
v-model="filterModel.search"
placeholder="Search by job ID or model name..."
class="input"
aria-label="배치 ID 모델명 검색"
role="searchbox"
/>
<select
v-model="filterModel.status"
class="input"
aria-label="배치 상태 필터"
>
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
</section>
</template>
<!-- Jobs List -->
<div class="jobs-list">
<div v-for="job in filteredJobs" :key="job.jobId" class="job-card" :style="{ borderLeftColor: getStatusColor(job.status) }">
<div class="job-header">
<h3>{{ job.modelName }}</h3>
<span class="status-badge" :style="{ backgroundColor: getStatusColor(job.status) }">
{{ job.status.toUpperCase() }}
</span>
</div>
<template #actions>
<KsButton
label="Trigger Shadow Run [실행]"
variant="primary"
aria-label="신규 Shadow Run 트리거"
/>
</template>
<div class="job-meta">
<div><strong>Job ID:</strong> <code>{{ job.jobId }}</code></div>
<div><strong>Window:</strong> {{ job.windowStart }} ~ {{ job.windowEnd }}</div>
<div><strong>Days:</strong> {{ job.tradingDays }}</div>
<div><strong>Started:</strong> {{ formatDate(job.startedAt) }}</div>
</div>
<!-- Default Slot: Table List -->
<div class="content">
<div class="job-list-container">
<h2>Shadow Run Jobs ({{ filteredJobs.length }})</h2>
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-bar" :style="{ width: job.progress + '%', backgroundColor: getStatusColor(job.status) }"></div>
<span class="progress-text">{{ job.progress }}%</span>
</div>
<div v-if="filteredJobs.length === 0" class="empty-state" role="status">
No shadow run jobs match your filters.
</div>
<!-- Error Message -->
<div v-if="job.errorMessage" class="error-message"> {{ job.errorMessage }}</div>
<!-- Actions -->
<div class="actions">
<button class="btn btn-secondary">View Details</button>
<button v-if="job.status === 'completed'" class="btn btn-secondary">Export</button>
<button v-if="job.status === 'failed'" class="btn btn-danger">Retry</button>
</div>
<div v-else class="table-wrapper">
<table class="grid-table" role="table" aria-label="Shadow Run 작업 목록">
<thead>
<tr role="row">
<th role="columnheader">Job ID</th>
<th role="columnheader">Model</th>
<th role="columnheader">Window Period</th>
<th role="columnheader">Trading Days</th>
<th role="columnheader">Status</th>
<th role="columnheader">Progress</th>
<th role="columnheader">Started At</th>
<th role="columnheader">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="job in filteredJobs" :key="job.jobId" role="row" class="grid-row">
<td role="cell" class="cell-id ks-financial-number">#{{ job.jobId }}</td>
<td role="cell" class="cell-model">
<strong>{{ job.modelName }}</strong>
</td>
<td role="cell" class="cell-window ks-financial-number">
{{ job.windowStart }} ~ {{ job.windowEnd }}
</td>
<td role="cell" class="cell-days ks-financial-number">{{ job.tradingDays }}d</td>
<td role="cell" class="cell-status">
<KsStatusTag
:value="job.status.toUpperCase()"
:severity="job.status === 'completed' ? 'success' : job.status === 'failed' ? 'danger' : 'info'"
/>
</td>
<td role="cell" class="cell-progress">
<div class="progress-bar" role="progressbar" :aria-valuenow="job.progress" aria-valuemin="0" aria-valuemax="100">
<div class="progress-fill" :style="{ width: `${job.progress}%` }"></div>
</div>
<span class="progress-text ks-financial-number">{{ job.progress }}%</span>
</td>
<td role="cell" class="cell-date ks-financial-number">{{ formatDate(job.startedAt) }}</td>
<td role="cell" class="cell-actions">
<KsButton
label="View Logs"
variant="secondary"
size="sm"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!isLoading && filteredJobs.length === 0" class="empty-state">
<p>No jobs found</p>
</div>
</template>
</div>
</div>
</BatchOperationsPageV2>
</template>
<style scoped>
@@ -300,8 +291,8 @@ h1 {
.input:focus {
outline: none;
border-color: var(--color-input-focus);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
border-color: var(--ks-color-action);
box-shadow: 0 0 0 2px var(--ks-color-action);
}
.empty-state {
@@ -21,4 +21,4 @@ const items: WbsItem[] = [
</div>
</section>
</template>
<style scoped>.page{display:grid;gap:var(--ks-space-5)}.page-header{display:flex;justify-content:space-between;gap:var(--ks-space-4);align-items:start}.eyebrow{font-size:var(--ks-font-caption);color:var(--ks-color-neutral-600);letter-spacing:.08em}.page-header h1{margin:.25rem 0}.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--ks-space-3)}.summary-grid .ks-card{display:grid;gap:.25rem;padding:var(--ks-space-4)}.summary-grid strong{font-size:1.35rem}.summary-grid span{color:var(--ks-color-neutral-600)}.workspace{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(18rem,.75fr);gap:var(--ks-space-4)}.list,.detail{padding:var(--ks-space-4)}.list h2,.detail h2{margin-top:0}.wbs-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--ks-space-3);width:100%;padding:var(--ks-space-3);border:0;border-top:1px solid var(--ks-color-neutral-200);background:#fff;text-align:left;cursor:pointer}.wbs-row span{display:grid;gap:.25rem}.wbs-row b{font-size:var(--ks-font-caption);color:var(--ks-color-primary-700)}.wbs-row small{color:var(--ks-color-neutral-600)}.wbs-row.selected{background:var(--ks-color-neutral-100)}.warning{padding:var(--ks-space-3);border-left:3px solid #d97706;background:#fffbeb}.detail dl{display:grid;grid-template-columns:auto 1fr;gap:var(--ks-space-2);font-size:var(--ks-font-caption)}.detail dt{font-weight:700}.detail dd{margin:0}@media(max-width:800px){.workspace,.summary-grid{grid-template-columns:1fr}.page-header{flex-direction:column}.wbs-row{grid-template-columns:1fr auto}}</style>
<style scoped>.page{display:grid;gap:var(--ks-space-5)}.page-header{display:flex;justify-content:space-between;gap:var(--ks-space-4);align-items:start}.eyebrow{font-size:var(--ks-font-caption);color:var(--ks-color-neutral-600);letter-spacing:.08em}.page-header h1{margin:.25rem 0}.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--ks-space-3)}.summary-grid .ks-card{display:grid;gap:.25rem;padding:var(--ks-space-4)}.summary-grid strong{font-size:1.35rem}.summary-grid span{color:var(--ks-color-neutral-600)}.workspace{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(18rem,.75fr);gap:var(--ks-space-4)}.list,.detail{padding:var(--ks-space-4)}.list h2,.detail h2{margin-top:0}.wbs-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--ks-space-3);width:100%;padding:var(--ks-space-3);border:0;border-top:1px solid var(--ks-color-neutral-200);background:var(--ks-color-surface);text-align:left;cursor:pointer}.wbs-row span{display:grid;gap:.25rem}.wbs-row b{font-size:var(--ks-font-caption);color:var(--ks-color-primary-700)}.wbs-row small{color:var(--ks-color-neutral-600)}.wbs-row.selected{background:var(--ks-color-neutral-100)}.warning{padding:var(--ks-space-3);border-left:3px solid var(--ks-color-warning);background:var(--ks-color-neutral-100)}.detail dl{display:grid;grid-template-columns:auto 1fr;gap:var(--ks-space-2);font-size:var(--ks-font-caption)}.detail dt{font-weight:700}.detail dd{margin:0}@media(max-width:800px){.workspace,.summary-grid{grid-template-columns:1fr}.page-header{flex-direction:column}.wbs-row{grid-template-columns:1fr auto}}</style>