Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { AppShellLayout } from './shared/ui/layouts'
|
||||
</script>
|
||||
<template>
|
||||
<AppShellLayout>
|
||||
<template #navigation><nav class="app-nav"><RouterLink to="/research/sell-decision">매도 의사결정</RouterLink><RouterLink to="/ops/data-quality">데이터 품질</RouterLink><RouterLink to="/ops/model-operations">모델 운영</RouterLink><RouterLink to="/internal/ui-standard">표준 UI 패턴</RouterLink></nav></template>
|
||||
<RouterView />
|
||||
</AppShellLayout>
|
||||
</template>
|
||||
<style scoped>.app-nav{display:grid;gap:var(--ks-space-2)}.app-nav a{padding:var(--ks-space-2) var(--ks-space-3);border-radius:var(--ks-radius-sm);text-decoration:none}.app-nav a.router-link-active{background:var(--ks-color-neutral-100);font-weight:700}</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: (failureCount, error: unknown) => {
|
||||
const status = typeof error === 'object' && error !== null && 'status' in error
|
||||
? Number((error as { status: unknown }).status)
|
||||
: 0
|
||||
return ![400, 401, 403, 404, 409, 422].includes(status) && failureCount < 2
|
||||
},
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
mutations: { retry: false }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import SellDecisionPage from '../features/sell-decision/pages/SellDecisionPage.vue'
|
||||
import DataQualityPage from '../features/data-quality/pages/DataQualityPage.vue'
|
||||
import ModelOperationsPage from '../features/model-operations/pages/ModelOperationsPage.vue'
|
||||
import UiStandardPage from '../features/ui-standard/pages/UiStandardPage.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/research/sell-decision' },
|
||||
{ path: '/research/sell-decision', component: SellDecisionPage, meta: { screenId: 'SCR-002', templateId: 'T02' } },
|
||||
{ path: '/ops/data-quality', component: DataQualityPage, meta: { screenId: 'SCR-013', templateId: 'T08' } },
|
||||
{ path: '/ops/model-operations', component: ModelOperationsPage, meta: { screenId: 'SCR-015', templateId: 'T10' } },
|
||||
{ path: '/internal/ui-standard', component: UiStandardPage, meta: { screenId: 'SCR-DEV-001', templateId: 'T01', internalOnly: true } }
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
@import './tokens.css';
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: light; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ks-color-canvas);
|
||||
color: var(--ks-color-neutral-950);
|
||||
font-family: Inter, Pretendard, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: var(--ks-font-body);
|
||||
line-height: var(--ks-line-body);
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
a { color: var(--ks-color-action); }
|
||||
:focus-visible { outline: 3px solid color-mix(in srgb, var(--ks-color-focus) 55%, transparent); outline-offset: 2px; }
|
||||
.ks-financial-number { font-variant-numeric: tabular-nums; }
|
||||
.ks-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.ks-card { background: var(--ks-color-surface); border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-md); box-shadow: var(--ks-shadow-sm); }
|
||||
.ks-stack { display: grid; gap: var(--ks-space-4); }
|
||||
.ks-inline { display: flex; align-items: center; gap: var(--ks-space-2); flex-wrap: wrap; }
|
||||
.ks-muted { color: var(--ks-color-neutral-600); }
|
||||
.ks-danger-text { color: var(--ks-color-danger); }
|
||||
@@ -0,0 +1,46 @@
|
||||
:root {
|
||||
--ks-color-action: #174a7e;
|
||||
--ks-color-action-hover: #123b65;
|
||||
--ks-color-info: #2563eb;
|
||||
--ks-color-success: #137333;
|
||||
--ks-color-warning: #9a6700;
|
||||
--ks-color-danger: #b42318;
|
||||
--ks-color-neutral-950: #111827;
|
||||
--ks-color-neutral-800: #1f2937;
|
||||
--ks-color-neutral-700: #374151;
|
||||
--ks-color-neutral-600: #4b5563;
|
||||
--ks-color-neutral-500: #6b7280;
|
||||
--ks-color-neutral-300: #d1d5db;
|
||||
--ks-color-neutral-200: #e5e7eb;
|
||||
--ks-color-neutral-100: #f3f4f6;
|
||||
--ks-color-surface: #ffffff;
|
||||
--ks-color-canvas: #f7f8fa;
|
||||
--ks-color-focus: #2563eb;
|
||||
--ks-space-1: 0.25rem;
|
||||
--ks-space-2: 0.5rem;
|
||||
--ks-space-3: 0.75rem;
|
||||
--ks-space-4: 1rem;
|
||||
--ks-space-6: 1.5rem;
|
||||
--ks-space-8: 2rem;
|
||||
--ks-radius-sm: 0.25rem;
|
||||
--ks-radius-md: 0.5rem;
|
||||
--ks-radius-lg: 0.75rem;
|
||||
--ks-shadow-sm: 0 1px 2px rgb(0 0 0 / 8%);
|
||||
--ks-shadow-md: 0 8px 24px rgb(0 0 0 / 10%);
|
||||
--ks-font-page: 1.5rem;
|
||||
--ks-line-page: 2rem;
|
||||
--ks-font-section: 1.125rem;
|
||||
--ks-line-section: 1.625rem;
|
||||
--ks-font-body: 0.875rem;
|
||||
--ks-line-body: 1.375rem;
|
||||
--ks-font-caption: 0.75rem;
|
||||
--ks-line-caption: 1.125rem;
|
||||
--ks-control-height: 2.75rem;
|
||||
--ks-grid-density: 2.25rem;
|
||||
--ks-content-max: 100rem;
|
||||
}
|
||||
|
||||
[data-density='compact'] {
|
||||
--ks-control-height: 2.25rem;
|
||||
--ks-grid-density: 2rem;
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_UI_ADAPTER?: 'primevue' | 'native'
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { UiGridColumn } from '../../../shared/ui/adapter/contracts'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import type { DataQualityRun } from '../schema'
|
||||
|
||||
// Template fixture only. Production data must come from DAT-03 and pass Zod validation.
|
||||
const rows: DataQualityRun[] = []
|
||||
const columns = computed<UiGridColumn[]>(() => [
|
||||
{ field: 'source', header: 'Source' },
|
||||
{ field: 'session', header: 'Session' },
|
||||
{ field: 'status', header: 'DQ' },
|
||||
{ field: 'rowCount', header: 'Rows' },
|
||||
{ field: 'failedRows', header: 'Failed' },
|
||||
{ field: 'sourceWatermark', header: 'Watermark' },
|
||||
{ field: 'datasetId', header: 'Dataset' },
|
||||
{ field: 'completedAt', header: 'Completed' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header>
|
||||
<h1>데이터 품질 운영</h1>
|
||||
<p>Raw→PIT→DQ→Dataset lineage를 확인합니다. QUARANTINED 데이터는 추천에 사용할 수 없습니다.</p>
|
||||
</header>
|
||||
<DataGridShell
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
empty-message="DAT-03 계약이 구현되면 서버 검증 결과가 표시됩니다."
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const dataQualityStatusSchema = z.enum(['PASS', 'WARN', 'QUARANTINED'])
|
||||
|
||||
export const dataQualityRunSchema = z.object({
|
||||
runId: z.string().uuid(),
|
||||
source: z.string().min(1),
|
||||
session: z.string().min(1),
|
||||
status: dataQualityStatusSchema,
|
||||
rowCount: z.number().int().nonnegative(),
|
||||
failedRows: z.number().int().nonnegative(),
|
||||
sourceWatermark: z.string().min(1),
|
||||
datasetId: z.string().min(1),
|
||||
contentHash: z.string().min(1),
|
||||
completedAt: z.string().datetime({ offset: true })
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.failedRows > value.rowCount) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '실패 행 수는 전체 행 수를 초과할 수 없습니다.',
|
||||
path: ['failedRows']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const dataQualityRunsSchema = z.array(dataQualityRunSchema)
|
||||
export type DataQualityRun = z.infer<typeof dataQualityRunSchema>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dataQualityRunSchema } from '../schema'
|
||||
|
||||
const valid = {
|
||||
runId: '00000000-0000-0000-0000-000000000001',
|
||||
source: 'KRX',
|
||||
session: '2026-08-01',
|
||||
status: 'PASS',
|
||||
rowCount: 100,
|
||||
failedRows: 0,
|
||||
sourceWatermark: 'KRX:2026-08-01',
|
||||
datasetId: 'dataset-1',
|
||||
contentHash: 'hash-1',
|
||||
completedAt: '2026-08-01T09:00:00Z'
|
||||
}
|
||||
|
||||
describe('data quality contract', () => {
|
||||
it('accepts a valid run', () => {
|
||||
expect(dataQualityRunSchema.safeParse(valid).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects failed rows greater than total rows', () => {
|
||||
expect(dataQualityRunSchema.safeParse({ ...valid, failedRows: 101 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { api } from '../../shared/api/client'
|
||||
import { modelOperationsPlanSchema, type ModelOperationsPlan } from './schema'
|
||||
|
||||
export async function getModelOperationsPlan(): Promise<ModelOperationsPlan> {
|
||||
const response = await api.get('/internal/v1/model-operations/plan')
|
||||
return modelOperationsPlanSchema.parse(response.data)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
algorithmStatus: string
|
||||
orderCapability: string
|
||||
modelMutationBoundary: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-labelledby="automation-boundary-title">
|
||||
<h2 id="automation-boundary-title">자동화 경계</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>알고리즘 상태</dt>
|
||||
<dd>{{ algorithmStatus }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>주문 Capability</dt>
|
||||
<dd>{{ orderCapability }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>모델 변경</dt>
|
||||
<dd>{{ modelMutationBoundary }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p>
|
||||
스케줄러는 평가 증거와 개선 제안만 생성합니다. 모델 승격·롤백·임계값 변경은 독립 검증과
|
||||
maker-checker 승인을 거쳐야 합니다.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelOperationItem } from '../schema'
|
||||
|
||||
defineProps<{ operations: ModelOperationItem[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-labelledby="operation-plan-title">
|
||||
<h2 id="operation-plan-title">지속 평가·개선 작업 계획</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>작업</th>
|
||||
<th>주기</th>
|
||||
<th>자동화 모드</th>
|
||||
<th>Queue</th>
|
||||
<th>Gate</th>
|
||||
<th>Owner</th>
|
||||
<th>산출물</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="operation in operations" :key="operation.operationCode">
|
||||
<td>{{ operation.operationCode }}</td>
|
||||
<td>{{ operation.name }}</td>
|
||||
<td>{{ operation.cadence }}</td>
|
||||
<td>{{ operation.automationMode }}</td>
|
||||
<td>{{ operation.queue }}</td>
|
||||
<td>{{ operation.gate }}</td>
|
||||
<td>{{ operation.primaryOwner }} / {{ operation.secondaryOwner }}</td>
|
||||
<td>{{ operation.output }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
|
||||
import AutomationBoundaryPanel from '../components/AutomationBoundaryPanel.vue'
|
||||
import ModelOperationTable from '../components/ModelOperationTable.vue'
|
||||
import { useModelOperationsPlanQuery } from '../queries'
|
||||
|
||||
const planQuery = useModelOperationsPlanQuery()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<header>
|
||||
<h1>모델 운영·지속 고도화</h1>
|
||||
<p>일·주·월·분기 평가, drift, champion/challenger, 개선 제안과 승격 증거를 관리합니다.</p>
|
||||
</header>
|
||||
|
||||
<QueryStateBoundary
|
||||
:loading="planQuery.isLoading.value"
|
||||
:error="planQuery.error.value as Error | null"
|
||||
:empty="!planQuery.data.value"
|
||||
>
|
||||
<template v-if="planQuery.data.value">
|
||||
<AutomationBoundaryPanel
|
||||
:algorithm-status="planQuery.data.value.algorithmStatus"
|
||||
:order-capability="planQuery.data.value.orderCapability"
|
||||
:model-mutation-boundary="planQuery.data.value.modelMutationBoundary"
|
||||
/>
|
||||
<ModelOperationTable :operations="planQuery.data.value.operations" />
|
||||
</template>
|
||||
</QueryStateBoundary>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { getModelOperationsPlan } from './api'
|
||||
|
||||
export const modelOperationsKeys = {
|
||||
all: ['model-operations'] as const,
|
||||
plan: () => [...modelOperationsKeys.all, 'plan'] as const
|
||||
}
|
||||
|
||||
export function useModelOperationsPlanQuery() {
|
||||
return useQuery({
|
||||
queryKey: modelOperationsKeys.plan(),
|
||||
queryFn: getModelOperationsPlan,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const operationItemSchema = z.object({
|
||||
operationCode: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
cadence: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'EVENT_DRIVEN']),
|
||||
automationMode: z.enum(['EVALUATION_ONLY', 'PROPOSAL_ONLY', 'DRILL_ONLY']),
|
||||
queue: z.string().min(1),
|
||||
primaryOwner: z.string().min(1),
|
||||
secondaryOwner: z.string().min(1),
|
||||
requiredEvidence: z.string().min(1),
|
||||
output: z.string().min(1),
|
||||
gate: z.string().min(1)
|
||||
})
|
||||
|
||||
export const modelOperationsPlanSchema = z.object({
|
||||
algorithmStatus: z.literal('RESEARCH_CANDIDATE_NOT_PRODUCTION'),
|
||||
orderCapability: z.literal('AUTOMATIC_ORDER_AND_KIS_SUBMISSION_OFF'),
|
||||
modelMutationBoundary: z.literal('EVALUATION_AND_PROPOSAL_ONLY_HUMAN_APPROVAL_REQUIRED'),
|
||||
operations: z.array(operationItemSchema)
|
||||
})
|
||||
|
||||
export type ModelOperationsPlan = z.infer<typeof modelOperationsPlanSchema>
|
||||
export type ModelOperationItem = z.infer<typeof operationItemSchema>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { modelOperationsPlanSchema } from '../schema'
|
||||
|
||||
describe('model operations plan schema', () => {
|
||||
it('rejects an automatic promotion mode', () => {
|
||||
const result = modelOperationsPlanSchema.safeParse({
|
||||
algorithmStatus: 'RESEARCH_CANDIDATE_NOT_PRODUCTION',
|
||||
orderCapability: 'AUTOMATIC_ORDER_AND_KIS_SUBMISSION_OFF',
|
||||
modelMutationBoundary: 'EVALUATION_AND_PROPOSAL_ONLY_HUMAN_APPROVAL_REQUIRED',
|
||||
operations: [{
|
||||
operationCode: 'J22',
|
||||
name: 'PromotionEvidenceReviewBuild',
|
||||
cadence: 'MONTHLY',
|
||||
automationMode: 'AUTO_PROMOTE',
|
||||
queue: 'q-control',
|
||||
primaryOwner: 'Risk',
|
||||
secondaryOwner: 'Compliance',
|
||||
requiredEvidence: 'all evidence',
|
||||
output: 'review packet',
|
||||
gate: 'G4'
|
||||
}]
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { api } from '../../shared/api/client'
|
||||
import {
|
||||
researchSellPolicyRequestSchema,
|
||||
researchSellPolicyResponseSchema,
|
||||
type ResearchSellPolicyRequest,
|
||||
type ResearchSellPolicyResponse
|
||||
} from './schema'
|
||||
|
||||
export interface ResearchSellPolicyCommand {
|
||||
request: ResearchSellPolicyRequest
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export async function evaluateResearchSellPolicy(
|
||||
command: ResearchSellPolicyCommand
|
||||
): Promise<ResearchSellPolicyResponse> {
|
||||
const request = researchSellPolicyRequestSchema.parse(command.request)
|
||||
const { data } = await api.post(
|
||||
'/internal/v1/research/sell-policy/evaluate',
|
||||
request,
|
||||
{ headers: { 'Idempotency-Key': command.idempotencyKey } }
|
||||
)
|
||||
return researchSellPolicyResponseSchema.parse(data)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ResearchSellPolicyResponse } from '../schema'
|
||||
|
||||
type TraceEntry = ResearchSellPolicyResponse['policyTrace'][number]
|
||||
const props = defineProps<{ entries: TraceEntry[]; schemaVersion: 2 }>()
|
||||
const dispositionLabel: Record<0 | 1 | 2, string> = { 0: 'NOT_APPLICABLE', 1: 'BLOCKED', 2: 'APPLIED' }
|
||||
const ordered = computed(() => [...props.entries].sort((a, b) => b.priority - a.priority))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-labelledby="policy-trace-title">
|
||||
<h2 id="policy-trace-title">정책 우선순위 추적</h2>
|
||||
<p>Trace schema v{{ props.schemaVersion }} · 상위 정책부터 평가된 불변 증거입니다.</p>
|
||||
<ol>
|
||||
<li v-for="entry in ordered" :key="`${entry.priority}-${entry.policyId}`">
|
||||
<strong>{{ entry.policyId }}</strong>
|
||||
<span> {{ dispositionLabel[entry.disposition] }} · {{ entry.reasonCode }}</span>
|
||||
<span v-if="entry.requestedSellRatioOfLot > 0">
|
||||
· 요청 {{ entry.requestedSellRatioOfLot }} / 적용 {{ entry.appliedSellRatioOfLot }}
|
||||
</span>
|
||||
<span v-if="entry.strategicCoreClampApplied"> · Strategic Core clamp</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import QueryStateBoundary from '../../../shared/ui/QueryStateBoundary.vue'
|
||||
import PolicyTracePanel from '../components/PolicyTracePanel.vue'
|
||||
import { useEvaluateResearchSellPolicy } from '../queries'
|
||||
import type { ResearchSellPolicyCommand } from '../api'
|
||||
|
||||
const mutation = useEvaluateResearchSellPolicy()
|
||||
const hardImpairmentApproved = ref(false)
|
||||
const capitalFloorBreached = ref(false)
|
||||
const gapBelowFloorAtr = ref(1.6)
|
||||
const consecutiveCloseBreaches = ref(0)
|
||||
const lastCommand = ref<ResearchSellPolicyCommand | null>(null)
|
||||
|
||||
const isBusy = computed(() => mutation.isPending.value)
|
||||
|
||||
function createCommand(): ResearchSellPolicyCommand {
|
||||
const asOf = new Date().toISOString()
|
||||
return {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
request: {
|
||||
positionLotId: '00000000-0000-0000-0000-000000000001',
|
||||
cycleId: '00000000-0000-0000-0000-000000000002',
|
||||
evidenceId: 'sample-evidence',
|
||||
datasetId: 'sample-dataset',
|
||||
modelVersion: 'research-v12.2',
|
||||
configVersion: 'proposal-v12.2',
|
||||
codeSha: 'sample-code-sha',
|
||||
asOf,
|
||||
publishedAtCutoff: asOf,
|
||||
currentSecurityPortfolioWeight: 0.6,
|
||||
currentLotPortfolioWeight: 0.2,
|
||||
strategicCoreFloorWeight: 0.3,
|
||||
hardImpairmentApproved: hardImpairmentApproved.value,
|
||||
capitalFloorBreached: capitalFloorBreached.value,
|
||||
survivalSellRatioOfLot: 0.5,
|
||||
gapBelowFloorAtr: gapBelowFloorAtr.value,
|
||||
consecutiveCloseBreaches: consecutiveCloseBreaches.value,
|
||||
cooldownSatisfied: true,
|
||||
concentrationSellRatioOfLot: 0,
|
||||
opportunityEdgeLowerBound: 0,
|
||||
opportunitySellRatioOfLot: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const command = createCommand()
|
||||
lastCommand.value = command
|
||||
mutation.mutate(command)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (lastCommand.value) mutation.mutate(lastCommand.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<header>
|
||||
<h1>매도 정책 연구 콘솔</h1>
|
||||
<p>
|
||||
순수 정책 계약과 우선순위를 확인하는 연구 전용 화면입니다.
|
||||
고객 제안·공개·주문 기능과 연결되지 않습니다.
|
||||
</p>
|
||||
<strong>RESEARCH_CANDIDATE_NOT_PRODUCTION · 자동주문 OFF</strong>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="run">
|
||||
<fieldset :disabled="isBusy">
|
||||
<legend>연구 입력 벡터</legend>
|
||||
<label>
|
||||
<input v-model="hardImpairmentApproved" type="checkbox" />
|
||||
Hard impairment 승인
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="capitalFloorBreached" type="checkbox" />
|
||||
자본바닥 위반
|
||||
</label>
|
||||
<label>
|
||||
보호선 이탈 ATR
|
||||
<input v-model.number="gapBelowFloorAtr" type="number" min="0" step="0.1" />
|
||||
</label>
|
||||
<label>
|
||||
연속 종가 이탈
|
||||
<input v-model.number="consecutiveCloseBreaches" type="number" min="0" step="1" />
|
||||
</label>
|
||||
<button type="submit">정책 평가</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<QueryStateBoundary
|
||||
:loading="isBusy"
|
||||
:error="mutation.error.value as Error | null"
|
||||
:empty="!mutation.data.value"
|
||||
@retry="retry"
|
||||
>
|
||||
<dl v-if="mutation.data.value">
|
||||
<dt>행동</dt><dd>{{ mutation.data.value.action }}</dd>
|
||||
<dt>정책</dt><dd>{{ mutation.data.value.policyId }}</dd>
|
||||
<dt>사유</dt><dd>{{ mutation.data.value.reasonCode }}</dd>
|
||||
<dt>Lot 매도비율</dt><dd>{{ mutation.data.value.sellRatioOfLot }}</dd>
|
||||
<dt>매도 후 종목 비중</dt><dd>{{ mutation.data.value.targetSecurityPortfolioWeightAfter }}</dd>
|
||||
<dt>재진입 가능</dt><dd>{{ mutation.data.value.reentryEligible }}</dd>
|
||||
<dt>결정 계약</dt><dd>{{ mutation.data.value.decisionContractVersion }}</dd>
|
||||
<dt>정책 추적</dt><dd>{{ mutation.data.value.policyTrace.length }}단계</dd>
|
||||
</dl>
|
||||
<PolicyTracePanel
|
||||
v-if="mutation.data.value"
|
||||
:entries="mutation.data.value.policyTrace"
|
||||
:schema-version="mutation.data.value.policyTraceSchemaVersion"
|
||||
/>
|
||||
</QueryStateBoundary>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import { evaluateResearchSellPolicy } from './api'
|
||||
|
||||
export function useEvaluateResearchSellPolicy() {
|
||||
return useMutation({ mutationFn: evaluateResearchSellPolicy })
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const ratio = z.number().min(0).max(1)
|
||||
|
||||
const policyTraceEntrySchema = z.object({
|
||||
policyId: z.string().min(1),
|
||||
priority: z.number().int(),
|
||||
disposition: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
||||
reasonCode: z.string().min(1),
|
||||
requestedSellRatioOfLot: ratio,
|
||||
appliedSellRatioOfLot: ratio,
|
||||
strategicCoreClampApplied: z.boolean()
|
||||
})
|
||||
|
||||
export const researchSellPolicyRequestSchema = z.object({
|
||||
positionLotId: z.string().uuid(),
|
||||
cycleId: z.string().uuid(),
|
||||
evidenceId: z.string().min(1).max(128),
|
||||
datasetId: z.string().min(1).max(128),
|
||||
modelVersion: z.string().min(1).max(128),
|
||||
configVersion: z.string().min(1).max(128),
|
||||
codeSha: z.string().min(1).max(128),
|
||||
asOf: z.string().datetime({ offset: true }),
|
||||
publishedAtCutoff: z.string().datetime({ offset: true }),
|
||||
currentSecurityPortfolioWeight: ratio,
|
||||
currentLotPortfolioWeight: ratio,
|
||||
strategicCoreFloorWeight: ratio,
|
||||
hardImpairmentApproved: z.boolean(),
|
||||
capitalFloorBreached: z.boolean(),
|
||||
survivalSellRatioOfLot: ratio,
|
||||
gapBelowFloorAtr: z.number().min(0),
|
||||
consecutiveCloseBreaches: z.number().int().min(0),
|
||||
cooldownSatisfied: z.boolean(),
|
||||
concentrationSellRatioOfLot: ratio,
|
||||
opportunityEdgeLowerBound: z.number(),
|
||||
opportunitySellRatioOfLot: ratio
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.currentLotPortfolioWeight > value.currentSecurityPortfolioWeight) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Lot 비중은 종목 전체 비중을 초과할 수 없습니다.',
|
||||
path: ['currentLotPortfolioWeight']
|
||||
})
|
||||
}
|
||||
if (new Date(value.publishedAtCutoff) > new Date(value.asOf)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '공개 가능 시각은 평가 시각 이후일 수 없습니다.',
|
||||
path: ['publishedAtCutoff']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const researchSellPolicyResponseSchema = z.object({
|
||||
action: z.enum(['Hold', 'PartialSell', 'FullSell']),
|
||||
sellRatioOfLot: ratio,
|
||||
targetSecurityPortfolioWeightAfter: ratio,
|
||||
policyId: z.string().min(1),
|
||||
reasonCode: z.string().min(1),
|
||||
decisionContractVersion: z.literal('sell-decision.v2'),
|
||||
policyTraceSchemaVersion: z.literal(2),
|
||||
reentryEligible: z.boolean(),
|
||||
policyTrace: z.array(policyTraceEntrySchema),
|
||||
evidenceStatus: z.literal('RESEARCH_CANDIDATE_NOT_PRODUCTION')
|
||||
})
|
||||
|
||||
export type ResearchSellPolicyRequest = z.infer<typeof researchSellPolicyRequestSchema>
|
||||
export type ResearchSellPolicyResponse = z.infer<typeof researchSellPolicyResponseSchema>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
researchSellPolicyRequestSchema,
|
||||
researchSellPolicyResponseSchema
|
||||
} from '../schema'
|
||||
|
||||
const baseRequest = {
|
||||
positionLotId: '00000000-0000-0000-0000-000000000001',
|
||||
cycleId: '00000000-0000-0000-0000-000000000002',
|
||||
evidenceId: 'evidence-1',
|
||||
datasetId: 'dataset-1',
|
||||
modelVersion: 'model-1',
|
||||
configVersion: 'config-1',
|
||||
codeSha: 'sha-1',
|
||||
asOf: '2026-08-01T07:00:00Z',
|
||||
publishedAtCutoff: '2026-08-01T06:00:00Z',
|
||||
currentSecurityPortfolioWeight: 0.6,
|
||||
currentLotPortfolioWeight: 0.2,
|
||||
strategicCoreFloorWeight: 0.3,
|
||||
hardImpairmentApproved: false,
|
||||
capitalFloorBreached: false,
|
||||
survivalSellRatioOfLot: 0,
|
||||
gapBelowFloorAtr: 0,
|
||||
consecutiveCloseBreaches: 0,
|
||||
cooldownSatisfied: true,
|
||||
concentrationSellRatioOfLot: 0,
|
||||
opportunityEdgeLowerBound: 0,
|
||||
opportunitySellRatioOfLot: 0
|
||||
}
|
||||
|
||||
describe('research sell policy contracts', () => {
|
||||
it('accepts a valid point-in-time request', () => {
|
||||
expect(researchSellPolicyRequestSchema.safeParse(baseRequest).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a lot weight above security weight', () => {
|
||||
const result = researchSellPolicyRequestSchema.safeParse({
|
||||
...baseRequest,
|
||||
currentLotPortfolioWeight: 0.7
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a future published-at cutoff', () => {
|
||||
const result = researchSellPolicyRequestSchema.safeParse({
|
||||
...baseRequest,
|
||||
publishedAtCutoff: '2026-08-01T08:00:00Z'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unknown production status', () => {
|
||||
const result = researchSellPolicyResponseSchema.safeParse({
|
||||
action: 'Hold',
|
||||
sellRatioOfLot: 0,
|
||||
targetSecurityPortfolioWeightAfter: 0.6,
|
||||
policyId: 'ALG-HOLD-001',
|
||||
reasonCode: 'NO_SELL_CONDITION',
|
||||
reentryEligible: false,
|
||||
policyTrace: [],
|
||||
evidenceStatus: 'PRODUCTION'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { SearchListCrudPage } from '@/shared/ui/screen-types'
|
||||
import { screenTemplateCatalogue } from '@/shared/ui/screen-types/catalogue'
|
||||
import { KsButton, KsDataGrid, KsSelect, KsStatusTag, KsTextField } from '@/shared/ui/components'
|
||||
import type { UiGridColumn, UiSelectOption } from '@/shared/ui/adapter/contracts'
|
||||
import { useUiAdapter } from '@/shared/ui/adapter/useUiAdapter'
|
||||
|
||||
const adapter = useUiAdapter()
|
||||
const query = ref('')
|
||||
const status = ref<unknown>('ALL')
|
||||
const options: UiSelectOption[] = [{ label: '전체', value: 'ALL' }, { label: '검토 필요', value: 'REVIEW' }, { label: '보류', value: 'HOLD' }]
|
||||
const rows = computed(() => screenTemplateCatalogue.filter(x => !query.value || `${x.id} ${x.name} ${x.component}`.toLowerCase().includes(query.value.toLowerCase())).map(x => ({ id: x.id, name: x.name, component: x.component, evidence: x.mandatoryEvidence.length, state: 'READY' })))
|
||||
const columns: UiGridColumn[] = [{ field: 'id', header: '화면 ID', width: 100 }, { field: 'name', header: '화면 타입' }, { field: 'component', header: '표준 컴포넌트', minWidth: 220 }, { field: 'evidence', header: '필수 증거', width: 110 }, { field: 'state', header: '상태', width: 110 }]
|
||||
</script>
|
||||
<template>
|
||||
<SearchListCrudPage title="표준 UI 패턴" subtitle="Feature는 공급자 라이브러리를 직접 사용하지 않고, v2 어댑터·레이아웃·화면 계약을 사용한다." state="READY" :evidence="{ asOf: '2026-08-02', version: 'UI-CONTRACT-2.0' }">
|
||||
<template #actions><KsButton label="새 화면 패킷" severity="secondary" /></template>
|
||||
<template #summary><div class="ks-card summary"><strong>10</strong><span>화면 타입</span></div><div class="ks-card summary"><strong>{{ adapter.descriptor.capabilities.size }}</strong><span>어댑터 포트</span></div><div class="ks-card summary"><KsStatusTag :value="adapter.descriptor.id" severity="info" /><span>{{ adapter.descriptor.vendor }}</span></div><div class="ks-card summary"><KsStatusTag value="자동주문 OFF" severity="warning" /><span>고정 경계</span></div></template>
|
||||
<template #filters><div class="filters"><KsTextField v-model="query" label="검색" placeholder="화면 ID, 타입 또는 컴포넌트" /><KsSelect v-model="status" label="상태" :options="options" /></div></template>
|
||||
<KsDataGrid :rows="rows" :columns="columns" height="25rem" />
|
||||
<template #detail><div class="ks-card detail"><h2>교체 계약</h2><p>기본 공급자 교체는 <code>VITE_UI_ADAPTER</code>와 provider registry에서 수행한다. Feature 코드는 변경하지 않는다.</p><p>PrimeVue+AG Grid와 Native reference adapter가 동일 contract test를 통과해야 한다.</p><p>생산 교체는 접근성, 키보드, 상태행렬, 시각회귀, 대량목록 성능을 별도 Gate로 검증한다.</p></div></template>
|
||||
</SearchListCrudPage>
|
||||
</template>
|
||||
<style scoped>.filters{display:grid;grid-template-columns:2fr 1fr;gap:var(--ks-space-3)}.summary,.detail{padding:var(--ks-space-4)}.summary{display:grid;gap:var(--ks-space-1)}.summary strong{font-size:1.5rem}.detail h2{margin-top:0;font-size:var(--ks-font-section)}@media(max-width:700px){.filters{grid-template-columns:1fr}}</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import App from './App.vue'
|
||||
import { router } from './app/router'
|
||||
import { queryClient } from './app/queryClient'
|
||||
import { resolveUiProvider } from './shared/ui/provider'
|
||||
import './design-system/base.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
resolveUiProvider(import.meta.env.VITE_UI_ADAPTER).install(app)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,23 @@
|
||||
import axios from 'axios'
|
||||
import { ApiProblem, type ProblemDetails } from './problem'
|
||||
|
||||
export const api = axios.create({ baseURL: '/api', timeout: 15_000 })
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const user = import.meta.env.VITE_DEV_AUTH_USER as string | undefined
|
||||
const role = import.meta.env.VITE_DEV_AUTH_ROLE as string | undefined
|
||||
if (import.meta.env.DEV && user && role) {
|
||||
config.headers['X-KArtSell-User'] = user
|
||||
config.headers['X-KArtSell-Role'] = role
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const data = error.response?.data as ProblemDetails | undefined
|
||||
if (data?.status) throw new ApiProblem(data)
|
||||
throw error
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface ProblemDetails {
|
||||
type?: string
|
||||
title: string
|
||||
status: number
|
||||
detail?: string
|
||||
traceId?: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export class ApiProblem extends Error {
|
||||
constructor(public readonly problem: ProblemDetails) {
|
||||
super(problem.title)
|
||||
}
|
||||
|
||||
get status(): number { return this.problem.status }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
allowed: boolean
|
||||
deniedMessage?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-if="props.allowed" />
|
||||
<p v-else role="alert">{{ props.deniedMessage ?? '이 기능을 사용할 권한이 없습니다.' }}</p>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface IdempotentCommand<T> {
|
||||
readonly idempotencyKey: string
|
||||
readonly request: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Create once per user intent. Retries must reuse the returned envelope rather than call this again.
|
||||
*/
|
||||
export function createIdempotentCommand<T>(request: T): IdempotentCommand<T> {
|
||||
return Object.freeze({ idempotencyKey: crypto.randomUUID(), request })
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const versionSetSchema = z.object({
|
||||
datasetId: z.string().min(1).max(128),
|
||||
dataHash: z.string().min(1).max(128),
|
||||
modelVersion: z.string().min(1).max(128),
|
||||
configVersion: z.string().min(1).max(128),
|
||||
codeSha: z.string().min(1).max(128),
|
||||
contractVersion: z.string().min(1).max(64)
|
||||
})
|
||||
|
||||
export type VersionSet = z.infer<typeof versionSetSchema>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { StandardScreenState } from '../ui/contracts/screenContract'
|
||||
import PageLayout from '../ui/layouts/PageLayout.vue'
|
||||
import FormPageLayout from '../ui/layouts/FormPageLayout.vue'
|
||||
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
|
||||
withDefaults(defineProps<{ title: string; subtitle?: string; state?: StandardScreenState; dirty?: boolean; readonly?: boolean; asOf?: string; version?: string }>(), { state: 'READY', dirty: false, readonly: false })
|
||||
const emit = defineEmits<{ retry: []; submit: []; cancel: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<PageLayout :title="title" :subtitle="subtitle" :status="readonly ? 'READONLY' : state" :as-of="asOf" :version="version">
|
||||
<StandardScreenBoundary :state="readonly ? 'READONLY' : (dirty ? 'DIRTY' : state)" :stale-at="asOf" @retry="emit('retry')">
|
||||
<FormPageLayout @submit="emit('submit')">
|
||||
<slot />
|
||||
<template v-if="$slots.aside" #preview><slot name="aside" /></template>
|
||||
</FormPageLayout>
|
||||
</StandardScreenBoundary>
|
||||
<template #footer><slot name="actions" /></template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../ui/adapter/contracts'
|
||||
import type { StandardScreenState } from '../ui/contracts/screenContract'
|
||||
import PageLayout from '../ui/layouts/PageLayout.vue'
|
||||
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
|
||||
import KsDataGrid from '../ui/components/KsDataGrid.vue'
|
||||
import KsPaginator from '../ui/components/KsPaginator.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
state?: StandardScreenState
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
asOf?: string
|
||||
version?: string
|
||||
warning?: string
|
||||
}>(), { state: 'READY', rows: () => [] })
|
||||
const emit = defineEmits<{ retry: []; rowSelected: [row: unknown]; pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
</script>
|
||||
<template>
|
||||
<PageLayout :title="title" :subtitle="subtitle" :status="state" :as-of="asOf" :version="version">
|
||||
<template #actions><slot name="actions" /></template>
|
||||
<template #summary><slot name="summary" /></template>
|
||||
<template #filters><slot name="filters" /></template>
|
||||
<StandardScreenBoundary :state="state" :warning="warning" :stale-at="asOf" @retry="emit('retry')">
|
||||
<KsDataGrid :rows="rows" :columns="columns" @row-selected="emit('rowSelected', $event)" />
|
||||
<KsPaginator :page="page" :page-size="pageSize" :total="total" @page-change="emit('pageChange', $event)" />
|
||||
</StandardScreenBoundary>
|
||||
<template v-if="$slots.detail" #aside><slot name="detail" /></template>
|
||||
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { UiGridColumn, UiGridFilter, UiGridSort } from '../ui/adapter/contracts'
|
||||
|
||||
export type CrudPermission = 'read' | 'create' | 'update' | 'delete' | 'export' | 'review' | 'publish'
|
||||
export interface CrudListQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
sorts: UiGridSort[]
|
||||
filters: UiGridFilter[]
|
||||
}
|
||||
export interface CrudPageResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
asOf: string
|
||||
projectionVersion: string
|
||||
watermark?: string
|
||||
stale: boolean
|
||||
}
|
||||
export interface CrudMutationContext {
|
||||
idempotencyKey: string
|
||||
ifMatch?: string
|
||||
reason?: string
|
||||
correlationId?: string
|
||||
}
|
||||
export interface CrudResourceContract<TItem, TForm> {
|
||||
resourceCode: string
|
||||
routeBase: string
|
||||
queryKey: readonly string[]
|
||||
columns: UiGridColumn[]
|
||||
formSchema: ZodType<TForm>
|
||||
permissions: Partial<Record<CrudPermission, string>>
|
||||
defaultQuery: CrudListQuery
|
||||
parseListResponse(payload: unknown): CrudPageResult<TItem>
|
||||
parseDetailResponse(payload: unknown): TItem
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CrudListQuery } from './contracts'
|
||||
|
||||
const positiveInt = (value: string | null, fallback: number): number => {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
|
||||
const sorts = params.getAll('sort').flatMap(value => {
|
||||
const [field, direction] = value.split(':')
|
||||
return field && (direction === 'asc' || direction === 'desc') ? [{ field, direction }] : []
|
||||
})
|
||||
const filters = params.getAll('filter').flatMap(value => {
|
||||
const first = value.indexOf(':')
|
||||
const second = value.indexOf(':', first + 1)
|
||||
if (first <= 0 || second <= first) return []
|
||||
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
|
||||
})
|
||||
return {
|
||||
page: positiveInt(params.get('page'), fallback.page),
|
||||
pageSize: positiveInt(params.get('pageSize'), fallback.pageSize),
|
||||
search: params.get('search')?.trim() || undefined,
|
||||
sorts: sorts.length ? sorts : fallback.sorts,
|
||||
filters: filters.length ? filters : fallback.filters
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeCrudQuery(query: CrudListQuery): URLSearchParams {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(query.page))
|
||||
params.set('pageSize', String(query.pageSize))
|
||||
if (query.search) params.set('search', query.search)
|
||||
for (const sort of query.sorts) params.append('sort', `${sort.field}:${sort.direction}`)
|
||||
for (const filter of query.filters) params.append('filter', `${filter.field}:${filter.operator}:${String(filter.value ?? '')}`)
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { UiGridColumn } from '../ui/adapter/contracts'
|
||||
export type CrudConcurrencyMode = 'NONE' | 'ETAG_IF_MATCH'
|
||||
export type CrudIdempotencyMode = 'NONE' | 'IDEMPOTENCY_KEY'
|
||||
export interface CrudResourceDefinition<TQuery, TRow, TForm> {
|
||||
resourceId: string
|
||||
querySchemaVersion: string
|
||||
responseSchemaVersion: string
|
||||
querySchema: ZodType<TQuery>
|
||||
rowSchema: ZodType<TRow>
|
||||
formSchema: ZodType<TForm>
|
||||
columns: readonly UiGridColumn[]
|
||||
sensitiveFields: readonly string[]
|
||||
permissionPolicy: string
|
||||
concurrencyMode: CrudConcurrencyMode
|
||||
idempotencyMode: CrudIdempotencyMode
|
||||
}
|
||||
export function assertCrudResourceDefinition(definition: CrudResourceDefinition<unknown, unknown, unknown>): void {
|
||||
if (!definition.resourceId.trim()) throw new Error('resourceId is required')
|
||||
const fields = new Set(definition.columns.map(x => x.field))
|
||||
for (const sensitive of definition.sensitiveFields) if (!fields.has(sensitive)) throw new Error(`Sensitive field '${sensitive}' has no grid column contract`)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeCrudQuery, encodeCrudQuery } from '../queryCodec'
|
||||
const fallback = { page: 1, pageSize: 20, sorts: [], filters: [] }
|
||||
describe('CRUD URL codec', () => {
|
||||
it('round-trips paging, sort, filter and search without hidden Pinia state', () => {
|
||||
const source = { page: 3, pageSize: 50, search: '005930', sorts: [{ field: 'asOf', direction: 'desc' as const }], filters: [{ field: 'status', operator: 'eq', value: 'WARN' }] }
|
||||
expect(decodeCrudQuery(encodeCrudQuery(source), fallback)).toEqual(source)
|
||||
})
|
||||
it('fails closed to approved defaults for invalid paging', () => {
|
||||
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CrudListQuery } from './contracts'
|
||||
|
||||
export function useCrudListState(initial: CrudListQuery) {
|
||||
const query = ref<CrudListQuery>({ ...initial, sorts: [...initial.sorts], filters: [...initial.filters] })
|
||||
const selectedId = ref<string | number | null>(null)
|
||||
const dirty = ref(false)
|
||||
|
||||
function replace(next: CrudListQuery): void { query.value = { ...next, sorts: [...next.sorts], filters: [...next.filters] } }
|
||||
function setPage(page: number, pageSize = query.value.pageSize): void { query.value = { ...query.value, page, pageSize } }
|
||||
function setSearch(search?: string): void { query.value = { ...query.value, page: 1, search: search?.trim() || undefined } }
|
||||
function reset(): void { replace(initial); selectedId.value = null; dirty.value = false }
|
||||
|
||||
return {
|
||||
query,
|
||||
selectedId,
|
||||
dirty,
|
||||
offset: computed(() => (query.value.page - 1) * query.value.pageSize),
|
||||
replace,
|
||||
setPage,
|
||||
setSearch,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
import { createIdempotencyKey } from '../commands/idempotency'
|
||||
export interface OptimisticCommandRequest<T> { payload: T; etag?: string }
|
||||
export interface OptimisticCommandResponse<TResult> { data: TResult; etag?: string; correlationId?: string }
|
||||
export function useOptimisticCommand<TPayload, TResult>(execute: (request: OptimisticCommandRequest<TPayload>, headers: Record<string,string>) => Promise<OptimisticCommandResponse<TResult>>) {
|
||||
const pending = ref(false); const conflict = ref(false); const lastCorrelationId = ref<string>()
|
||||
async function run(request: OptimisticCommandRequest<TPayload>): Promise<OptimisticCommandResponse<TResult>> {
|
||||
if (pending.value) throw new Error('Command is already in progress')
|
||||
pending.value=true; conflict.value=false
|
||||
try {
|
||||
const headers: Record<string,string> = { 'Idempotency-Key': createIdempotencyKey() }
|
||||
if (request.etag) headers['If-Match']=request.etag
|
||||
const response=await execute(request,headers); lastCorrelationId.value=response.correlationId; return response
|
||||
} catch (error: unknown) {
|
||||
const status=(error as { response?: { status?: number } })?.response?.status
|
||||
if (status===409 || status===412) conflict.value=true
|
||||
throw error
|
||||
} finally { pending.value=false }
|
||||
}
|
||||
return { pending, conflict, lastCorrelationId, run }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function formatCurrency(value: number | null | undefined, currency: string, locale = 'ko-KR'): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
export function formatPercent(value: number | null | undefined, digits = 2, locale = 'ko-KR'): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat(locale, { style: 'percent', minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value)
|
||||
}
|
||||
export function formatQuantity(value: number | null | undefined, digits = 4, locale = 'ko-KR'): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value)
|
||||
}
|
||||
export function formatAsOf(value: string | Date | null | undefined, locale = 'ko-KR'): string {
|
||||
if (!value) return '—'
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul' }).format(date)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCurrency, formatPercent, formatQuantity } from '../financial'
|
||||
describe('financial formatters', () => {
|
||||
it('renders missing values as an explicit em dash', () => { expect(formatCurrency(null, 'KRW')).toBe('—'); expect(formatPercent(undefined)).toBe('—') })
|
||||
it('keeps percentage inputs in decimal-return units', () => { expect(formatPercent(0.125, 1)).toContain('12.5') })
|
||||
it('uses bounded quantity precision', () => { expect(formatQuantity(1.234567, 2)).toContain('1.23') })
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
asOf: string
|
||||
staleAfterMinutes: number
|
||||
}>()
|
||||
|
||||
const ageMinutes = computed(() => Math.max(0, (Date.now() - new Date(props.asOf).getTime()) / 60_000))
|
||||
const stale = computed(() => ageMinutes.value > props.staleAfterMinutes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :aria-label="stale ? '데이터 지연' : '데이터 최신'" :data-status="stale ? 'stale' : 'fresh'">
|
||||
{{ stale ? 'STALE' : 'FRESH' }} · {{ new Date(props.asOf).toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from './adapter/contracts'
|
||||
import { KsDataGrid } from './components'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
}>(), { loading: false, emptyMessage: '표시할 데이터가 없습니다.' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="데이터 표" :aria-busy="loading">
|
||||
<p v-if="loading">데이터를 불러오는 중입니다.</p>
|
||||
<p v-else-if="rows.length === 0">{{ emptyMessage }}</p>
|
||||
<KsDataGrid v-else :rows="rows" :columns="columns" height="30rem" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { VersionSet } from '../contracts/versionSet'
|
||||
|
||||
const props = defineProps<{
|
||||
value: VersionSet
|
||||
compact?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl class="version-set" :data-compact="props.compact ? 'true' : 'false'">
|
||||
<dt>Dataset</dt><dd>{{ props.value.datasetId }}</dd>
|
||||
<dt>Data hash</dt><dd><code>{{ props.value.dataHash }}</code></dd>
|
||||
<dt>Model</dt><dd>{{ props.value.modelVersion }}</dd>
|
||||
<dt>Config</dt><dd>{{ props.value.configVersion }}</dd>
|
||||
<dt>Code</dt><dd><code>{{ props.value.codeSha }}</code></dd>
|
||||
<dt>Contract</dt><dd>{{ props.value.contractVersion }}</dd>
|
||||
</dl>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import KsButton from './components/KsButton.vue'
|
||||
import KsInlineMessage from './components/KsInlineMessage.vue'
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
processing?: boolean
|
||||
dirty?: boolean
|
||||
error?: Error | null
|
||||
empty?: boolean
|
||||
partial?: boolean
|
||||
staleAt?: string | null
|
||||
warning?: string | null
|
||||
unauthorized?: boolean
|
||||
forbidden?: boolean
|
||||
conflict?: boolean
|
||||
expired?: boolean
|
||||
readonly?: boolean
|
||||
correlationId?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<section :aria-busy="props.loading || props.processing">
|
||||
<KsInlineMessage v-if="props.loading" severity="info" message="불러오는 중입니다." />
|
||||
<KsInlineMessage v-else-if="props.unauthorized" severity="warning" title="로그인 필요" message="로그인 후 다시 시도하세요." />
|
||||
<KsInlineMessage v-else-if="props.forbidden" severity="danger" title="권한 없음" message="이 작업을 수행할 권한이 없습니다." />
|
||||
<KsInlineMessage v-else-if="props.conflict" severity="warning" title="변경 충돌" message="다른 사용자가 먼저 변경했습니다. 최신 버전을 확인하세요." />
|
||||
<KsInlineMessage v-else-if="props.expired" severity="warning" title="유효기간 만료" message="만료된 증거 또는 제안은 실행·공개할 수 없습니다." />
|
||||
<div v-else-if="props.error" role="alert" class="ks-state-error">
|
||||
<KsInlineMessage severity="danger" title="요청 실패" :message="props.error.message" />
|
||||
<KsButton label="같은 요청 다시 시도" severity="secondary" @click="emit('retry')" />
|
||||
<small v-if="correlationId">Correlation: {{ correlationId }}</small>
|
||||
</div>
|
||||
<KsInlineMessage v-else-if="props.empty" severity="info" message="표시할 데이터가 없습니다." />
|
||||
<template v-else>
|
||||
<KsInlineMessage v-if="props.partial" severity="warning" message="일부 데이터만 표시하고 있습니다. 완전성 경고를 확인하세요." />
|
||||
<KsInlineMessage v-if="props.warning" severity="warning" :message="props.warning" />
|
||||
<KsInlineMessage v-if="props.readonly" severity="info" message="읽기 전용 상태입니다." />
|
||||
<KsInlineMessage v-if="props.dirty" severity="warning" message="저장되지 않은 변경사항이 있습니다." />
|
||||
<KsInlineMessage v-if="props.processing" severity="info" message="처리 중입니다. 중복 제출하지 마세요." />
|
||||
<slot />
|
||||
</template>
|
||||
<small v-if="props.staleAt">데이터 기준시각: {{ props.staleAt }}</small>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.ks-state-error{display:grid;gap:var(--ks-space-2);justify-items:start}</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
currentVersion?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; reload: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog :open="props.open" aria-labelledby="version-conflict-title">
|
||||
<h2 id="version-conflict-title">버전 충돌</h2>
|
||||
<p>다른 사용자가 먼저 변경했습니다. 최신 데이터를 다시 불러온 뒤 재검토하세요.</p>
|
||||
<p v-if="props.currentVersion">현재 버전: {{ props.currentVersion }}</p>
|
||||
<button type="button" @click="emit('reload')">최신 버전 불러오기</button>
|
||||
<button type="button" @click="emit('close')">닫기</button>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { UiAdapter, UiAdapterCapability } from './contracts'
|
||||
|
||||
export interface UiAdapterCompatibilityIssue {
|
||||
code: 'CONTRACT_VERSION' | 'MISSING_CAPABILITY' | 'MISSING_COMPONENT' | 'PRODUCTION_INELIGIBLE'
|
||||
severity: 'ERROR' | 'WARNING'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface UiAdapterCompatibilityReport {
|
||||
adapterId: string
|
||||
contractVersion: string
|
||||
compatible: boolean
|
||||
issues: readonly UiAdapterCompatibilityIssue[]
|
||||
}
|
||||
|
||||
const componentByCapability: Readonly<Record<UiAdapterCapability, keyof UiAdapter['components']>> = {
|
||||
'button': 'Button', 'text-field': 'TextField', 'text-area': 'TextArea', 'select': 'Select',
|
||||
'multi-select': 'MultiSelect', 'checkbox': 'Checkbox', 'date-field': 'DateField',
|
||||
'number-field': 'NumberField', 'dialog': 'Dialog', 'status-tag': 'StatusTag',
|
||||
'inline-message': 'InlineMessage', 'paginator': 'Paginator', 'tabs': 'Tabs', 'data-grid': 'DataGrid'
|
||||
}
|
||||
|
||||
export function evaluateUiAdapterCompatibility(
|
||||
adapter: UiAdapter,
|
||||
requiredCapabilities: readonly UiAdapterCapability[],
|
||||
requireProductionEligible = false
|
||||
): UiAdapterCompatibilityReport {
|
||||
const issues: UiAdapterCompatibilityIssue[] = []
|
||||
if (adapter.descriptor.contractVersion !== '4.0') {
|
||||
issues.push({ code: 'CONTRACT_VERSION', severity: 'ERROR', detail: `Expected 4.0, got ${adapter.descriptor.contractVersion}` })
|
||||
}
|
||||
for (const capability of requiredCapabilities) {
|
||||
if (!adapter.descriptor.capabilities.has(capability)) {
|
||||
issues.push({ code: 'MISSING_CAPABILITY', severity: 'ERROR', detail: capability })
|
||||
continue
|
||||
}
|
||||
const component = adapter.components[componentByCapability[capability]] as Component | undefined
|
||||
if (!component) issues.push({ code: 'MISSING_COMPONENT', severity: 'ERROR', detail: capability })
|
||||
}
|
||||
if (requireProductionEligible && !adapter.descriptor.productionEligible) {
|
||||
issues.push({ code: 'PRODUCTION_INELIGIBLE', severity: 'ERROR', detail: adapter.descriptor.id })
|
||||
}
|
||||
return { adapterId: adapter.descriptor.id, contractVersion: adapter.descriptor.contractVersion, compatible: !issues.some(x => x.severity === 'ERROR'), issues }
|
||||
}
|
||||
|
||||
export function assertUiAdapterCompatibility(report: UiAdapterCompatibilityReport): void {
|
||||
if (!report.compatible) throw new Error(`UI adapter ${report.adapterId} is incompatible: ${report.issues.map(x => `${x.code}:${x.detail}`).join(', ')}`)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Component, InjectionKey } from 'vue'
|
||||
|
||||
export type UiSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
|
||||
export type UiButtonType = 'button' | 'submit' | 'reset'
|
||||
export type UiAdapterId = 'primevue-aggrid' | 'native-accessible'
|
||||
export type UiAdapterContractVersion = '4.0'
|
||||
export type UiAdapterCapability =
|
||||
| 'button'
|
||||
| 'text-field'
|
||||
| 'text-area'
|
||||
| 'select'
|
||||
| 'multi-select'
|
||||
| 'checkbox'
|
||||
| 'date-field'
|
||||
| 'number-field'
|
||||
| 'dialog'
|
||||
| 'status-tag'
|
||||
| 'inline-message'
|
||||
| 'paginator'
|
||||
| 'tabs'
|
||||
| 'data-grid'
|
||||
|
||||
export interface UiSelectOption {
|
||||
label: string
|
||||
value: string | number | boolean | null
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface UiTabItem {
|
||||
id: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
badge?: string | number
|
||||
}
|
||||
|
||||
export type UiGridSortDirection = 'asc' | 'desc'
|
||||
export interface UiGridSort { field: string; direction: UiGridSortDirection }
|
||||
export interface UiGridFilter { field: string; operator: string; value: unknown }
|
||||
export interface UiGridQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
sorts: UiGridSort[]
|
||||
filters: UiGridFilter[]
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface UiGridColumn {
|
||||
field: string
|
||||
header: string
|
||||
width?: number
|
||||
minWidth?: number
|
||||
sortable?: boolean
|
||||
filterable?: boolean
|
||||
sensitive?: boolean
|
||||
formatter?: (value: unknown, row: unknown) => string
|
||||
}
|
||||
|
||||
export interface UiAdapterDescriptor {
|
||||
readonly id: UiAdapterId
|
||||
readonly version: string
|
||||
readonly contractVersion: UiAdapterContractVersion
|
||||
readonly vendor: string
|
||||
readonly capabilities: ReadonlySet<UiAdapterCapability>
|
||||
readonly productionEligible: boolean
|
||||
readonly accessibilityBaseline: 'WCAG_2_2_AA_TARGET'
|
||||
}
|
||||
|
||||
export interface UiAdapter {
|
||||
readonly descriptor: UiAdapterDescriptor
|
||||
readonly components: {
|
||||
Button: Component
|
||||
TextField: Component
|
||||
TextArea: Component
|
||||
Select: Component
|
||||
MultiSelect: Component
|
||||
Checkbox: Component
|
||||
DateField: Component
|
||||
NumberField: Component
|
||||
Dialog: Component
|
||||
StatusTag: Component
|
||||
InlineMessage: Component
|
||||
Paginator: Component
|
||||
Tabs: Component
|
||||
DataGrid: Component
|
||||
}
|
||||
}
|
||||
|
||||
export const requiredUiAdapterCapabilities: readonly UiAdapterCapability[] = Object.freeze([
|
||||
'button', 'text-field', 'text-area', 'select', 'multi-select', 'checkbox', 'date-field',
|
||||
'number-field', 'dialog', 'status-tag', 'inline-message', 'paginator', 'tabs', 'data-grid'
|
||||
])
|
||||
|
||||
export function assertUiAdapterContract(adapter: UiAdapter): void {
|
||||
if (adapter.descriptor.contractVersion !== '4.0') {
|
||||
throw new Error(`Unsupported UI adapter contract: ${adapter.descriptor.contractVersion}`)
|
||||
}
|
||||
const missing = requiredUiAdapterCapabilities.filter(x => !adapter.descriptor.capabilities.has(x))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`UI adapter ${adapter.descriptor.id} is missing capabilities: ${missing.join(', ')}`)
|
||||
}
|
||||
const componentNames = [
|
||||
'Button', 'TextField', 'TextArea', 'Select', 'MultiSelect', 'Checkbox', 'DateField',
|
||||
'NumberField', 'Dialog', 'StatusTag', 'InlineMessage', 'Paginator', 'Tabs', 'DataGrid'
|
||||
] as const
|
||||
for (const name of componentNames) {
|
||||
if (!adapter.components[name]) throw new Error(`UI adapter ${adapter.descriptor.id} has no component for ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const uiAdapterKey: InjectionKey<UiAdapter> = Symbol('KArtSellUiAdapterV4')
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiButtonType, UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), { severity: 'primary', type: 'button', disabled: false, loading: false })
|
||||
const emit = defineEmits<{ activate: [event: MouseEvent] }>()
|
||||
</script>
|
||||
<template><button class="ks-native-button" :class="`is-${severity}`" :type="type" :disabled="disabled || loading" @click="emit('activate', $event)"><span v-if="loading" aria-hidden="true">…</span><slot>{{ label }}</slot></button></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: boolean; inputId?: string; disabled?: boolean; invalid?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-checkbox" type="checkbox" :checked="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" @change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../contracts'
|
||||
withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||
const emit = defineEmits<{ 'row-selected': [row: unknown] }>()
|
||||
function value(row: unknown, field: string): unknown { return typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[field] : undefined }
|
||||
</script>
|
||||
<template><div class="ks-native-grid" :style="{ maxHeight: height }" :aria-busy="loading"><p v-if="loading" role="status">불러오는 중입니다.</p><table><thead><tr><th v-for="column in columns" :key="column.field" scope="col" :style="{ width: column.width ? `${column.width}px` : undefined, minWidth: column.minWidth ? `${column.minWidth}px` : undefined }">{{ column.header }}</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="index" tabindex="0" @click="emit('row-selected', row)" @keydown.enter="emit('row-selected', row)"><td v-for="column in columns" :key="column.field">{{ column.formatter ? column.formatter(value(row, column.field), row) : value(row, column.field) }}</td></tr><tr v-if="!loading && rows.length === 0"><td :colspan="columns.length">조회 결과가 없습니다.</td></tr></tbody></table></div></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string | Date | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: Date; max?: Date }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||
function toDateValue(value: string | Date | null): string { if (!value) return ''; if (value instanceof Date) return value.toISOString().slice(0, 10); return value.slice(0, 10) }
|
||||
function boundary(value?: Date): string | undefined { return value?.toISOString().slice(0, 10) }
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-input" type="date" :value="toDateValue(modelValue)" :disabled="disabled" :aria-invalid="invalid || undefined" :min="boundary(min)" :max="boundary(max)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
const props = defineProps<{ visible: boolean; title?: string; modal?: boolean; closeOnEscape?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
const element = ref<HTMLDialogElement | null>(null)
|
||||
watch(() => props.visible, async visible => { await nextTick(); const dialog = element.value; if (!dialog) return; if (visible && !dialog.open) props.modal === false ? dialog.show() : dialog.showModal(); if (!visible && dialog.open) dialog.close() }, { immediate: true })
|
||||
function close(): void { emit('update:visible', false) }
|
||||
</script>
|
||||
<template><dialog ref="element" class="ks-native-dialog" @close="close" @cancel="close"><header><h2>{{ title }}</h2><button type="button" aria-label="닫기" @click="close">×</button></header><section><slot /></section><footer><slot name="footer" /></footer></dialog></template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
</script>
|
||||
<template><div class="ks-inline-message" :data-severity="severity" :role="severity === 'danger' ? 'alert' : 'status'"><strong v-if="title">{{ title }}</strong><span>{{ message }}</span><button v-if="dismissible" type="button" aria-label="메시지 닫기" @click="emit('dismiss')">×</button></div></template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
const props = withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
||||
function update(event: Event): void {
|
||||
const selected = Array.from((event.target as HTMLSelectElement).selectedOptions).map(x => {
|
||||
const option = props.options[Number(x.value)]
|
||||
return option?.value ?? null
|
||||
})
|
||||
emit('update:modelValue', selected)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span>
|
||||
<select multiple :disabled="disabled" :required="required" @change="update">
|
||||
<option v-for="(option, index) in options" :key="`${index}:${option.label}`" :value="index" :disabled="option.disabled" :selected="modelValue.includes(option.value)">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: number | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
function parse(raw: string): number | null { if (raw.trim() === '') return null; const value = Number(raw); return Number.isFinite(value) ? value : null }
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-input" type="number" :value="modelValue ?? ''" :disabled="disabled" :aria-invalid="invalid || undefined" :min="min" :max="max" :step="maxFractionDigits ? 1 / 10 ** maxFractionDigits : 1" @input="emit('update:modelValue', parse(($event.target as HTMLInputElement).value))" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
const pageCount = () => Math.max(1, Math.ceil(props.total / props.pageSize))
|
||||
function move(page: number): void { emit('pageChange', { page: Math.min(Math.max(1, page), pageCount()), pageSize: props.pageSize }) }
|
||||
function size(event: Event): void { emit('pageChange', { page: 1, pageSize: Number((event.target as HTMLSelectElement).value) }) }
|
||||
</script>
|
||||
<template><nav class="ks-paginator" aria-label="목록 페이지"><button type="button" :disabled="disabled || page <= 1" @click="move(page - 1)">이전</button><span>{{ page }} / {{ pageCount() }} · 총 {{ total }}건</span><button type="button" :disabled="disabled || page >= pageCount()" @click="move(page + 1)">다음</button><label>페이지 크기 <select :value="pageSize" :disabled="disabled" @change="size"><option v-for="item in pageSizes" :key="item" :value="item">{{ item }}</option></select></label></nav></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
const props = defineProps<{ modelValue: unknown; inputId?: string; options: UiSelectOption[]; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||
function encode(value: UiSelectOption['value']): string { return JSON.stringify(value) }
|
||||
function decode(raw: string): unknown { const option = props.options.find(x => encode(x.value) === raw); return option?.value ?? null }
|
||||
</script>
|
||||
<template><select :id="inputId" class="ks-native-input" :value="encode(modelValue as UiSelectOption['value'])" :disabled="disabled" :aria-invalid="invalid || undefined" @change="emit('update:modelValue', decode(($event.target as HTMLSelectElement).value))" @blur="emit('blur', $event)"><option v-if="placeholder" value="" disabled>{{ placeholder }}</option><option v-for="option in options" :key="encode(option.value)" :value="encode(option.value)" :disabled="option.disabled">{{ option.label }}</option></select></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ value: string; severity?: UiSeverity }>(), { severity: 'info' })
|
||||
</script>
|
||||
<template><span class="ks-native-tag" :class="`is-${severity}`">{{ value }}</span></template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiTabItem } from '../contracts'
|
||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; rows?: number; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template><textarea :id="inputId" class="ks-native-input" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :rows="rows ?? 4" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template><input :id="inputId" class="ks-native-input" type="text" :value="modelValue" :disabled="disabled" :aria-invalid="invalid || undefined" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @blur="emit('blur', $event)" /></template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UiAdapter, UiAdapterCapability } from '../contracts'
|
||||
import Button from './NativeButtonAdapter.vue'
|
||||
import TextField from './NativeTextFieldAdapter.vue'
|
||||
import TextArea from './NativeTextAreaAdapter.vue'
|
||||
import Select from './NativeSelectAdapter.vue'
|
||||
import MultiSelect from './NativeMultiSelectAdapter.vue'
|
||||
import Checkbox from './NativeCheckboxAdapter.vue'
|
||||
import DateField from './NativeDateFieldAdapter.vue'
|
||||
import NumberField from './NativeNumberFieldAdapter.vue'
|
||||
import Dialog from './NativeDialogAdapter.vue'
|
||||
import StatusTag from './NativeStatusTagAdapter.vue'
|
||||
import InlineMessage from './NativeInlineMessageAdapter.vue'
|
||||
import Paginator from './NativePaginatorAdapter.vue'
|
||||
import Tabs from './NativeTabsAdapter.vue'
|
||||
import DataGrid from './NativeDataGridAdapter.vue'
|
||||
|
||||
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
|
||||
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
|
||||
'dialog','status-tag','inline-message','paginator','tabs','data-grid'
|
||||
])
|
||||
|
||||
export const nativeUiAdapter: UiAdapter = Object.freeze({
|
||||
descriptor: Object.freeze({ id: 'native-accessible', version: '2.0.0', contractVersion: '4.0', vendor: 'HTML platform primitives', capabilities, productionEligible: false, accessibilityBaseline: 'WCAG_2_2_AA_TARGET' }),
|
||||
components: Object.freeze({ Button, TextField, TextArea, Select, MultiSelect, Checkbox, DateField, NumberField, Dialog, StatusTag, InlineMessage, Paginator, Tabs, DataGrid })
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { App } from 'vue'
|
||||
import type { UiProvider } from '../../provider/UiProvider'
|
||||
import { installUiAdapter } from '../useUiAdapter'
|
||||
import { nativeUiAdapter } from './index'
|
||||
import './native.css'
|
||||
|
||||
export const nativeUiProvider: UiProvider = {
|
||||
id: 'native-accessible',
|
||||
install(app: App): void { installUiAdapter(app, nativeUiAdapter) }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.ks-native-button,.ks-native-input,.ks-native-dialog{font:inherit}.ks-native-button{min-height:2.5rem;padding:.5rem .9rem;border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-sm);background:#fff;cursor:pointer}.ks-native-button.is-primary{background:var(--ks-color-primary-700);border-color:var(--ks-color-primary-700);color:#fff}.ks-native-button:disabled{opacity:.55;cursor:not-allowed}.ks-native-input{width:100%;min-height:2.5rem;padding:.45rem .65rem;border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-sm);background:#fff}.ks-native-input[aria-invalid=true]{border-color:var(--ks-color-danger-600)}.ks-native-checkbox{width:1.15rem;height:1.15rem}.ks-native-dialog{width:min(42rem,calc(100vw - 2rem));border:0;border-radius:var(--ks-radius-md);box-shadow:0 1rem 3rem rgb(15 23 42 / 25%)}.ks-native-dialog::backdrop{background:rgb(15 23 42 / 55%)}.ks-native-dialog header{display:flex;justify-content:space-between;align-items:center}.ks-native-dialog footer{display:flex;justify-content:flex-end;gap:var(--ks-space-2)}.ks-native-tag{display:inline-flex;padding:.2rem .55rem;border-radius:999px;background:var(--ks-color-neutral-100)}.ks-native-tag.is-warning{background:#fef3c7}.ks-native-tag.is-danger{background:#fee2e2}.ks-native-tag.is-success{background:#dcfce7}.ks-native-grid{overflow:auto;border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.ks-native-grid table{width:100%;border-collapse:collapse}.ks-native-grid th,.ks-native-grid td{padding:.65rem;border-bottom:1px solid var(--ks-color-neutral-200);text-align:left}.ks-native-grid tbody tr:focus{outline:2px solid var(--ks-color-primary-700);outline-offset:-2px}
|
||||
.ks-inline-message{display:flex;gap:.5rem;align-items:flex-start;padding:.75rem;border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm);background:#fff}.ks-inline-message[data-severity="danger"]{border-color:#b91c1c}.ks-inline-message[data-severity="warning"]{border-color:#b45309}.ks-paginator,.ks-tabs{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.ks-tabs [aria-selected="true"]{font-weight:700;border-bottom:2px solid currentColor}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import {
|
||||
AllCommunityModule,
|
||||
ModuleRegistry,
|
||||
themeQuartz,
|
||||
type ColDef,
|
||||
type RowClickedEvent
|
||||
} from 'ag-grid-community'
|
||||
import type { UiGridColumn } from '../contracts'
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
loading?: boolean
|
||||
height?: string
|
||||
rowSelection?: 'single' | 'multiple' | 'none'
|
||||
}>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||
|
||||
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
|
||||
|
||||
const columnDefs = computed<ColDef[]>(() => props.columns.map(column => ({
|
||||
field: column.field,
|
||||
headerName: column.header,
|
||||
width: column.width,
|
||||
minWidth: column.minWidth ?? 120,
|
||||
sortable: column.sortable ?? true,
|
||||
filter: column.filterable ?? true,
|
||||
valueFormatter: column.formatter
|
||||
? params => column.formatter?.(params.value, params.data) ?? ''
|
||||
: undefined
|
||||
})))
|
||||
|
||||
const rowSelectionOptions = computed(() => {
|
||||
if (props.rowSelection === 'none') return undefined
|
||||
return props.rowSelection === 'multiple'
|
||||
? ({ mode: 'multiRow' } as const)
|
||||
: ({ mode: 'singleRow' } as const)
|
||||
})
|
||||
|
||||
function onRowClicked(event: RowClickedEvent): void {
|
||||
if (event.data) emit('rowSelected', event.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ks-grid" :style="{ height }" :aria-busy="loading">
|
||||
<AgGridVue
|
||||
style="height: 100%; width: 100%"
|
||||
:theme="themeQuartz"
|
||||
:row-data="rows"
|
||||
:column-defs="columnDefs"
|
||||
:row-selection="rowSelectionOptions"
|
||||
:loading="loading"
|
||||
@row-clicked="onRowClicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-grid {
|
||||
min-height: 12rem;
|
||||
border: 1px solid var(--ks-color-neutral-200);
|
||||
border-radius: var(--ks-radius-md);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import type { UiButtonType, UiSeverity } from '../contracts'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
severity?: UiSeverity
|
||||
type?: UiButtonType
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(), { severity: 'primary', type: 'button', disabled: false, loading: false })
|
||||
|
||||
defineEmits<{ activate: [event: MouseEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
class="ks-button"
|
||||
:label="label"
|
||||
:severity="severity"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
@click="$emit('activate', $event)"
|
||||
>
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-button { min-height: var(--ks-control-height); padding: 0 var(--ks-space-4); border: 0; border-radius: var(--ks-radius-sm); background: var(--ks-color-action); color: #fff; font-weight: 650; cursor: pointer; }
|
||||
.ks-button:hover:not(:disabled) { background: var(--ks-color-action-hover); }
|
||||
.ks-button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
|
||||
defineProps<{ modelValue: boolean; inputId?: string; disabled?: boolean }>()
|
||||
defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Checkbox
|
||||
class="ks-checkbox"
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
binary
|
||||
:disabled="disabled"
|
||||
@update:model-value="$emit('update:modelValue', Boolean($event))"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
defineProps<{ modelValue: string | Date | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: Date; max?: Date }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template>
|
||||
<DatePicker
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:min-date="min"
|
||||
:max-date="max"
|
||||
date-format="yy-mm-dd"
|
||||
show-icon
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
|
||||
defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
class="ks-dialog"
|
||||
:visible="visible"
|
||||
:header="title"
|
||||
:modal="modal ?? true"
|
||||
:closable="closable ?? true"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
>
|
||||
<slot />
|
||||
<template #footer><slot name="footer" /></template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import type { UiSeverity } from '../contracts'
|
||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
const map = { primary: 'info', secondary: 'secondary', success: 'success', info: 'info', warning: 'warn', danger: 'error' } as const
|
||||
</script>
|
||||
<template><Message :severity="map[severity]" :closable="dismissible" @close="emit('dismiss')"><strong v-if="title">{{ title }} </strong>{{ message }}</Message></template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
||||
</script>
|
||||
<template><label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span><MultiSelect :model-value="modelValue" :options="options" option-label="label" option-value="value" option-disabled="disabled" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /></label></template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
defineProps<{ modelValue: number | null; inputId?: string; disabled?: boolean; invalid?: boolean; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
<template>
|
||||
<InputNumber
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:min-fraction-digits="minFractionDigits"
|
||||
:max-fraction-digits="maxFractionDigits"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Paginator from 'primevue/paginator'
|
||||
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
</script>
|
||||
<template><Paginator :first="(page - 1) * pageSize" :rows="pageSize" :total-records="total" :rows-per-page-options="pageSizes" :disabled="disabled" @page="emit('pageChange', { page: $event.page + 1, pageSize: $event.rows })" /></template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import type { UiSelectOption } from '../contracts'
|
||||
|
||||
defineProps<{ modelValue: unknown; inputId?: string; options: UiSelectOption[]; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select
|
||||
class="ks-select"
|
||||
:input-id="inputId"
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
option-disabled="disabled"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-select { width: 100%; min-height: var(--ks-control-height); border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); background: #fff; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
import type { UiSeverity } from '../contracts'
|
||||
|
||||
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag class="ks-status-tag" :severity="severity ?? 'info'">
|
||||
<span v-if="iconLabel" aria-hidden="true">{{ iconLabel }}</span>
|
||||
<span>{{ value }}</span>
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-status-tag { display: inline-flex; align-items: center; gap: var(--ks-space-1); border-radius: 999px; padding: var(--ks-space-1) var(--ks-space-2); border: 1px solid currentColor; font-size: var(--ks-font-caption); line-height: var(--ks-line-caption); }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiTabItem } from '../contracts'
|
||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import Textarea from 'primevue/textarea'
|
||||
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; rows?: number; placeholder?: string }>()
|
||||
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Textarea
|
||||
class="ks-textarea"
|
||||
:id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:rows="rows ?? 4"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', String($event ?? ''))"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-textarea { width: 100%; border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); padding: var(--ks-space-3); background: #fff; color: var(--ks-color-neutral-950); resize: vertical; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
defineProps<{ modelValue: string; inputId?: string; disabled?: boolean; invalid?: boolean; placeholder?: string }>()
|
||||
defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InputText
|
||||
class="ks-input"
|
||||
:id="inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="invalid"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', String($event ?? ''))"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-input { width: 100%; min-height: var(--ks-control-height); border: 1px solid var(--ks-color-neutral-300); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-3); background: #fff; color: var(--ks-color-neutral-950); }
|
||||
.ks-input[aria-invalid='true'] { border-color: var(--ks-color-danger); }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
/* PrimeVue v4 is intentionally installed in unstyled mode. Vendor DOM classes stay in this adapter boundary. */
|
||||
.p-dialog-mask { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: var(--ks-space-4); background: rgb(15 23 42 / 48%); }
|
||||
.p-dialog.ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
|
||||
.p-dialog.ks-dialog .p-dialog-header, .p-dialog.ks-dialog .p-dialog-content, .p-dialog.ks-dialog .p-dialog-footer { padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog .p-dialog-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--ks-color-neutral-200); font-weight: 700; }
|
||||
.p-dialog.ks-dialog .p-dialog-footer { display: flex; justify-content: flex-end; gap: var(--ks-space-2); border-top: 1px solid var(--ks-color-neutral-200); }
|
||||
.p-select-overlay { z-index: 1100; min-width: 12rem; overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-sm); background: #fff; box-shadow: var(--ks-shadow-md); }
|
||||
.p-select-list { margin: 0; padding: var(--ks-space-1); list-style: none; }
|
||||
.p-select-option { padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); cursor: pointer; }
|
||||
.p-select-option.p-focus, .p-select-option:hover { background: var(--ks-color-neutral-100); }
|
||||
.p-checkbox.ks-checkbox { display: inline-grid; width: 1.25rem; height: 1.25rem; place-items: center; border: 1px solid var(--ks-color-neutral-400); border-radius: .25rem; background: #fff; }
|
||||
.p-checkbox.ks-checkbox.p-checked { border-color: var(--ks-color-action); background: var(--ks-color-action); color: #fff; }
|
||||
.ks-tabs{display:flex;gap:.5rem;flex-wrap:wrap}.ks-tabs [aria-selected="true"]{font-weight:700;border-bottom:2px solid currentColor}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UiAdapter, UiAdapterCapability } from '../contracts'
|
||||
import Button from './PrimeButtonAdapter.vue'
|
||||
import TextField from './PrimeTextFieldAdapter.vue'
|
||||
import TextArea from './PrimeTextAreaAdapter.vue'
|
||||
import Select from './PrimeSelectAdapter.vue'
|
||||
import MultiSelect from './PrimeMultiSelectAdapter.vue'
|
||||
import Checkbox from './PrimeCheckboxAdapter.vue'
|
||||
import DateField from './PrimeDateFieldAdapter.vue'
|
||||
import NumberField from './PrimeNumberFieldAdapter.vue'
|
||||
import Dialog from './PrimeDialogAdapter.vue'
|
||||
import StatusTag from './PrimeStatusTagAdapter.vue'
|
||||
import InlineMessage from './PrimeInlineMessageAdapter.vue'
|
||||
import Paginator from './PrimePaginatorAdapter.vue'
|
||||
import Tabs from './PrimeTabsAdapter.vue'
|
||||
import DataGrid from './AgGridAdapter.vue'
|
||||
|
||||
const capabilities: ReadonlySet<UiAdapterCapability> = new Set([
|
||||
'button','text-field','text-area','select','multi-select','checkbox','date-field','number-field',
|
||||
'dialog','status-tag','inline-message','paginator','tabs','data-grid'
|
||||
])
|
||||
|
||||
export const primeVueUiAdapter: UiAdapter = Object.freeze({
|
||||
descriptor: Object.freeze({ id: 'primevue-aggrid', version: '4.x+34.x', contractVersion: '4.0', vendor: 'PrimeVue + AG Grid Community', capabilities, productionEligible: true, accessibilityBaseline: 'WCAG_2_2_AA_TARGET' }),
|
||||
components: Object.freeze({ Button, TextField, TextArea, Select, MultiSelect, Checkbox, DateField, NumberField, Dialog, StatusTag, InlineMessage, Paginator, Tabs, DataGrid })
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { App } from 'vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import type { UiProvider } from '../../provider/UiProvider'
|
||||
import { installUiAdapter } from '../useUiAdapter'
|
||||
import { primeVueUiAdapter } from './index'
|
||||
import './adapter.css'
|
||||
|
||||
export const primeVueUiProvider: UiProvider = {
|
||||
id: 'primevue-aggrid',
|
||||
install(app: App): void {
|
||||
app.use(PrimeVue, { unstyled: true })
|
||||
installUiAdapter(app, primeVueUiAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
export function installPrimeVueAdapter(app: App): void {
|
||||
primeVueUiProvider.install(app)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertUiAdapterContract, requiredUiAdapterCapabilities } from '../contracts'
|
||||
import { nativeUiAdapter } from '../native'
|
||||
import { primeVueUiAdapter } from '../primevue'
|
||||
|
||||
describe.each([nativeUiAdapter, primeVueUiAdapter])('UI adapter $descriptor.id', adapter => {
|
||||
it('implements the complete v4 normalized contract', () => {
|
||||
expect(() => assertUiAdapterContract(adapter)).not.toThrow()
|
||||
expect(adapter.descriptor.contractVersion).toBe('4.0')
|
||||
expect(adapter.descriptor.capabilities.size).toBe(requiredUiAdapterCapabilities.length)
|
||||
expect(adapter.descriptor.accessibilityBaseline).toBe('WCAG_2_2_AA_TARGET')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { App } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import type { UiAdapter } from './contracts'
|
||||
import { assertUiAdapterContract, uiAdapterKey } from './contracts'
|
||||
|
||||
export function installUiAdapter(app: App, adapter: UiAdapter): void {
|
||||
assertUiAdapterContract(adapter)
|
||||
app.provide(uiAdapterKey, adapter)
|
||||
}
|
||||
|
||||
export function useUiAdapter(): UiAdapter {
|
||||
const adapter = inject(uiAdapterKey)
|
||||
if (!adapter) {
|
||||
throw new Error('UI adapter is not installed. Install a validated provider during app bootstrap.')
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
const props = defineProps<{ label: string; inputId?: string; required?: boolean; error?: string; help?: string }>()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`)
|
||||
const messageId = computed(() => props.error || props.help ? `${resolvedId.value}-message` : undefined)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-field-shell" :data-invalid="Boolean(error) || undefined">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<slot :input-id="resolvedId" :described-by="messageId" :invalid="Boolean(error)" />
|
||||
<small v-if="error || help" :id="messageId" :class="{ 'ks-danger-text': error }" :role="error ? 'alert' : undefined">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-field-shell{display:grid;gap:var(--ks-space-1)}
|
||||
label{font-weight:650} small{color:var(--ks-color-neutral-600)}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiButtonType, UiSeverity } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
|
||||
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), {
|
||||
severity: 'primary', type: 'button', disabled: false, loading: false
|
||||
})
|
||||
const emit = defineEmits<{ click: [event: MouseEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="adapter.components.Button" v-bind="$props" @activate="emit('click', $event)"><slot /></component>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: boolean; label: string; inputId?: string; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-check-${generatedId}`)
|
||||
</script>
|
||||
<template><label class="ks-check" :for="resolvedId"><component :is="adapter.components.Checkbox" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /><span>{{ label }}</span></label></template>
|
||||
<style scoped>.ks-check { display: inline-flex; align-items: center; gap: var(--ks-space-2); cursor: pointer; }</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import KsButton from './KsButton.vue'
|
||||
export interface CommandBarAction { id: string; label: string; severity?: 'primary'|'secondary'|'success'|'info'|'warning'|'danger'; disabled?: boolean; busy?: boolean }
|
||||
defineProps<{ actions: readonly CommandBarAction[]; ariaLabel?: string }>()
|
||||
const emit = defineEmits<{ execute: [actionId: string] }>()
|
||||
</script>
|
||||
<template><nav class="ks-command-bar" :aria-label="ariaLabel ?? 'Page actions'"><KsButton v-for="action in actions" :key="action.id" :label="action.label" :severity="action.severity" :disabled="action.disabled" :loading="action.busy" @click="emit('execute', action.id)" /></nav></template>
|
||||
<style scoped>.ks-command-bar{display:flex;gap:var(--ks-space-2);flex-wrap:wrap;justify-content:flex-end}</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { VersionSet } from '../../contracts/versionSet'
|
||||
import EvidenceVersionSet from '../EvidenceVersionSet.vue'
|
||||
const props = defineProps<{
|
||||
title: string; asOf: string; datasetId: string; dataHash: string; modelVersion: string; configVersion: string;
|
||||
codeSha: string; contractVersion: string; projectionVersion?: string; watermark?: string;
|
||||
stale?: boolean; correlationId?: string
|
||||
}>()
|
||||
const versionSet = computed<VersionSet>(() => ({
|
||||
datasetId: props.datasetId,
|
||||
dataHash: props.dataHash,
|
||||
modelVersion: props.modelVersion,
|
||||
configVersion: props.configVersion,
|
||||
codeSha: props.codeSha,
|
||||
contractVersion: props.contractVersion
|
||||
}))
|
||||
</script>
|
||||
<template>
|
||||
<header class="ks-data-context" :data-stale="stale || undefined">
|
||||
<div><h1>{{ title }}</h1><p>As-of {{ asOf }} <strong v-if="stale">STALE</strong></p></div>
|
||||
<EvidenceVersionSet :value="versionSet" compact />
|
||||
<dl v-if="projectionVersion || watermark || correlationId">
|
||||
<template v-if="projectionVersion"><dt>Projection</dt><dd>{{ projectionVersion }}</dd></template>
|
||||
<template v-if="watermark"><dt>Watermark</dt><dd>{{ watermark }}</dd></template>
|
||||
<template v-if="correlationId"><dt>Correlation</dt><dd>{{ correlationId }}</dd></template>
|
||||
</dl>
|
||||
</header>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-data-context{display:grid;gap:var(--ks-space-3);padding:var(--ks-space-4);border:1px solid var(--ks-color-neutral-300);border-radius:var(--ks-radius-md)}
|
||||
.ks-data-context[data-stale=true]{border-color:var(--ks-color-warning-500)} h1,p{margin:0} dl{display:flex;gap:var(--ks-space-3);margin:0;flex-wrap:wrap} dt{font-weight:700} dd{margin:0}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.DataGrid" v-bind="$props" @row-selected="emit('rowSelected', $event)" /></template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: string | Date | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: Date; max?: Date }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-date-${generatedId}`)
|
||||
</script>
|
||||
<template><div class="ks-field"><label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label><component :is="adapter.components.DateField" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :min="min" :max="max" :aria-describedby="error || help ? `${resolvedId}-message` : undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /><small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small></div></template>
|
||||
<style scoped>.ks-field{display:grid;gap:var(--ks-space-1)}label{font-weight:650}small{color:var(--ks-color-neutral-600)}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.Dialog" v-bind="$props" @update:visible="emit('update:visible', $event)"><slot /><template #footer><slot name="footer" /></template></component></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.InlineMessage" v-bind="$props" @dismiss="emit('dismiss')" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSelectOption } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.MultiSelect" v-bind="$props" @update:model-value="emit('update:modelValue', $event)" /></template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: number | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-number-${generatedId}`)
|
||||
</script>
|
||||
<template><div class="ks-field"><label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label><component :is="adapter.components.NumberField" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="error || help ? `${resolvedId}-message` : undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /><small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small></div></template>
|
||||
<style scoped>.ks-field{display:grid;gap:var(--ks-space-1)}label{font-weight:650}small{color:var(--ks-color-neutral-600)}</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.Paginator" v-bind="$props" @page-change="emit('pageChange', $event)" /></template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import type { UiSelectOption } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: unknown; label: string; options: UiSelectOption[]; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-select-${generatedId}`)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-field">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<component :is="adapter.components.Select" :input-id="resolvedId" :model-value="modelValue" :options="options" :disabled="disabled" :invalid="Boolean(error)" :placeholder="placeholder" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
|
||||
<small v-if="error || help" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.ks-field { display: grid; gap: var(--ks-space-1); } label { font-weight: 650; } small { color: var(--ks-color-neutral-600); }</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiSeverity } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.StatusTag" v-bind="$props" /></template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiTabItem } from '../adapter/contracts'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template><component :is="adapter.components.Tabs" v-bind="$props" @update:model-value="emit('update:modelValue', $event)"><template #default="slotProps"><slot :active-id="slotProps.activeId" /></template></component></template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; rows?: number; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-area-${generatedId}`)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ks-field">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<component :is="adapter.components.TextArea" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" :invalid="Boolean(error)" :rows="rows" :placeholder="placeholder" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
|
||||
<small v-if="error || help" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.ks-field { display: grid; gap: var(--ks-space-1); } label { font-weight: 650; } small { color: var(--ks-color-neutral-600); }</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
|
||||
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
const generatedId = useId()
|
||||
const resolvedId = computed(() => props.inputId ?? `ks-field-${generatedId}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ks-field">
|
||||
<label :for="resolvedId">{{ label }} <span v-if="required" aria-hidden="true">*</span></label>
|
||||
<component
|
||||
:is="adapter.components.TextField"
|
||||
:input-id="resolvedId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="Boolean(error)"
|
||||
:placeholder="placeholder"
|
||||
:aria-describedby="error || help ? `${resolvedId}-message` : undefined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<small v-if="error || help" :id="`${resolvedId}-message`" :class="{ 'ks-danger-text': error }">{{ error ?? help }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-field { display: grid; gap: var(--ks-space-1); }
|
||||
label { font-weight: 650; }
|
||||
small { color: var(--ks-color-neutral-600); }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
export { default as KsButton } from './KsButton.vue'
|
||||
export { default as KsTextField } from './KsTextField.vue'
|
||||
export { default as KsTextArea } from './KsTextArea.vue'
|
||||
export { default as KsSelect } from './KsSelect.vue'
|
||||
export { default as KsMultiSelect } from './KsMultiSelect.vue'
|
||||
export { default as KsCheckbox } from './KsCheckbox.vue'
|
||||
export { default as KsDateField } from './KsDateField.vue'
|
||||
export { default as KsNumberField } from './KsNumberField.vue'
|
||||
export { default as KsDialog } from './KsDialog.vue'
|
||||
export { default as KsStatusTag } from './KsStatusTag.vue'
|
||||
export { default as KsInlineMessage } from './KsInlineMessage.vue'
|
||||
export { default as KsPaginator } from './KsPaginator.vue'
|
||||
export { default as KsTabs } from './KsTabs.vue'
|
||||
export { default as KsDataGrid } from './KsDataGrid.vue'
|
||||
export { default as FieldShell } from './FieldShell.vue'
|
||||
export { default as KsDataContextHeader } from './KsDataContextHeader.vue'
|
||||
export { default as KsCommandBar } from './KsCommandBar.vue'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user