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 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 {