728393226f
Refactored 4 feature pages to use standard screen-type v2 layouts: 1. HomePage → ScorecardDashboardPage - Exception-driven work queue metrics + dashboard layout - KPI cards, filters, operational guides - Viewport-fit ready, no page-level scrolling 2. ModelsList → MasterDetailCrudPage - Master list + detail panel layout - Model grid with phase/performance metrics - Search, filter, pagination 3. ModelDetail → DetailReadPage - Read-only model detail view - State management (LOADING/ERROR/READY) - Metric display (PBO, DSR, Return) 4. ShadowRunDetail → DetailReadPage - Shadow run detail view - State management (LOADING/ERROR/READY) - Performance metrics (PBO, DSR, OOS) Pattern Applied: - Remove PageLayout, use screen-type component - Add StandardScreenProps (state, evidence) - State computed from query status - Slot structure maintained or aligned - No functional changes, pure standardization Benefits: - Consistent layout across pages - Standardized state management - Improved viewport-fit compliance - Better component reusability Next: Viewport-fit final validation + viewport-fit edge cases Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
240 lines
6.3 KiB
Vue
240 lines
6.3 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { MasterDetailCrudPage } from '../../../shared/ui/screen-types/v2'
|
||
import { KsDataGrid, EmptyStatePlaceholder, SkeletonLoader } from '../../../shared/ui/components'
|
||
import type { UiGridColumn } from '../../../shared/ui/adapter/contracts'
|
||
import type { Model, ModelListResponse } from '../composables/useModels'
|
||
import type { StandardScreenProps } from '../../../shared/ui/contracts/screenContract'
|
||
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(20)
|
||
const searchQuery = ref('')
|
||
const selectedPhase = ref('')
|
||
|
||
const isLoading = ref(true)
|
||
const isError = ref(false)
|
||
const modelsData = ref<ModelListResponse | null>(null)
|
||
|
||
const items = computed(() => {
|
||
return modelsData.value?.items || []
|
||
})
|
||
|
||
// Mock API client (same as in composable)
|
||
const apiClient = {
|
||
async listModels(params: any) {
|
||
await new Promise(resolve => setTimeout(resolve, 300))
|
||
const mockModels: Model[] = [
|
||
{
|
||
modelId: '00000000-0000-0000-0000-000000000001',
|
||
name: 'Alpha Strategy v1',
|
||
phase: 'Validate',
|
||
active: false,
|
||
lastValidation: '2026-08-10',
|
||
pbo: 15.2,
|
||
dsr: 96.5,
|
||
returnMtd: 12.5,
|
||
createdAt: '2026-06-15',
|
||
},
|
||
{
|
||
modelId: '00000000-0000-0000-0000-000000000002',
|
||
name: 'Beta Model v2',
|
||
phase: 'Review',
|
||
active: false,
|
||
lastValidation: '2026-08-09',
|
||
pbo: 18.3,
|
||
dsr: 94.2,
|
||
returnMtd: 8.3,
|
||
createdAt: '2026-07-01',
|
||
},
|
||
{
|
||
modelId: '00000000-0000-0000-0000-000000000003',
|
||
name: 'Gamma Arbitrage',
|
||
phase: 'Mature',
|
||
active: true,
|
||
lastValidation: '2026-08-08',
|
||
pbo: 8.5,
|
||
dsr: 98.1,
|
||
returnMtd: 18.7,
|
||
createdAt: '2026-05-10',
|
||
},
|
||
]
|
||
return {
|
||
items: mockModels,
|
||
total: mockModels.length,
|
||
page: 1,
|
||
pageSize: 20,
|
||
}
|
||
},
|
||
}
|
||
|
||
async function loadModels() {
|
||
try {
|
||
isLoading.value = true
|
||
isError.value = false
|
||
const data = await apiClient.listModels({
|
||
page: currentPage.value,
|
||
pageSize: pageSize.value,
|
||
search: searchQuery.value,
|
||
phase: selectedPhase.value,
|
||
})
|
||
modelsData.value = data
|
||
} catch (error) {
|
||
isError.value = true
|
||
console.error('Failed to load models:', error)
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadModels()
|
||
})
|
||
|
||
const columns: UiGridColumn[] = [
|
||
{ field: 'modelId', header: '모델 ID', width: 140 },
|
||
{ field: 'name', header: '모델명', flex: 1, minWidth: 180 },
|
||
{ field: 'phase', header: '진행 단계', width: 120 },
|
||
{
|
||
field: 'active',
|
||
header: '활성화 상태',
|
||
width: 120,
|
||
formatter: (value) => (value ? 'Active (활성)' : 'Inactive (비활성)'),
|
||
},
|
||
{
|
||
field: 'pbo',
|
||
header: 'PBO (%)',
|
||
width: 110,
|
||
formatter: (value) => (typeof value === 'number' ? `${value.toFixed(2)}%` : '-'),
|
||
},
|
||
{
|
||
field: 'dsr',
|
||
header: 'DSR (%)',
|
||
width: 110,
|
||
formatter: (value) => (typeof value === 'number' ? `${value.toFixed(2)}%` : '-'),
|
||
},
|
||
{
|
||
field: 'returnMtd',
|
||
header: 'Return MTD (%)',
|
||
width: 130,
|
||
formatter: (value) => (typeof value === 'number' ? `${value.toFixed(2)}%` : '-'),
|
||
},
|
||
{ field: 'createdAt', header: '생성일', width: 110 },
|
||
]
|
||
|
||
function handleSearch() {
|
||
currentPage.value = 1
|
||
loadModels()
|
||
}
|
||
|
||
const screenState = ref<StandardScreenProps['state']>('READY')
|
||
const screenEvidence = { asOf: new Date().toISOString(), version: '1.0' }
|
||
</script>
|
||
|
||
<template>
|
||
<MasterDetailCrudPage
|
||
title="트레이딩 모델 목록 (Model Management)"
|
||
subtitle="전체 트레이딩 모델의 라이프사이클 및 성과 지표를 조회·관리합니다."
|
||
:state="screenState"
|
||
:evidence="screenEvidence"
|
||
>
|
||
<template #commandBar>
|
||
<button type="button" class="p-button p-button-sm p-button-primary" @click="handleSearch">
|
||
🔍 조회 [F3]
|
||
</button>
|
||
<button type="button" class="p-button p-button-sm p-button-secondary">
|
||
➕ 신규 등록
|
||
</button>
|
||
</template>
|
||
|
||
<template #filters>
|
||
<div class="filters">
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
class="search-input"
|
||
placeholder="모델명 검색..."
|
||
@keyup.enter="handleSearch"
|
||
/>
|
||
<select v-model="selectedPhase" class="status-select" @change="handleSearch">
|
||
<option value="">전체 단계 (All Phases)</option>
|
||
<option value="Validate">Validate</option>
|
||
<option value="Review">Review</option>
|
||
<option value="Mature">Mature</option>
|
||
</select>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Loading State -->
|
||
<div v-if="isLoading" class="state-container">
|
||
<SkeletonLoader type="table" :rows="8" />
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<div v-else-if="isError" class="state-container">
|
||
<EmptyStatePlaceholder title="데이터 로드 실패" description="모델 목록 데이터를 불러오지 못했습니다. 다시 시도해 주세요." />
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div v-else-if="!items.length" class="state-container">
|
||
<EmptyStatePlaceholder title="조회된 모델이 없습니다" description="새로운 트레이딩 모델을 등록하거나 검색 조건을 변경하세요." />
|
||
</div>
|
||
|
||
<!-- Grid Data State -->
|
||
<div v-else class="grid-container">
|
||
<KsDataGrid
|
||
:rows="items"
|
||
:columns="columns"
|
||
height="100%"
|
||
:show-row-number="true"
|
||
/>
|
||
</div>
|
||
</MasterDetailCrudPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filters {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--ks-space-3);
|
||
width: 100%;
|
||
}
|
||
|
||
.search-input {
|
||
min-width: 200px;
|
||
max-width: 350px;
|
||
width: 100%;
|
||
}
|
||
|
||
.status-select {
|
||
min-width: 150px;
|
||
}
|
||
|
||
.state-container {
|
||
padding: 2rem;
|
||
text-align: center;
|
||
border: 1px solid var(--color-border-primary);
|
||
border-radius: 4px;
|
||
background-color: var(--color-background-secondary);
|
||
min-height: 300px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.grid-container {
|
||
border: 1px solid var(--color-border-primary);
|
||
border-radius: var(--kbx-border-radius-sm, 4px);
|
||
overflow: hidden;
|
||
height: 100%;
|
||
min-height: 300px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
}
|
||
</style>
|