V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
|
||||
import { KsButton } from '@shared/ui/components'
|
||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||
import { useModelDetail, useActivateModel, useDeactivateModel, useTransitionPhase } from '../composables/useModels'
|
||||
|
||||
@@ -143,27 +143,27 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<KbxButton
|
||||
<KsButton
|
||||
label="Edit"
|
||||
variant="default"
|
||||
severity="secondary"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<KbxButton
|
||||
<KsButton
|
||||
v-if="!model.active"
|
||||
:label="canActivate ? 'Activate' : 'Cannot Activate'"
|
||||
:variant="canActivate ? 'primary' : 'default'"
|
||||
:severity="canActivate ? 'primary' : 'secondary'"
|
||||
:disabled="!canActivate"
|
||||
@click="handleActivate"
|
||||
/>
|
||||
<KbxButton
|
||||
<KsButton
|
||||
v-else
|
||||
label="Deactivate"
|
||||
variant="danger"
|
||||
severity="danger"
|
||||
@click="handleDeactivate"
|
||||
/>
|
||||
<KbxButton
|
||||
<KsButton
|
||||
label="Back"
|
||||
variant="default"
|
||||
severity="secondary"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from '../../shared/api/client'
|
||||
import { holdingsResponseSchema, mismatchesResponseSchema, type HoldingsResponse, type MismatchesResponse } from './schema'
|
||||
|
||||
export async function getReconciliationHoldings(): Promise<HoldingsResponse> {
|
||||
const response = await api.get('/reconciliation/holdings')
|
||||
return holdingsResponseSchema.parse(response.data)
|
||||
}
|
||||
|
||||
export async function getReconciliationMismatches(params?: { dateFrom?: string; dateTo?: string }): Promise<MismatchesResponse> {
|
||||
const response = await api.get('/reconciliation/mismatches', { params })
|
||||
return mismatchesResponseSchema.parse(response.data)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { getReconciliationHoldings, getReconciliationMismatches } from './api'
|
||||
|
||||
export const reconciliationKeys = {
|
||||
all: ['reconciliation'] as const,
|
||||
holdings: () => [...reconciliationKeys.all, 'holdings'] as const,
|
||||
mismatches: (dateFrom?: string, dateTo?: string) => [...reconciliationKeys.all, 'mismatches', { dateFrom, dateTo }] as const
|
||||
}
|
||||
|
||||
export function useReconciliationHoldingsQuery() {
|
||||
return useQuery({
|
||||
queryKey: reconciliationKeys.holdings(),
|
||||
queryFn: getReconciliationHoldings,
|
||||
staleTime: 60_000,
|
||||
retry: 1
|
||||
})
|
||||
}
|
||||
|
||||
export function useReconciliationMismatchesQuery(dateFrom?: string, dateTo?: string) {
|
||||
return useQuery({
|
||||
queryKey: reconciliationKeys.mismatches(dateFrom, dateTo),
|
||||
queryFn: () => getReconciliationMismatches({ dateFrom, dateTo }),
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
enabled: Boolean(dateFrom && dateTo)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const utcDateTime = z.string().datetime({ offset: true })
|
||||
|
||||
export const holdingSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
securityId: z.string().uuid(),
|
||||
quantity: z.number().int(),
|
||||
weightedAvgCost: z.number(),
|
||||
totalCostBasis: z.number(),
|
||||
marketValue: z.number().nullable(),
|
||||
unrealizedGainLoss: z.number().nullable(),
|
||||
updatedAt: utcDateTime,
|
||||
correlationId: z.string().uuid()
|
||||
})
|
||||
|
||||
export const holdingsResponseSchema = z.object({
|
||||
items: z.array(holdingSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
pages: z.number().int().positive()
|
||||
})
|
||||
|
||||
export const mismatchSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
tradeId: z.string().uuid(),
|
||||
holdingId: z.string().uuid(),
|
||||
mismatchReason: z.string().nullable(),
|
||||
quantityBefore: z.number().int(),
|
||||
quantityAfter: z.number().int(),
|
||||
costBasisDelta: z.number(),
|
||||
detectedAt: utcDateTime
|
||||
})
|
||||
|
||||
export const mismatchesResponseSchema = z.object({
|
||||
items: z.array(mismatchSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
pages: z.number().int().positive()
|
||||
})
|
||||
|
||||
export type HoldingsResponse = z.infer<typeof holdingsResponseSchema>
|
||||
export type MismatchesResponse = z.infer<typeof mismatchesResponseSchema>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { reconciliationKeys } from '../queries'
|
||||
|
||||
describe('reconciliation query keys', () => {
|
||||
it('keeps holdings and mismatch resources separate', () => {
|
||||
expect(reconciliationKeys.holdings()).not.toEqual(reconciliationKeys.mismatches('2026-08-01', '2026-08-12'))
|
||||
})
|
||||
|
||||
it('includes the mismatch date window in the cache identity', () => {
|
||||
expect(reconciliationKeys.mismatches('2026-08-01', '2026-08-12')).not.toEqual(reconciliationKeys.mismatches('2026-08-02', '2026-08-12'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { holdingsResponseSchema, mismatchesResponseSchema } from '../schema'
|
||||
|
||||
const id = '550e8400-e29b-41d4-a716-446655440001'
|
||||
|
||||
describe('reconciliation API contracts', () => {
|
||||
it('accepts the holdings response emitted by the current endpoint', () => {
|
||||
expect(holdingsResponseSchema.safeParse({
|
||||
items: [{ id, securityId: id, quantity: 10, weightedAvgCost: 100, totalCostBasis: 1000, marketValue: 1100, unrealizedGainLoss: 100, updatedAt: '2026-08-12T00:00:00Z', correlationId: id }],
|
||||
total: 1,
|
||||
pages: 1
|
||||
}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a mismatch response with an invalid timestamp', () => {
|
||||
expect(mismatchesResponseSchema.safeParse({
|
||||
items: [{ id, tradeId: id, holdingId: id, mismatchReason: null, quantityBefore: 10, quantityAfter: 9, costBasisDelta: -100, detectedAt: 'not-a-date' }],
|
||||
total: 1,
|
||||
pages: 1
|
||||
}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a negative response total', () => {
|
||||
expect(holdingsResponseSchema.safeParse({ items: [], total: -1, pages: 1 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
|
||||
import { KsButton } from '@shared/ui/components'
|
||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||
import { useShadowRunDetail } from '../composables/useShadowRuns'
|
||||
|
||||
@@ -108,25 +108,25 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<KbxButton
|
||||
<KsButton
|
||||
:label="`Status: ${run.status}`"
|
||||
variant="default"
|
||||
severity="secondary"
|
||||
disabled
|
||||
/>
|
||||
<KbxButton
|
||||
<KsButton
|
||||
label="Export"
|
||||
variant="default"
|
||||
severity="secondary"
|
||||
@click="handleExport"
|
||||
/>
|
||||
<KbxButton
|
||||
<KsButton
|
||||
v-if="validationStatus === 'valid'"
|
||||
label="Approve"
|
||||
variant="primary"
|
||||
severity="primary"
|
||||
@click="handleApprove"
|
||||
/>
|
||||
<KbxButton
|
||||
<KsButton
|
||||
label="Back"
|
||||
variant="default"
|
||||
severity="secondary"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user