V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -1,24 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 { screenTemplateCatalogue, type ScreenTemplateId } from '@/shared/ui/screen-types/catalogue'
|
||||
import { KsButton, KsDataGrid, KsSelect, KsStatusTag, KsTextField, KsNumberField, KsDateField, KsCheckbox, KsFormGrid, KsFormSection, KsFormSpan, KsValidationSummary } from '@/shared/ui/components'
|
||||
import type { UiGridColumn, UiSelectOption } from '@/shared/ui/adapter/contracts'
|
||||
import { useUiAdapter } from '@/shared/ui/adapter/useUiAdapter'
|
||||
|
||||
const adapter = useUiAdapter()
|
||||
const router = useRouter()
|
||||
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 }]
|
||||
const templateFilter = ref<unknown>('ALL')
|
||||
type ScreenCatalogueRow = { screenId: string; name: string; templateId: ScreenTemplateId; templateName: string; path: string }
|
||||
const selectedScreenId = ref<unknown>(null)
|
||||
const demo = ref({ name: '', amount: null as number | null, date: null as Date | null, acknowledged: false })
|
||||
const demoReadonly = ref(false)
|
||||
const submitted = ref(false)
|
||||
const demoErrors = computed(() => submitted.value ? [
|
||||
...(demo.value.name.trim() ? [] : [{ field: 'name', message: '이름을 입력하세요.' }]),
|
||||
...(demo.value.amount !== null && demo.value.amount > 0 ? [] : [{ field: 'amount', message: '0보다 큰 금액을 입력하세요.' }]),
|
||||
...(demo.value.acknowledged ? [] : [{ field: 'acknowledged', message: '확인 항목에 동의하세요.' }])
|
||||
] : [])
|
||||
function submitDemo(): void { submitted.value = true }
|
||||
function resetDemo(): void { demo.value = { name: '', amount: null, date: null, acknowledged: false }; submitted.value = false }
|
||||
const templateOptions: UiSelectOption[] = [{ label: '전체 화면 유형', value: 'ALL' }, ...screenTemplateCatalogue.map(template => ({ label: `${template.id} · ${template.name}`, value: template.id }))]
|
||||
const rows = computed<ScreenCatalogueRow[]>(() => router.getRoutes()
|
||||
.map(route => {
|
||||
const screenId = route.meta.screenId
|
||||
const templateId = route.meta.templateId
|
||||
const name = route.meta.title
|
||||
if (route.path === '/internal/ui-standard' || route.meta.internalOnly === true || typeof screenId !== 'string' || typeof templateId !== 'string' || typeof name !== 'string') return null
|
||||
const template = screenTemplateCatalogue.find(item => item.id === templateId)
|
||||
return template ? { screenId, name, templateId: template.id, templateName: template.name, path: route.path } : null
|
||||
})
|
||||
.filter((row): row is ScreenCatalogueRow => row !== null)
|
||||
.filter(row => templateFilter.value === 'ALL' || row.templateId === templateFilter.value)
|
||||
.filter(row => !query.value || `${row.screenId} ${row.name} ${row.templateId} ${row.templateName}`.toLowerCase().includes(query.value.toLowerCase()))
|
||||
)
|
||||
const screenOptions = computed<UiSelectOption[]>(() => rows.value.map(row => ({ label: `${row.screenId} · ${row.name}`, value: row.screenId })))
|
||||
const selectedScreen = computed(() => rows.value.find(row => row.screenId === selectedScreenId.value) ?? null)
|
||||
const columns: UiGridColumn[] = [{ field: 'screenId', header: '화면 ID', width: 130 }, { field: 'name', header: '기능 화면', minWidth: 180 }, { field: 'templateId', header: '유형', width: 90 }, { field: 'templateName', header: '화면 유형', minWidth: 180 }]
|
||||
function openSelectedScreen(): void { if (selectedScreen.value) void router.push(selectedScreen.value.path) }
|
||||
</script>
|
||||
<template>
|
||||
<SearchListCrudPage title="표준 UI 패턴" subtitle="Feature는 공급자 라이브러리를 직접 사용하지 않고, v4 어댑터·레이아웃·화면 계약을 사용한다." state="READY" :evidence="{ asOf: '2026-08-02', version: 'UI-CONTRACT-4.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" />
|
||||
<SearchListCrudPage title="표준 UI 패턴" subtitle="화면 유형을 선택하고, 실제 화면 ID를 선택해 기능 화면을 엽니다." state="READY" :evidence="{ asOf: '2026-08-02', version: 'UI-CONTRACT-4.0' }">
|
||||
<template #actions><KsButton label="선택 화면 열기" :disabled="!selectedScreen" @click="openSelectedScreen" /></template>
|
||||
<template #summary><div class="ks-card summary"><strong>{{ screenTemplateCatalogue.length }}</strong><span>화면 타입</span></div><div class="ks-card summary"><strong>{{ rows.length }}</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="templateFilter" label="화면 유형" :options="templateOptions" /><KsSelect v-model="selectedScreenId" label="열 화면" placeholder="화면 ID를 선택하세요" :options="screenOptions" /></div></template>
|
||||
<KsDataGrid :rows="rows" :columns="columns" height="25rem" row-selection="none" />
|
||||
<section class="ks-card component-preview" aria-labelledby="component-preview-title">
|
||||
<div><h2 id="component-preview-title">컴포넌트 동작 프리뷰</h2><p>공유 UI 포트를 실제 상태로 확인하는 내부 전용 카탈로그입니다.</p></div>
|
||||
<div class="preview-grid">
|
||||
@@ -27,7 +56,21 @@ const columns: UiGridColumn[] = [{ field: 'id', header: '화면 ID', width: 100
|
||||
<div><h3>Inputs</h3><KsTextField label="텍스트 필드" model-value="샘플 값" /><KsSelect label="선택 필드" model-value="READY" :options="[{ label: '준비', value: 'READY' }, { label: '검토', value: 'REVIEW' }]" /></div>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
<section class="ks-card component-preview" aria-labelledby="form-template-title">
|
||||
<div><h2 id="form-template-title">입력 유형 기능 테스트</h2><p>서버 요청 없이 shared UI의 입력·검증·읽기전용 조합을 확인합니다.</p></div>
|
||||
<KsValidationSummary :errors="demoErrors" />
|
||||
<KsFormSection title="기본 입력" description="필수 항목과 유효성 상태를 조작할 수 있습니다.">
|
||||
<template #actions><KsButton :label="demoReadonly ? '편집 허용' : '읽기 전용'" severity="secondary" @click="demoReadonly = !demoReadonly" /></template>
|
||||
<KsFormGrid aria-label="기능 테스트 입력">
|
||||
<KsTextField v-model="demo.name" label="이름" :disabled="demoReadonly" :error="submitted && !demo.name.trim() ? '필수 입력입니다.' : undefined" />
|
||||
<KsNumberField v-model="demo.amount" label="금액" :disabled="demoReadonly" :error="submitted && (!demo.amount || demo.amount <= 0) ? '0보다 커야 합니다.' : undefined" />
|
||||
<KsDateField v-model="demo.date" label="기준일" :disabled="demoReadonly" />
|
||||
<KsCheckbox v-model="demo.acknowledged" label="검증 조건을 확인했습니다" :disabled="demoReadonly" />
|
||||
<KsFormSpan span="full"><div class="ks-inline"><KsButton label="검증 실행" :disabled="demoReadonly" @click="submitDemo" /><KsButton label="초기화" severity="secondary" @click="resetDemo" /></div></KsFormSpan>
|
||||
</KsFormGrid>
|
||||
</KsFormSection>
|
||||
</section>
|
||||
<template #detail><div class="ks-card detail"><h2>선택한 기능 화면</h2><template v-if="selectedScreen"><p><strong>{{ selectedScreen.screenId }}</strong> · {{ selectedScreen.name }}</p><p>{{ selectedScreen.templateId }} · {{ selectedScreen.templateName }}</p><p><code>{{ selectedScreen.path }}</code></p><KsButton label="선택 화면 열기" @click="openSelectedScreen" /></template><p v-else>목록에서 화면 ID를 선택한 뒤, “선택 화면 열기”를 누르세요.</p></div></template>
|
||||
</SearchListCrudPage>
|
||||
</template>
|
||||
<style scoped>.filters{display:grid;grid-template-columns:2fr 1fr;gap:var(--ks-space-3)}.summary,.detail,.component-preview{padding:var(--ks-space-4)}.summary{display:grid;gap:var(--ks-space-1)}.summary strong{font-size:1.5rem}.detail h2,.component-preview h2{margin-top:0;font-size:var(--ks-font-section)}.preview-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--ks-space-4)}.preview-grid>div{display:grid;align-content:start;gap:var(--ks-space-2);padding:var(--ks-space-3);border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.preview-grid h3{margin:0;font-size:var(--ks-font-caption)}.status-row{display:flex;flex-wrap:wrap;gap:var(--ks-space-2)}@media(max-width:700px){.filters,.preview-grid{grid-template-columns:1fr}}</style>
|
||||
<style scoped>.filters{display:grid;grid-template-columns:2fr 1fr 1fr;gap:var(--ks-space-3)}.summary,.detail,.component-preview{padding:var(--ks-space-4)}.summary{display:grid;gap:var(--ks-space-1)}.summary strong{font-size:1.5rem}.detail h2,.component-preview h2{margin-top:0;font-size:var(--ks-font-section)}.preview-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--ks-space-4)}.preview-grid>div{display:grid;align-content:start;gap:var(--ks-space-2);padding:var(--ks-space-3);border:1px solid var(--ks-color-neutral-200);border-radius:var(--ks-radius-sm)}.preview-grid h3{margin:0;font-size:var(--ks-font-caption)}.status-row{display:flex;flex-wrap:wrap;gap:var(--ks-space-2)}@media(max-width:700px){.filters,.preview-grid{grid-template-columns:1fr}}</style>
|
||||
|
||||
Reference in New Issue
Block a user