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:
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user