V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -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'