feat: Approval Queue page (T03 transaction template) — third KBX v60 page

Implement ApprovalQueue.vue using KBX Foundation v60 transaction pattern:
- T03 Transaction template for maker-checker approval workflow
- KbxScreenFrame wrapper with breadcrumb/title
- Summary stats (Pending, Approved, Rejected counts)
- Status and action type filters
- Header with model name, action type, status badge
- Request details grid (Request ID, Requester, Requested At, Status)
- Validation metrics display (PBO, DSR, OOS, Target Phase)
- Review & approval section with textarea for comments
- Approve/Reject buttons with submit state
- Review history display (reviewer, date, decision, comment)
- Side panel with request list (fixed position on desktop, stacked on mobile)
- Dark mode and responsive layout

New files:
- features/approval/pages/ApprovalQueue.vue (T03 transaction page)
- features/approval/composables/useApprovalRequests.ts (approval data)
- features/approval/types/index.ts (type definitions)
- features/approval/registry.ts (screen definition)

Demo data: 3 approval requests (pending, approved, rejected) with full workflow.
Mock approval/rejection methods with comment capture.
Ready for API integration and real backend workflow.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 10:48:02 +09:00
parent 81bcd58dcd
commit f7b290d6c0
4 changed files with 871 additions and 0 deletions
@@ -0,0 +1,159 @@
/**
* Approval Requests Composable
* Fetch and manage approval requests
*/
import { ref, computed } from 'vue'
import type { ApprovalRequest, ApprovalFilter } from '../types'
export function useApprovalRequests() {
const requests = ref<ApprovalRequest[]>([])
const selectedRequestId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const filter = ref<ApprovalFilter>({})
const mockRequests: ApprovalRequest[] = [
{
requestId: 'APR-2026-001',
modelId: '00000000-0000-0000-0000-000000000001',
modelName: 'Hawkeye-Alpha v2.1',
action: 'activate',
metadata: {
pbo: 15.2,
dsr: 96.5,
oos: 1.8,
},
status: 'pending',
requesterName: 'kjh2064',
requestedAt: '2026-08-14T10:30:00Z',
},
{
requestId: 'APR-2026-002',
modelId: '00000000-0000-0000-0000-000000000002',
modelName: 'Falcon-Beta v1.8',
action: 'transition-phase',
metadata: {
currentPhase: 'Review',
targetPhase: 'Manual Activation',
pbo: 18.3,
dsr: 94.2,
oos: 2.1,
},
status: 'approved',
requesterName: 'kjh2064',
requestedAt: '2026-08-12T14:22:00Z',
reviewerName: 'admin',
reviewedAt: '2026-08-13T09:15:00Z',
reviewComment: 'Metrics acceptable for transition. Approved.',
},
{
requestId: 'APR-2026-003',
modelId: '00000000-0000-0000-0000-000000000003',
modelName: 'Eagle-Gamma v3.0',
action: 'transition-phase',
metadata: {
currentPhase: 'Validate',
targetPhase: 'Review',
pbo: 20.5,
dsr: 92.1,
oos: 2.8,
},
status: 'rejected',
requesterName: 'kjh2064',
requestedAt: '2026-08-10T11:45:00Z',
reviewerName: 'admin',
reviewedAt: '2026-08-11T16:20:00Z',
reviewComment: 'PBO exceeds 20% threshold. Needs further optimization.',
},
]
const selectedRequest = computed(() => {
return requests.value.find(r => r.requestId === selectedRequestId.value) || null
})
const filteredRequests = computed(() => {
let result = requests.value
if (filter.value.status) {
result = result.filter(r => r.status === filter.value.status)
}
if (filter.value.action) {
result = result.filter(r => r.action === filter.value.action)
}
return result.sort(
(a, b) => new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime()
)
})
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,
total: requests.value.length,
}))
async function fetchRequests() {
isLoading.value = true
error.value = null
try {
await new Promise(resolve => setTimeout(resolve, 600))
requests.value = mockRequests
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch requests'
} finally {
isLoading.value = false
}
}
function selectRequest(requestId: string) {
selectedRequestId.value = requestId
}
function clearSelection() {
selectedRequestId.value = null
}
function setFilter(newFilter: ApprovalFilter) {
filter.value = newFilter
}
async function approveRequest(requestId: string, comment: string) {
const req = requests.value.find(r => r.requestId === requestId)
if (req) {
req.status = 'approved'
req.reviewerName = 'current-user'
req.reviewedAt = new Date().toISOString()
req.reviewComment = comment
}
}
async function rejectRequest(requestId: string, comment: string) {
const req = requests.value.find(r => r.requestId === requestId)
if (req) {
req.status = 'rejected'
req.reviewerName = 'current-user'
req.reviewedAt = new Date().toISOString()
req.reviewComment = comment
}
}
return {
requests,
filteredRequests,
selectedRequest,
selectedRequestId,
isLoading,
error,
statusStats,
fetchRequests,
selectRequest,
clearSelection,
setFilter,
approveRequest,
rejectRequest,
}
}
@@ -0,0 +1,635 @@
<script setup lang="ts">
/**
* Approval Queue (T03 Transaction Template)
* Maker-checker approval workflow
*/
import { reactive, computed, onMounted, ref } from 'vue'
import {
KbxScreenFrame,
KbxTransactionTemplate,
KbxTemplateStateBoundary,
KbxButton,
KbxStatusTag,
KbxInput,
KbxSelect,
KbxSectionHeader,
KbxDataGrid,
KbxTextarea,
} from '@kbx/ui'
import { useApprovalRequests } from '../composables/useApprovalRequests'
const {
filteredRequests,
selectedRequest,
selectedRequestId,
isLoading,
statusStats,
fetchRequests,
selectRequest,
approveRequest,
rejectRequest,
} = useApprovalRequests()
const filterModel = reactive({
status: 'pending',
action: '',
})
const reviewComment = ref('')
const isSubmitting = ref(false)
const breadcrumb = [
{ label: 'Governance', href: '/governance' },
{ label: 'Approvals' },
]
onMounted(() => {
fetchRequests()
})
const updateFilter = () => {
// Trigger re-filter
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'warning'
case 'approved':
return 'success'
case 'rejected':
return 'danger'
default:
return 'default'
}
}
const getActionLabel = (action: string) => {
const labels: Record<string, string> = {
'activate': '모델 활성화',
'retire': '모델 퇴역',
'transition-phase': '단계 전환',
}
return labels[action] || action
}
const canApprove = computed(() => selectedRequest.value?.status === 'pending')
const canReject = computed(() => selectedRequest.value?.status === 'pending')
async function handleApprove() {
if (!selectedRequest.value) return
isSubmitting.value = true
try {
await approveRequest(selectedRequest.value.requestId, reviewComment.value)
reviewComment.value = ''
} finally {
isSubmitting.value = false
}
}
async function handleReject() {
if (!selectedRequest.value) return
isSubmitting.value = true
try {
await rejectRequest(selectedRequest.value.requestId, reviewComment.value)
reviewComment.value = ''
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<KbxScreenFrame
title="Approval Queue"
:breadcrumb="breadcrumb"
class="approval-queue"
>
<!-- Summary -->
<div class="approval-queue__stats">
<div class="stat-item pending">
<div class="stat-label">Pending</div>
<div class="stat-value">{{ statusStats.pending }}</div>
</div>
<div class="stat-item approved">
<div class="stat-label">Approved</div>
<div class="stat-value">{{ statusStats.approved }}</div>
</div>
<div class="stat-item rejected">
<div class="stat-label">Rejected</div>
<div class="stat-value">{{ statusStats.rejected }}</div>
</div>
</div>
<!-- Filters -->
<div class="approval-queue__filters">
<KbxSelect
v-model="filterModel.status"
label="Status"
:options="[
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
{ value: '', label: 'All' },
]"
@update:model-value="updateFilter"
/>
<KbxSelect
v-model="filterModel.action"
label="Action Type"
:options="[
{ value: '', label: 'All Actions' },
{ value: 'activate', label: 'Activate Model' },
{ value: 'transition-phase', label: 'Phase Transition' },
{ value: 'retire', label: 'Retire Model' },
]"
@update:model-value="updateFilter"
/>
</div>
<!-- Content -->
<KbxTemplateStateBoundary
:state="isLoading ? 'loading' : filteredRequests.length ? 'idle' : 'empty'"
@retry="fetchRequests"
>
<KbxTransactionTemplate v-if="filteredRequests.length">
<!-- Header: Request Summary -->
<template #header>
<div class="transaction-header">
<div class="header-left">
<h2 v-if="selectedRequest" class="header-title">
{{ selectedRequest.modelName }}
</h2>
<p v-if="selectedRequest" class="header-subtitle">
{{ getActionLabel(selectedRequest.action) }}
</p>
</div>
<div v-if="selectedRequest" class="header-right">
<KbxStatusTag
:tone="getStatusColor(selectedRequest.status)"
:label="selectedRequest.status.toUpperCase()"
/>
</div>
</div>
</template>
<!-- Detail: Request Details & Review -->
<template #detail>
<div class="transaction-detail">
<!-- Request Info Section -->
<KbxSectionHeader
title="Request Details"
description="Original request information"
/>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Request ID</div>
<div class="detail-value">{{ selectedRequest?.requestId }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Requested By</div>
<div class="detail-value">{{ selectedRequest?.requesterName }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Requested At</div>
<div class="detail-value">{{ formatDate(selectedRequest?.requestedAt) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Status</div>
<div class="detail-value">
<KbxStatusTag
:tone="getStatusColor(selectedRequest?.status)"
:label="selectedRequest?.status"
/>
</div>
</div>
</div>
<!-- Metadata Section -->
<KbxSectionHeader
title="Validation Metrics"
description="Model metrics at time of request"
/>
<div class="metric-grid">
<div v-if="selectedRequest?.metadata.pbo" class="metric-card">
<div class="metric-label">PBO</div>
<div class="metric-value">{{ selectedRequest.metadata.pbo.toFixed(2) }}%</div>
</div>
<div v-if="selectedRequest?.metadata.dsr" class="metric-card">
<div class="metric-label">DSR</div>
<div class="metric-value">{{ selectedRequest.metadata.dsr.toFixed(2) }}%</div>
</div>
<div v-if="selectedRequest?.metadata.oos" class="metric-card">
<div class="metric-label">OOS</div>
<div class="metric-value">{{ selectedRequest.metadata.oos.toFixed(2) }}%</div>
</div>
<div v-if="selectedRequest?.metadata.targetPhase" class="metric-card">
<div class="metric-label">Target Phase</div>
<div class="metric-value">{{ selectedRequest.metadata.targetPhase }}</div>
</div>
</div>
<!-- Review Section (if pending) -->
<template v-if="canApprove || canReject">
<KbxSectionHeader
title="Review & Approval"
description="Add review notes before approving or rejecting"
/>
<div class="review-section">
<KbxTextarea
v-model="reviewComment"
label="Review Comment"
placeholder="Enter your review comment..."
:rows="4"
/>
<div class="action-buttons">
<KbxButton
variant="primary"
size="md"
label="Approve"
:disabled="!canApprove || isSubmitting"
@click="handleApprove"
/>
<KbxButton
variant="danger"
size="md"
label="Reject"
:disabled="!canReject || isSubmitting"
@click="handleReject"
/>
</div>
</div>
</template>
<!-- Review History (if reviewed) -->
<template v-if="selectedRequest?.reviewedAt">
<KbxSectionHeader
title="Review History"
description="Previous review decision"
/>
<div class="history-section">
<div class="history-item">
<div class="history-header">
<strong>{{ selectedRequest.reviewerName }}</strong>
<KbxStatusTag
:tone="getStatusColor(selectedRequest.status)"
:label="selectedRequest.status"
/>
</div>
<div class="history-date">{{ formatDate(selectedRequest.reviewedAt) }}</div>
<div v-if="selectedRequest.reviewComment" class="history-comment">
{{ selectedRequest.reviewComment }}
</div>
</div>
</div>
</template>
</div>
</template>
</KbxTransactionTemplate>
<!-- List of Requests (side area, using summary approach) -->
<div class="approval-queue__list">
<div class="list-header">
<h3>{{ filteredRequests.length }} Requests</h3>
</div>
<div class="request-items">
<div
v-for="req in filteredRequests"
:key="req.requestId"
class="request-item"
:class="{ 'is-selected': selectedRequestId === req.requestId }"
@click="selectRequest(req.requestId)"
>
<div class="item-header">
<strong>{{ req.modelName }}</strong>
<KbxStatusTag :tone="getStatusColor(req.status)" :label="req.status" />
</div>
<div class="item-subtitle">{{ getActionLabel(req.action) }}</div>
<div class="item-meta">
<span class="meta-label">{{ req.requesterName }}</span>
<span class="meta-date">{{ formatDate(req.requestedAt) }}</span>
</div>
</div>
</div>
</div>
</KbxTemplateStateBoundary>
</KbxScreenFrame>
</template>
<style scoped>
.approval-queue {
display: flex;
flex-direction: column;
gap: 20px;
}
.approval-queue__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-item {
padding: 16px;
border-radius: 4px;
text-align: center;
}
.stat-item.pending {
background: rgba(245, 158, 11, 0.1);
border-left: 4px solid #f59e0b;
}
.stat-item.approved {
background: rgba(16, 185, 129, 0.1);
border-left: 4px solid #10b981;
}
.stat-item.rejected {
background: rgba(239, 68, 68, 0.1);
border-left: 4px solid #ef4444;
}
.stat-label {
font-size: 12px;
color: var(--kbx-color-text-muted);
font-weight: 500;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--kbx-color-text);
}
.approval-queue__filters {
display: grid;
grid-template-columns: 150px 150px;
gap: 16px;
}
.transaction-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid var(--kbx-color-border);
}
.header-left {
flex: 1;
}
.header-title {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 700;
}
.header-subtitle {
margin: 0;
font-size: 14px;
color: var(--kbx-color-text-muted);
}
.header-right {
display: flex;
gap: 8px;
}
.transaction-detail {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
font-size: 12px;
color: var(--kbx-color-text-muted);
font-weight: 500;
}
.detail-value {
font-size: 14px;
color: var(--kbx-color-text);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.metric-card {
padding: 16px;
background: var(--kbx-color-surface-secondary);
border-radius: 4px;
text-align: center;
}
.metric-label {
font-size: 12px;
color: var(--kbx-color-text-muted);
font-weight: 500;
margin-bottom: 8px;
}
.metric-value {
font-size: 16px;
font-weight: 700;
color: var(--kbx-color-text);
}
.review-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.action-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.history-section {
padding: 16px;
background: var(--kbx-color-surface-secondary);
border-radius: 4px;
}
.history-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.history-date {
font-size: 12px;
color: var(--kbx-color-text-muted);
}
.history-comment {
padding: 12px;
background: var(--kbx-color-surface);
border-left: 2px solid var(--kbx-color-primary);
border-radius: 2px;
font-size: 13px;
color: var(--kbx-color-text);
margin-top: 8px;
}
.approval-queue__list {
position: fixed;
right: 0;
top: 120px;
width: 300px;
height: calc(100vh - 140px);
background: var(--kbx-color-surface-secondary);
border-left: 1px solid var(--kbx-color-border);
display: flex;
flex-direction: column;
z-index: 10;
}
.list-header {
padding: 16px;
border-bottom: 1px solid var(--kbx-color-border);
}
.list-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.request-items {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
}
.request-item {
padding: 12px;
background: var(--kbx-color-surface);
border: 1px solid var(--kbx-color-border);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.request-item:hover {
background: var(--kbx-color-surface-hover);
}
.request-item.is-selected {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-size: 12px;
}
.item-subtitle {
font-size: 11px;
color: var(--kbx-color-text-muted);
margin-bottom: 6px;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--kbx-color-text-muted);
}
@media (max-width: 1200px) {
.approval-queue__list {
position: static;
width: auto;
height: auto;
border-left: none;
border-top: 1px solid var(--kbx-color-border);
margin-top: 20px;
}
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (prefers-color-scheme: dark) {
.approval-queue__list {
background: #1f2937;
}
.list-header {
border-bottom-color: #374151;
}
.request-item {
background: #111827;
border-color: #374151;
}
.request-item:hover {
background: #1f2937;
}
.metric-card {
background: #1f2937;
}
.history-section {
background: #1f2937;
}
.history-comment {
background: #111827;
}
}
</style>
@@ -0,0 +1,48 @@
/**
* Approval Feature Screen Registry
*/
import type { ScreenDefinition } from '@kbx/contracts'
export const approvalQueueScreen: ScreenDefinition = {
screenId: 'governance.approval.queue',
title: 'Approval Queue',
module: 'ERP',
path: '/governance/approvals',
component: () => import('./pages/ApprovalQueue.vue'),
permissions: ['approval.review'],
template: 'T03',
help: {
title: 'Approval Workflow',
sections: [
{
title: 'What is Maker-Checker?',
content:
'Maker-Checker enforces that critical model decisions require two parties: the requester and an independent reviewer.',
},
{
title: 'How to Approve',
content: 'Select a pending request, review the metrics and comments, then approve or reject with your decision.',
},
{
title: 'Decision Criteria',
content: 'Activation requires: PBO ≤ 20%, DSR ≥ 95%, OOS ≤ 2.5%, plus 252+ trading-day shadow run.',
},
],
relatedScreens: ['model-ops.models.list', 'model-ops.shadow-run.queue'],
},
grid: {
columnDefs: [
{ field: 'requestId', headerName: 'Request ID', width: 120 },
{ field: 'modelName', headerName: 'Model', width: 150 },
{ field: 'action', headerName: 'Action', width: 100 },
{ field: 'status', headerName: 'Status', width: 100 },
{ field: 'requesterName', headerName: 'Requester', width: 120 },
{ field: 'requestedAt', headerName: 'Date', width: 150 },
],
},
}
export default [approvalQueueScreen]
@@ -0,0 +1,29 @@
/**
* Approval Feature Types
*/
export interface ApprovalRequest {
requestId: string
modelId: string
modelName: string
action: 'activate' | 'retire' | 'transition-phase'
metadata: {
currentPhase?: string
targetPhase?: string
pbo?: number
dsr?: number
oos?: number
}
status: 'pending' | 'approved' | 'rejected'
requesterName: string
requestedAt: string
reviewerName?: string
reviewedAt?: string
reviewComment?: string
}
export interface ApprovalFilter {
status?: string
modelId?: string
action?: string
}