V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
/* 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); }
|
||||
/* PrimeVue 4.5.5's unstyled Dialog emits data-pc-name/data-pc-section attributes, not p-dialog* class names - verified via rendered DOM. */
|
||||
[data-pc-section="mask"]:has(> [data-pc-name="dialog"]) { background: rgb(15 23 42 / 48%); padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog, [data-pc-name="dialog"][data-pc-section="root"].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,
|
||||
.ks-dialog [data-pc-section="header"], .ks-dialog [data-pc-section="content"], .ks-dialog [data-pc-section="footer"] { padding: var(--ks-space-4); }
|
||||
.p-dialog.ks-dialog .p-dialog-header, .ks-dialog [data-pc-section="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, .ks-dialog [data-pc-section="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; }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
import FieldShell from './FieldShell.vue'
|
||||
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; currency?: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number }>(), { currency: 'KRW' })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
</script>
|
||||
<template>
|
||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
||||
<span class="ks-money-field">
|
||||
<component
|
||||
:is="adapter.components.NumberField"
|
||||
:input-id="field.inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="field.invalid"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="0"
|
||||
:aria-describedby="field.describedBy"
|
||||
:aria-required="field.required || undefined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<span class="ks-money-field__currency" aria-hidden="true">{{ props.currency }}</span>
|
||||
</span>
|
||||
</FieldShell>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-money-field { display: inline-flex; align-items: center; gap: var(--ks-space-2); }
|
||||
.ks-money-field__currency { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
||||
import FieldShell from './FieldShell.vue'
|
||||
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; unit?: string; availableQuantity?: number; allowNegative?: boolean; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string }>(), { unit: '주', allowNegative: false })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||
const adapter = useUiAdapter()
|
||||
|
||||
const exceedsAvailable = computed(() =>
|
||||
props.modelValue != null && props.availableQuantity != null && props.modelValue > props.availableQuantity
|
||||
)
|
||||
const effectiveError = computed(() => props.error ?? (exceedsAvailable.value ? `가용 수량(${props.availableQuantity} ${props.unit})을 초과했습니다.` : undefined))
|
||||
const effectiveHelp = computed(() => props.availableQuantity != null ? `가용 ${props.availableQuantity} ${props.unit}` : props.help)
|
||||
</script>
|
||||
<template>
|
||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="effectiveError" :help="effectiveHelp" v-slot="field">
|
||||
<span class="ks-quantity-field">
|
||||
<component
|
||||
:is="adapter.components.NumberField"
|
||||
:input-id="field.inputId"
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:invalid="field.invalid"
|
||||
:min="allowNegative ? undefined : 0"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="0"
|
||||
:aria-describedby="field.describedBy"
|
||||
:aria-required="field.required || undefined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@blur="emit('blur', $event)"
|
||||
/>
|
||||
<span class="ks-quantity-field__unit" aria-hidden="true">{{ props.unit }}</span>
|
||||
</span>
|
||||
</FieldShell>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ks-quantity-field { display: inline-flex; align-items: center; gap: var(--ks-space-2); }
|
||||
.ks-quantity-field__unit { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); font-weight: 600; }
|
||||
</style>
|
||||
@@ -6,6 +6,8 @@ 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 KsMoneyField } from './KsMoneyField.vue'
|
||||
export { default as KsQuantityField } from './KsQuantityField.vue'
|
||||
export { default as KsDialog } from './KsDialog.vue'
|
||||
export { default as KsStatusTag } from './KsStatusTag.vue'
|
||||
export { default as KsInlineMessage } from './KsInlineMessage.vue'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { uiAdapterKey } from '../../adapter/contracts'
|
||||
import { nativeUiAdapter } from '../../adapter/native'
|
||||
import KsMoneyField from '../KsMoneyField.vue'
|
||||
import KsQuantityField from '../KsQuantityField.vue'
|
||||
|
||||
const globalProvide = { global: { provide: { [uiAdapterKey as unknown as string]: nativeUiAdapter } } }
|
||||
|
||||
describe('KsMoneyField', () => {
|
||||
it('connects the label, shows the currency code, and emits numeric updates', async () => {
|
||||
const wrapper = mount(KsMoneyField, { props: { modelValue: 1250000, label: '단가' }, ...globalProvide })
|
||||
expect(wrapper.get('label').text()).toContain('단가')
|
||||
expect(wrapper.text()).toContain('KRW')
|
||||
await wrapper.get('input').setValue('2000000')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([2000000])
|
||||
})
|
||||
|
||||
it('accepts a non-KRW currency code', () => {
|
||||
const wrapper = mount(KsMoneyField, { props: { modelValue: 100, label: '금액', currency: 'USD' }, ...globalProvide })
|
||||
expect(wrapper.text()).toContain('USD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('KsQuantityField', () => {
|
||||
it('shows the available quantity as help text when under the limit', () => {
|
||||
const wrapper = mount(KsQuantityField, { props: { modelValue: 5, label: '주문수량', unit: 'EA', availableQuantity: 8 }, ...globalProvide })
|
||||
expect(wrapper.text()).toContain('가용 8 EA')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('raises an accessible error when the value exceeds the available quantity', () => {
|
||||
const wrapper = mount(KsQuantityField, { props: { modelValue: 10, label: '주문수량', unit: 'EA', availableQuantity: 8 }, ...globalProvide })
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('가용 수량(8 EA)을 초과했습니다.')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
export const screenTemplateCatalogue = Object.freeze([
|
||||
{ id: 'T01', name: '검색·목록형 CRUD', component: 'SearchListCrudPage', intendedUse: '상품·고객·권한·데이터 Run·추천 검토함', mandatoryStates: ['READY', 'LOADING', 'EMPTY', 'WARN', 'ERROR', 'UNAUTHORIZED', 'FORBIDDEN', 'PARTIAL'], mandatoryEvidence: ['filter-url', 'permission', 'export-auth'], antiPatterns: ['client-side-all-data', 'unbounded-export'] },
|
||||
{ id: 'T02', name: '상세 조회형', component: 'DetailReadPage', intendedUse: '투자제안·상품·포트폴리오·백테스트 결과', mandatoryStates: ['LOADING', 'WARN', 'EXPIRED', 'READONLY', 'UNAUTHORIZED'], mandatoryEvidence: ['as-of', 'version-set', 'audit'], antiPatterns: ['mutable-evidence', 'hidden-version'] },
|
||||
{ id: 'T03', name: '등록·편집 Form', component: 'EditFormPage', intendedUse: '고객·IPS·비용표·권한·설정', mandatoryStates: ['DIRTY', 'CONFLICT', 'PROCESSING', 'ERROR'], mandatoryEvidence: ['zod', 'if-match', 'idempotency-key'], antiPatterns: ['silent-overwrite', 'pinia-form-cache'] },
|
||||
{ id: 'T04', name: 'Master-Detail', component: 'MasterDetailCrudPage', intendedUse: '고객-IPS·추천-항목·Watch-Stage·대사 Run-Break', mandatoryStates: ['LOADING', 'EMPTY', 'DIRTY', 'PARTIAL', 'CONFLICT'], mandatoryEvidence: ['route-selection', 'unsaved-guard', 'version'], antiPatterns: ['selection-only-local', 'detail-n-plus-one'] },
|
||||
{ id: 'T05', name: '검토·승인 Workbench', component: 'ApprovalWorkbenchPage', intendedUse: '추천·모델·정정·대사 maker-checker', mandatoryStates: ['WARN', 'EXPIRED', 'CONFLICT', 'PROCESSING', 'READONLY'], mandatoryEvidence: ['maker-checker', 'reason', 'warning-ack'], antiPatterns: ['self-approval', 'approval-without-evidence'] },
|
||||
{ id: 'T06', name: '단계 Wizard', component: 'StepWizardPage', intendedUse: '고객 온보딩·IPS·리밸런싱·Backfill', mandatoryStates: ['DIRTY', 'ERROR', 'PROCESSING', 'READONLY'], mandatoryEvidence: ['resume', 'branch', 'impact-revalidation'], antiPatterns: ['single-huge-form', 'skip-validation'] },
|
||||
{ id: 'T07', name: 'Dashboard·Scorecard', component: 'ScorecardDashboardPage', intendedUse: '고객 대시보드·일평가·운영 SLO', mandatoryStates: ['LOADING', 'EMPTY', 'WARN', 'PARTIAL'], mandatoryEvidence: ['metric-definition', 'sample-size', 'table-alternative'], antiPatterns: ['chart-only', 'metric-definition-hidden'] },
|
||||
{ id: 'T08', name: 'Batch·데이터 운영', component: 'BatchOperationsPageV2', intendedUse: '수집·Feature·추천·평가·Backfill', mandatoryStates: ['PROCESSING', 'WARN', 'ERROR', 'PARTIAL', 'READONLY'], mandatoryEvidence: ['job-run', 'watermark', 'replay-scope'], antiPatterns: ['blind-retry', 'overwrite-reprocess'] },
|
||||
{ id: 'T09', name: '대사·예외 처리', component: 'ReconciliationExceptionPage', intendedUse: 'KIS/원장 대사·데이터 격리·DQ 예외', mandatoryStates: ['WARN', 'CONFLICT', 'PROCESSING', 'READONLY'], mandatoryEvidence: ['before-after', 'correction', 'audit'], antiPatterns: ['direct-db-fix', 'delete-break'] },
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN', 'EXPIRED', 'READONLY', 'CONFLICT'], mandatoryEvidence: ['same-dataset-cost', 'gate-pack', 'rollback'], antiPatterns: ['auto-promotion', 'different-cohort-comparison'] }
|
||||
]);
|
||||
export function getScreenTemplate(id) {
|
||||
const template = screenTemplateCatalogue.find(x => x.id === id);
|
||||
if (!template)
|
||||
throw new Error(`Unknown screen template: ${id}`);
|
||||
return template;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ScreenTemplateId = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09' | 'T10'
|
||||
export type ScreenTemplateId = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09' | 'T10' | 'T11' | 'T12'
|
||||
|
||||
export interface ScreenTemplateDefinition {
|
||||
id: ScreenTemplateId
|
||||
@@ -20,7 +20,9 @@ export const screenTemplateCatalogue: readonly ScreenTemplateDefinition[] = Obje
|
||||
{ id: 'T07', name: 'Dashboard·Scorecard', component: 'ScorecardDashboardPage', intendedUse: '고객 대시보드·일평가·운영 SLO', mandatoryStates: ['LOADING','EMPTY','WARN','PARTIAL'], mandatoryEvidence: ['metric-definition','sample-size','table-alternative'], antiPatterns: ['chart-only','metric-definition-hidden'] },
|
||||
{ id: 'T08', name: 'Batch·데이터 운영', component: 'BatchOperationsPageV2', intendedUse: '수집·Feature·추천·평가·Backfill', mandatoryStates: ['PROCESSING','WARN','ERROR','PARTIAL','READONLY'], mandatoryEvidence: ['job-run','watermark','replay-scope'], antiPatterns: ['blind-retry','overwrite-reprocess'] },
|
||||
{ id: 'T09', name: '대사·예외 처리', component: 'ReconciliationExceptionPage', intendedUse: 'KIS/원장 대사·데이터 격리·DQ 예외', mandatoryStates: ['WARN','CONFLICT','PROCESSING','READONLY'], mandatoryEvidence: ['before-after','correction','audit'], antiPatterns: ['direct-db-fix','delete-break'] },
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN','EXPIRED','READONLY','CONFLICT'], mandatoryEvidence: ['same-dataset-cost','gate-pack','rollback'], antiPatterns: ['auto-promotion','different-cohort-comparison'] }
|
||||
{ id: 'T10', name: '버전 비교·거버넌스', component: 'VersionGovernancePage', intendedUse: '모델·정책·설정·데이터 공급원 승격', mandatoryStates: ['WARN','EXPIRED','READONLY','CONFLICT'], mandatoryEvidence: ['same-dataset-cost','gate-pack','rollback'], antiPatterns: ['auto-promotion','different-cohort-comparison'] },
|
||||
{ id: 'T11', name: '대량 입력(Fast Grid Entry)', component: 'FastEntryGridPage', intendedUse: '임계값·파라미터·비용표 등 다건 일괄 입력', mandatoryStates: ['DIRTY','PROCESSING','ERROR','PARTIAL'], mandatoryEvidence: ['cell-level-validation','paste-audit','idempotency-key'], antiPatterns: ['silent-bulk-overwrite','unbounded-paste'] },
|
||||
{ id: 'T12', name: '작업 큐(Work Queue)', component: 'WorkQueuePage', intendedUse: 'SLA 큐 깊이·DQ 격리·대사 Break 등 예외구동 작업목록', mandatoryStates: ['LOADING','EMPTY','WARN','PARTIAL'], mandatoryEvidence: ['queue-depth-source','exception-count-definition'], antiPatterns: ['chart-only-no-actionable-list'] }
|
||||
])
|
||||
|
||||
export function getScreenTemplate(id: ScreenTemplateId): ScreenTemplateDefinition {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getScreenTemplate, screenTemplateCatalogue } from '../catalogue';
|
||||
describe('screen template catalogue', () => {
|
||||
it('defines all ten screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(10);
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(10);
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(10);
|
||||
});
|
||||
it('requires evidence and anti-pattern declarations for every template', () => {
|
||||
for (const template of screenTemplateCatalogue) {
|
||||
expect(template.mandatoryEvidence.length).toBeGreaterThan(0);
|
||||
expect(template.mandatoryStates.length).toBeGreaterThan(0);
|
||||
expect(template.antiPatterns.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
it('resolves a template by stable ID', () => {
|
||||
expect(getScreenTemplate('T05').component).toBe('ApprovalWorkbenchPage');
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import { getScreenTemplate, screenTemplateCatalogue } from '../catalogue'
|
||||
import type { StandardScreenState } from '../../contracts/screenContract'
|
||||
|
||||
describe('screen template catalogue', () => {
|
||||
it('defines all ten screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(10)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(10)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(10)
|
||||
it('defines all twelve screen contracts without duplicate IDs or components', () => {
|
||||
expect(screenTemplateCatalogue).toHaveLength(12)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.id)).size).toBe(12)
|
||||
expect(new Set(screenTemplateCatalogue.map(x => x.component)).size).toBe(12)
|
||||
})
|
||||
|
||||
it('requires evidence and anti-pattern declarations for every template', () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf" :version="props.evidence?.version"><template #actions><slot name="actions"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section class="ks-stack"><div class="ks-card ks-section"><slot name="grid"/></div><div v-if="$slots.validationSummary" class="ks-card ks-section"><slot name="validationSummary"/></div></section></StandardScreenBoundary><template #summary><slot name="total"/></template></PageLayout></template>
|
||||
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import PageLayout from '../../layouts/PageLayout.vue'; import StandardScreenBoundary from './StandardScreenBoundary.vue'; import type { StandardScreenProps } from '../../contracts/screenContract'; const props=defineProps<StandardScreenProps>(); defineEmits<{retry:[]}>()</script>
|
||||
<template><PageLayout :title="props.title" :subtitle="props.subtitle" :status="props.state" :as-of="props.evidence?.asOf"><template #actions><slot name="quickActions"/></template><template #summary><slot name="workSummary"/></template><StandardScreenBoundary :state="props.state" :warning="props.warning" :stale-at="props.evidence?.asOf" @retry="$emit('retry')"><section class="ks-stack"><div v-if="$slots.exceptionSummary" class="ks-card ks-section"><slot name="exceptionSummary"/></div><div class="ks-card ks-section"><slot name="queue"/></div></section></StandardScreenBoundary></PageLayout></template>
|
||||
<style scoped>.ks-section{padding:var(--ks-space-4)}</style>
|
||||
@@ -8,4 +8,6 @@ export { default as ScorecardDashboardPage } from './ScorecardDashboardPage.vue'
|
||||
export { default as BatchOperationsPageV2 } from './BatchOperationsPageV2.vue'
|
||||
export { default as ReconciliationExceptionPage } from './ReconciliationExceptionPage.vue'
|
||||
export { default as VersionGovernancePage } from './VersionGovernancePage.vue'
|
||||
export { default as FastEntryGridPage } from './FastEntryGridPage.vue'
|
||||
export { default as WorkQueuePage } from './WorkQueuePage.vue'
|
||||
export { default as StandardScreenBoundary } from './StandardScreenBoundary.vue'
|
||||
|
||||
Reference in New Issue
Block a user