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
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { buildNavigationEntries, groupByModule } from './navigationCatalog'
import { useScreenPreferenceStore } from './screenPreferenceStore'
import { useWorkspaceStore, type WorkspaceTab } from './workspaceStore'
import KsGlobalHeader from './KsGlobalHeader.vue'
import KsSideNavigation from './KsSideNavigation.vue'
import KsWorkspaceTabs from './KsWorkspaceTabs.vue'
import KsMenuSearch from './KsMenuSearch.vue'
import type { NavigationEntry } from './navigationCatalog'
defineProps<{ productName?: string; environment?: string; automationStatus?: string }>()
const route = useRoute()
const router = useRouter()
const preference = useScreenPreferenceStore()
const workspace = useWorkspaceStore()
const collapsed = ref(false)
const menuSearchOpen = ref(false)
const appVersion = import.meta.env.VITE_APP_VERSION ?? '0.1.0'
const entries = () => buildNavigationEntries(router.getRoutes()).filter(entry => !entry.internalOnly)
const sections = () => groupByModule(entries())
const favorites = () => preference.favoriteScreenIds
.map(id => entries().find(entry => entry.screenId === id))
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
const activeTabKey = () => {
const screenId = route.meta.screenId
return typeof screenId === 'string' ? `${screenId}:${route.path}` : null
}
watch(() => route.fullPath, () => {
const meta = route.meta
if (typeof meta.screenId !== 'string' || meta.module === 'Home') return
const title = typeof meta.title === 'string' ? meta.title : route.path
preference.recordVisit(meta.screenId, route.path)
workspace.open(meta.screenId, route.path, title)
}, { immediate: true })
function goHome() {
router.push('/home')
}
function openMenuSearch() {
menuSearchOpen.value = true
}
function selectEntry(entry: NavigationEntry) {
menuSearchOpen.value = false
router.push(entry.path)
}
function selectTab(tab: WorkspaceTab) {
router.push(tab.path)
}
function closeTab(tab: WorkspaceTab) {
const wasActive = activeTabKey() === `${tab.screenId}:${tab.path}`
workspace.close(tab)
if (wasActive) {
const next = workspace.tabs.at(-1)
router.push(next ? next.path : '/home')
}
}
function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
menuSearchOpen.value = true
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="ks-app-shell">
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
<KsGlobalHeader :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" />
<KsWorkspaceTabs :tabs="workspace.visibleTabs" :overflow-count="workspace.overflowCount" :active-key="activeTabKey()" @select="selectTab" @close="closeTab" @toggle-pin="workspace.togglePin" />
<div class="ks-app-shell__body">
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" @toggle-collapsed="collapsed = !collapsed" />
<main id="ks-main" class="ks-app-shell__main" tabindex="-1">
<slot />
</main>
</div>
<footer class="ks-app-shell__footer">RESEARCH_CANDIDATE_NOT_PRODUCTION</footer>
<div class="ks-app-shell__version" data-testid="app-version" aria-label="애플리케이션 버전">v{{ appVersion }} · UI contract 4.0</div>
<KsMenuSearch :open="menuSearchOpen" :entries="entries()" @close="menuSearchOpen = false" @select="selectEntry" />
</div>
</template>
<style scoped>
.ks-app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr auto; background: var(--ks-color-canvas); }
.ks-app-shell__body { display: flex; min-height: 0; }
.ks-app-shell__main { flex: 1; min-width: 0; padding: var(--ks-space-6); overflow: auto; }
.ks-app-shell__footer { padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-border); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
.ks-app-shell__version { position: fixed; right: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: rgb(255 255 255 / 92%); color: var(--ks-color-text-muted); font-size: .7rem; box-shadow: var(--ks-shadow-sm); }
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: var(--ks-color-surface); }
.ks-skip:focus { top: var(--ks-space-2); }
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } }
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(defineProps<{ productName?: string; environment?: string; automationStatus?: string }>(), {
productName: 'K-ArtSell Aegis',
environment: 'IMPLEMENTATION_TEMPLATE',
automationStatus: '투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF'
})
const emit = defineEmits<{ home: []; menuSearch: [] }>()
</script>
<template>
<header class="ks-global-header">
<button type="button" class="ks-global-header__brand" @click="emit('home')">
<strong>{{ productName }}</strong><small>{{ environment }}</small>
</button>
<button type="button" class="ks-global-header__search" aria-keyshortcuts="Control+K" @click="emit('menuSearch')">
<span>메뉴명 · 화면코드 · 업무명 검색</span><kbd>Ctrl K</kbd>
</button>
<div class="ks-global-header__status" role="status">{{ automationStatus }}</div>
<slot name="actions" />
</header>
</template>
<style scoped>
.ks-global-header { height: var(--ks-shell-header-height); display: flex; align-items: center; gap: var(--ks-space-4); padding: 0 var(--ks-space-4); background: var(--ks-color-neutral-950); color: var(--ks-color-text-on-dark); }
.ks-global-header__brand { display: grid; gap: 0; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.ks-global-header__brand small { color: #cbd5e1; font-size: var(--ks-font-caption); }
.ks-global-header__search { flex: 1; max-width: 28rem; height: 2rem; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-2); padding: 0 var(--ks-space-3); border: 1px solid var(--ks-color-neutral-700); border-radius: var(--ks-radius-sm); background: var(--ks-color-neutral-800); color: var(--ks-color-neutral-300); }
.ks-global-header__search kbd { font-size: var(--ks-font-caption); border: 1px solid var(--ks-color-neutral-600); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-1); }
.ks-global-header__status { margin-left: auto; padding: var(--ks-space-1) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; font-size: var(--ks-font-caption); white-space: nowrap; }
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import KsDialog from '../ui/components/KsDialog.vue'
import type { NavigationEntry } from './navigationCatalog'
const props = defineProps<{ open: boolean; entries: NavigationEntry[] }>()
const emit = defineEmits<{ close: []; select: [entry: NavigationEntry] }>()
const query = ref('')
const activeIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const results = computed<NavigationEntry[]>(() => {
const q = query.value.trim().toLowerCase()
const source = q
? props.entries.filter(entry => entry.title.toLowerCase().includes(q) || entry.screenId.toLowerCase().includes(q) || entry.module.toLowerCase().includes(q))
: props.entries
return source.slice(0, 20)
})
watch(() => props.open, async value => {
if (!value) return
query.value = ''
activeIndex.value = 0
await nextTick()
inputRef.value?.focus()
})
watch(results, () => { activeIndex.value = 0 })
function move(delta: number) {
if (!results.value.length) return
activeIndex.value = (activeIndex.value + delta + results.value.length) % results.value.length
}
function selectActive() {
const entry = results.value[activeIndex.value]
if (entry) emit('select', entry)
}
</script>
<template>
<KsDialog :visible="props.open" title="메뉴 검색" modal closable @update:visible="value => { if (!value) emit('close') }">
<div class="ks-menu-search" role="presentation" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="selectActive" @keydown.esc="emit('close')">
<input ref="inputRef" v-model="query" type="text" placeholder="메뉴명 · 화면코드 · 업무명 검색" aria-label="메뉴 검색" />
<ul role="listbox" aria-label="검색 결과">
<li
v-for="(entry, index) in results"
:key="entry.screenId"
role="option"
:aria-selected="index === activeIndex"
:class="{ active: index === activeIndex }"
@mouseenter="activeIndex = index"
@click="emit('select', entry)"
>
<span>{{ entry.title }}</span><small>{{ entry.module }} · {{ entry.screenId }}</small>
</li>
<li v-if="!results.length" class="empty">검색 결과가 없습니다.</li>
</ul>
</div>
</KsDialog>
</template>
<style scoped>
.ks-menu-search { display: grid; gap: var(--ks-space-2); min-width: 24rem; }
.ks-menu-search input { height: var(--ks-control-height); padding: 0 var(--ks-space-3); border: 1px solid var(--ks-color-border-strong); border-radius: var(--ks-radius-sm); font-size: var(--ks-font-body); }
.ks-menu-search ul { list-style: none; margin: 0; padding: 0; max-height: 20rem; overflow-y: auto; display: grid; gap: 1px; }
.ks-menu-search li { display: flex; justify-content: space-between; gap: var(--ks-space-2); padding: var(--ks-space-2) var(--ks-space-3); border-radius: var(--ks-radius-sm); cursor: pointer; }
.ks-menu-search li.active { background: var(--ks-color-neutral-100); }
.ks-menu-search li small { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); white-space: nowrap; }
.ks-menu-search li.empty { color: var(--ks-color-text-muted); cursor: default; }
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { NavigationEntry, NavigationSection } from './navigationCatalog'
const props = withDefaults(defineProps<{
sections: NavigationSection[]
favorites?: NavigationEntry[]
activePath?: string
collapsed?: boolean
}>(), { favorites: () => [], activePath: '', collapsed: false })
const emit = defineEmits<{ toggleCollapsed: [] }>()
</script>
<template>
<aside class="ks-side-nav" :class="{ 'ks-side-nav--collapsed': props.collapsed }" aria-label="주요 메뉴">
<button type="button" class="ks-side-nav__toggle" :aria-expanded="!props.collapsed" @click="emit('toggleCollapsed')">
{{ props.collapsed ? '»' : '« 접기' }}
</button>
<template v-if="!props.collapsed">
<section v-if="favorites.length" class="ks-side-nav__section">
<h2>즐겨찾기</h2>
<RouterLink v-for="entry in favorites" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }"> {{ entry.title }}</RouterLink>
</section>
<section v-for="section in sections" :key="section.module" class="ks-side-nav__section">
<h2>{{ section.module }}</h2>
<RouterLink v-for="entry in section.entries" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }">{{ entry.title }}</RouterLink>
</section>
</template>
</aside>
</template>
<style scoped>
.ks-side-nav { width: var(--ks-shell-sidenav-width); flex-shrink: 0; display: grid; gap: var(--ks-space-3); align-content: start; padding: var(--ks-space-3) var(--ks-space-2); border-right: 1px solid var(--ks-color-border); background: var(--ks-color-surface); overflow-y: auto; }
.ks-side-nav--collapsed { width: var(--ks-shell-sidenav-collapsed); padding: var(--ks-space-3) var(--ks-space-1); }
.ks-side-nav__toggle { justify-self: end; border: 0; background: transparent; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); cursor: pointer; }
.ks-side-nav__section { display: grid; gap: var(--ks-space-1); }
.ks-side-nav__section h2 { margin: 0 var(--ks-space-2); font-size: var(--ks-font-caption); font-weight: 600; color: var(--ks-color-text-muted); text-transform: uppercase; }
.ks-side-nav__section a { padding: var(--ks-space-2) var(--ks-space-2); border-radius: var(--ks-radius-sm); text-decoration: none; color: var(--ks-color-text); font-size: var(--ks-font-body); }
.ks-side-nav__section a.active, .ks-side-nav__section a.router-link-active { background: var(--ks-color-neutral-100); font-weight: 700; }
</style>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { ref } from 'vue'
import KsDialog from '../ui/components/KsDialog.vue'
import type { WorkspaceTab } from './workspaceStore'
const props = defineProps<{ tabs: WorkspaceTab[]; overflowCount?: number; activeKey?: string | null }>()
const emit = defineEmits<{ select: [tab: WorkspaceTab]; close: [tab: WorkspaceTab]; togglePin: [tab: WorkspaceTab] }>()
const pendingClose = ref<WorkspaceTab | null>(null)
function requestClose(tab: WorkspaceTab) {
if (tab.dirty) pendingClose.value = tab
else emit('close', tab)
}
function confirmDiscard() {
if (pendingClose.value) emit('close', pendingClose.value)
pendingClose.value = null
}
function cancelClose() {
pendingClose.value = null
}
function keyOf(tab: WorkspaceTab): string {
return `${tab.screenId}:${tab.path}`
}
</script>
<template>
<nav class="ks-workspace-tabs" :class="{ 'ks-workspace-tabs--empty': !props.tabs.length }" aria-label="열린 업무">
<div
v-for="tab in props.tabs"
:key="keyOf(tab)"
class="ks-workspace-tabs__tab"
:class="{ active: keyOf(tab) === props.activeKey }"
>
<button type="button" class="select" @click="emit('select', tab)">
<span v-if="tab.dirty" class="dot" aria-hidden="true"></span>
<span class="title">{{ tab.title }}</span>
</button>
<button type="button" class="pin" :aria-pressed="tab.pinned" :aria-label="tab.pinned ? '고정 해제' : ' 고정'" @click="emit('togglePin', tab)">{{ tab.pinned ? '📌' : '📍' }}</button>
<button type="button" class="close" aria-label=" 닫기" @click="requestClose(tab)">×</button>
</div>
<span v-if="(props.overflowCount ?? 0) > 0" class="ks-workspace-tabs__overflow">더보기 {{ props.overflowCount }}</span>
<KsDialog :visible="Boolean(pendingClose)" title="저장하지 않은 변경사항" modal closable @update:visible="value => { if (!value) cancelClose() }">
<p>저장하지 않은 변경사항이 있습니다. 계속 진행하면 변경 내용이 사라집니다.</p>
<template #footer>
<button type="button" @click="cancelClose">계속 편집</button>
<button type="button" @click="confirmDiscard">변경 버리기</button>
</template>
</KsDialog>
</nav>
</template>
<style scoped>
.ks-workspace-tabs { height: var(--ks-shell-tabs-height); display: flex; align-items: stretch; gap: 1px; padding: 0 var(--ks-space-2); border-bottom: 1px solid var(--ks-color-border); background: var(--ks-color-surface); overflow-x: auto; }
.ks-workspace-tabs--empty { height: 0; padding: 0; border-bottom: 0; overflow: hidden; }
.ks-workspace-tabs__tab { display: flex; align-items: center; gap: var(--ks-space-1); padding: 0 var(--ks-space-1); border-bottom: 2px solid transparent; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); white-space: nowrap; }
.ks-workspace-tabs__tab.active { color: var(--ks-color-text); border-bottom-color: var(--ks-color-action); font-weight: 600; }
.ks-workspace-tabs__tab .select { display: flex; align-items: center; gap: var(--ks-space-1); border: 0; background: transparent; color: inherit; font: inherit; padding: 0 var(--ks-space-1); cursor: pointer; }
.ks-workspace-tabs__tab .dot { color: var(--ks-color-warning); }
.ks-workspace-tabs__tab .pin, .ks-workspace-tabs__tab .close { border: 0; background: transparent; color: inherit; padding: 0 var(--ks-space-1); cursor: pointer; }
.ks-workspace-tabs__overflow { align-self: center; padding: 0 var(--ks-space-2); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
</style>
@@ -0,0 +1,52 @@
export interface NavigationRouteLike {
path: string
meta?: Record<string, unknown>
}
export interface NavigationEntry {
screenId: string
path: string
module: string
section: string
title: string
order: number
favoriteAllowed: boolean
internalOnly: boolean
}
export interface NavigationSection {
module: string
entries: NavigationEntry[]
}
function toEntry(route: NavigationRouteLike): NavigationEntry | null {
const meta = route.meta
if (!meta || typeof meta.screenId !== 'string' || typeof meta.module !== 'string') return null
if (meta.module === 'Home') return null
return {
screenId: meta.screenId,
path: route.path,
module: meta.module,
section: typeof meta.section === 'string' ? meta.section : meta.module,
title: typeof meta.title === 'string' ? meta.title : route.path,
order: typeof meta.order === 'number' ? meta.order : 0,
favoriteAllowed: meta.favoriteAllowed !== false,
internalOnly: meta.internalOnly === true
}
}
/** router.getRoutes()의 flat route 목록에서 즐겨찾기/검색/사이드바가 공유하는 단일 카탈로그를 파생한다. */
export function buildNavigationEntries(routes: readonly NavigationRouteLike[]): NavigationEntry[] {
const entries = routes.map(toEntry).filter((entry): entry is NavigationEntry => entry !== null)
return entries.sort((a, b) => a.module.localeCompare(b.module) || a.order - b.order)
}
export function groupByModule(entries: readonly NavigationEntry[]): NavigationSection[] {
const byModule = new Map<string, NavigationEntry[]>()
for (const entry of entries) {
const list = byModule.get(entry.module) ?? []
list.push(entry)
byModule.set(entry.module, list)
}
return [...byModule.entries()].map(([module, list]) => ({ module, entries: [...list].sort((a, b) => a.order - b.order) }))
}
@@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
export interface RecentNavigation {
screenId: string
path: string
visitedAt: string
}
const STORAGE_KEY = 'ks.shell.screenPreference.v1'
const MAX_RECENTS = 10
const MAX_FAVORITES = 20
interface PersistedShape {
favoriteScreenIds: string[]
recents: RecentNavigation[]
}
function readPersisted(): PersistedShape {
if (typeof window === 'undefined') return { favoriteScreenIds: [], recents: [] }
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return { favoriteScreenIds: [], recents: [] }
const parsed = JSON.parse(raw) as Partial<PersistedShape>
return {
favoriteScreenIds: Array.isArray(parsed.favoriteScreenIds) ? parsed.favoriteScreenIds.slice(0, MAX_FAVORITES) : [],
recents: Array.isArray(parsed.recents) ? parsed.recents.slice(0, MAX_RECENTS) : []
}
} catch {
return { favoriteScreenIds: [], recents: [] }
}
}
export const useScreenPreferenceStore = defineStore('ks-screen-preference', {
state: () => readPersisted(),
actions: {
persist() {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ favoriteScreenIds: this.favoriteScreenIds, recents: this.recents }))
},
toggleFavorite(screenId: string) {
const index = this.favoriteScreenIds.indexOf(screenId)
if (index >= 0) this.favoriteScreenIds.splice(index, 1)
else this.favoriteScreenIds = [screenId, ...this.favoriteScreenIds].slice(0, MAX_FAVORITES)
this.persist()
},
isFavorite(screenId: string): boolean {
return this.favoriteScreenIds.includes(screenId)
},
recordVisit(screenId: string, path: string) {
const withoutCurrent = this.recents.filter(entry => entry.screenId !== screenId)
this.recents = [{ screenId, path, visitedAt: new Date().toISOString() }, ...withoutCurrent].slice(0, MAX_RECENTS)
this.persist()
}
}
})
@@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import KsAppShell from '../KsAppShell.vue'
function buildTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', redirect: '/home' },
{ path: '/home', component: { template: '<div>home</div>' }, meta: { screenId: 'SCR-000', module: 'Home', title: '홈' } },
{ path: '/research/sell-decision', component: { template: '<div>sell</div>' }, meta: { screenId: 'SCR-002', module: 'Research', section: 'Research', title: '매도 의사결정', order: 1, favoriteAllowed: true } }
]
})
}
describe('KsAppShell contract', () => {
it('provides skip navigation, header, side navigation, and workspace tab landmarks', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const router = buildTestRouter()
router.push('/research/sell-decision')
await router.isReady()
const wrapper = mount(KsAppShell, { global: { plugins: [pinia, router], stubs: { KsDialog: true } }, slots: { default: '<p>화면 내용</p>' } })
await wrapper.vm.$nextTick()
expect(wrapper.get('.ks-skip').attributes('href')).toBe('#ks-main')
expect(wrapper.find('header.ks-global-header').exists()).toBe(true)
expect(wrapper.get('aside').attributes('aria-label')).toBe('주요 메뉴')
expect(wrapper.get('nav.ks-workspace-tabs').attributes('aria-label')).toBe('열린 업무')
expect(wrapper.get('main').attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain('화면 내용')
expect(wrapper.text()).toContain('RESEARCH_CANDIDATE_NOT_PRODUCTION')
})
it('opens a workspace tab for the active route and records it as a recent screen', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const router = buildTestRouter()
router.push('/research/sell-decision')
await router.isReady()
const wrapper = mount(KsAppShell, { global: { plugins: [pinia, router], stubs: { KsDialog: true } } })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('매도 의사결정')
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { buildNavigationEntries, groupByModule } from '../navigationCatalog'
describe('navigation catalogue', () => {
it('includes the read-only component catalogue in the Design System menu', () => {
const entries = buildNavigationEntries([
{
path: '/internal/ui-standard',
meta: {
screenId: 'SCR-DEV-001',
module: 'Design System',
section: 'Design System',
title: '컴포넌트 확인',
order: 1,
favoriteAllowed: false,
internalOnly: false
}
},
{
path: '/internal/wbs',
meta: { screenId: 'SCR-DEV-002', module: 'Internal', title: 'WBS 작업공간', internalOnly: true }
}
])
expect(groupByModule(entries).find(section => section.module === 'Design System')?.entries).toEqual([
expect.objectContaining({ path: '/internal/ui-standard', title: '컴포넌트 확인', internalOnly: false })
])
expect(entries.find(entry => entry.path === '/internal/wbs')?.internalOnly).toBe(true)
})
})
@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
export interface WorkspaceTab {
screenId: string
path: string
title: string
pinned: boolean
dirty: boolean
}
const MAX_VISIBLE_TABS = 10
function tabKey(screenId: string, path: string): string {
return `${screenId}:${path}`
}
export const useWorkspaceStore = defineStore('ks-workspace', {
state: () => ({ tabs: [] as WorkspaceTab[], activeKey: null as string | null }),
getters: {
visibleTabs: state => state.tabs.slice(0, MAX_VISIBLE_TABS),
overflowTabs: state => state.tabs.slice(MAX_VISIBLE_TABS),
overflowCount: state => Math.max(state.tabs.length - MAX_VISIBLE_TABS, 0)
},
actions: {
/** 화면 진입 시 호출. 이미 열려 있으면 새 가상 상태를 만들지 않고 activeKey만 갱신한다. */
open(screenId: string, path: string, title: string) {
const key = tabKey(screenId, path)
if (!this.tabs.some(tab => tabKey(tab.screenId, tab.path) === key)) {
this.tabs.push({ screenId, path, title, pinned: false, dirty: false })
}
this.activeKey = key
},
/** dirty 여부와 무관하게 실제로 탭을 제거한다. dirty guard 확인은 호출자(KsWorkspaceTabs) 책임. */
close(tab: WorkspaceTab) {
const key = tabKey(tab.screenId, tab.path)
this.tabs = this.tabs.filter(t => tabKey(t.screenId, t.path) !== key)
if (this.activeKey === key) {
const last = this.tabs.at(-1)
this.activeKey = last ? tabKey(last.screenId, last.path) : null
}
},
togglePin(tab: WorkspaceTab) {
const found = this.tabs.find(t => t.screenId === tab.screenId && t.path === tab.path)
if (found) found.pinned = !found.pinned
},
setDirty(screenId: string, path: string, dirty: boolean) {
const found = this.tabs.find(t => t.screenId === screenId && t.path === path)
if (found) found.dirty = dirty
}
}
})
@@ -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'