V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -0,0 +1,21 @@
<script setup lang="ts">
import KbxButton from '../components/KbxButton.vue'
withDefaults(defineProps<{ screenTitle?:string; screenId?:string; revealDetails?:boolean }>(),{revealDetails:false})
const emit=defineEmits<{ home:[]; menuSearch:[] }>()
</script>
<template>
<section class="kbx-access-denied" role="alert" aria-labelledby="kbx-access-denied-title">
<div class="kbx-access-denied__box">
<strong id="kbx-access-denied-title">접근 권한이 없습니다.</strong>
<p><template v-if="revealDetails"><b v-if="screenTitle">{{screenTitle}}</b><span v-if="screenId"> ({{screenId}})</span> </template>현재 사용자에게 업무 화면을 사용할 권한이 없습니다.</p>
<p>메뉴 노출 여부와 실제 업무 권한은 분리되며, 서버에서도 동일 권한을 최종 검증해야 합니다.</p>
<div class="kbx-access-denied__actions"><KbxButton label="홈으로 이동" variant="primary" @click="emit('home')" /><KbxButton label="다른 업무 찾기" @click="emit('menuSearch')" /></div>
</div>
</section>
</template>
<style scoped>
.kbx-access-denied{height:100%;min-height:var(--kbx-content-min-height);display:grid;place-items:center;padding:var(--kbx-space-6)}.kbx-access-denied__box{max-width:var(--kbx-access-max-width);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-danger);padding:var(--kbx-space-5);background:var(--kbx-color-surface)}.kbx-access-denied strong{font-size:var(--kbx-font-lg)}.kbx-access-denied p{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);line-height:1.6}.kbx-access-denied__actions{display:flex;align-items:center;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { KbxNavigationResolvedEntry, KbxNavigationSection, KbxRecentNavigation, KbxWorkspaceTab } from '@kbx/contracts'
import KbxGlobalHeader from './KbxGlobalHeader.vue'
import KbxMenuSearch from './KbxMenuSearch.vue'
import KbxSideNavigation from './KbxSideNavigation.vue'
import KbxWorkspaceTabs from './KbxWorkspaceTabs.vue'
const props=withDefaults(defineProps<{productName?:string;activeModule?:string;activeScreenId?:string|null;sections:KbxNavigationSection[];allEntries:KbxNavigationResolvedEntry[];favorites?:KbxNavigationResolvedEntry[];recents?:KbxRecentNavigation[];tabs?:KbxWorkspaceTab[];activeTabKey?:string|null;sideNavCollapsed?:boolean;menuSearchOpen?:boolean;operationCount?:number;notificationCount?:number;operationsOpen?:boolean;notificationsOpen?:boolean;homeActive?:boolean;workspaceMaxTabs?:number;shellNotice?:string|null;themeMode?:'light'|'dark'}>(),{productName:'KBX',activeModule:'',activeScreenId:null,favorites:()=>[],recents:()=>[],tabs:()=>[],activeTabKey:null,sideNavCollapsed:false,menuSearchOpen:false,operationCount:0,notificationCount:0,operationsOpen:false,notificationsOpen:false,homeActive:false,workspaceMaxTabs:10,shellNotice:null,themeMode:'light'})
const emit=defineEmits<{home:[];open:[entry:KbxNavigationResolvedEntry];openRecent:[entry:KbxRecentNavigation];selectTab:[tab:KbxWorkspaceTab];closeTab:[tab:KbxWorkspaceTab];toggleTabPin:[tab:KbxWorkspaceTab];toggleFavorite:[screenId:string];toggleSideNav:[];menuSearchOpen:[];menuSearchClose:[];operations:[];notifications:[];themeToggle:[];profile:[];dismissShellNotice:[]}>()
const moduleOptions=computed(()=>[...new Set(props.sections.map(section=>section.module).filter((module):module is NonNullable<KbxNavigationSection['module']>=>Boolean(module)))])
const focusedModule=ref(props.activeModule||moduleOptions.value[0]||'')
watch([()=>props.activeModule,moduleOptions],()=>{if(props.activeModule)focusedModule.value=props.activeModule;else if(!moduleOptions.value.includes(focusedModule.value as any))focusedModule.value=moduleOptions.value[0]??''},{immediate:true})
function selectModule(module:string){if(moduleOptions.value.includes(module as any))focusedModule.value=module}
const priorityScreenIds=computed(()=>[...props.favorites.map(x=>x.screenId),...props.recents.map(x=>x.screenId)].filter((id,index,list)=>list.indexOf(id)===index))
</script>
<template>
<div class="app-shell" :data-kbx-module="focusedModule || 'COMMON'" :data-home-active="props.homeActive || undefined">
<a class="skip-link" href="#kbx-main-workspace">본문 바로가기</a>
<KbxGlobalHeader :product-name="props.productName" :active-module="focusedModule" :modules="moduleOptions" :operation-count="props.operationCount" :notification-count="props.notificationCount" :operations-open="props.operationsOpen" :notifications-open="props.notificationsOpen" :home-active="props.homeActive" :theme-mode="props.themeMode" @home="emit('home')" @module-select="selectModule" @menu-search="emit('menuSearchOpen')" @operations="emit('operations')" @notifications="emit('notifications')" @theme-toggle="emit('themeToggle')" @profile="emit('profile')" />
<div class="below-header">
<KbxSideNavigation :sections="props.sections" :favorites="props.favorites" :recents="props.recents" :active-screen-id="props.activeScreenId" :collapsed="props.sideNavCollapsed" :home-active="props.homeActive" :preferred-module="focusedModule" @home="emit('home')" @open="emit('open',$event)" @open-recent="emit('openRecent',$event)" @toggle-favorite="emit('toggleFavorite',$event)" @toggle-collapsed="emit('toggleSideNav')" @module-selected="selectModule" @menu-search="emit('menuSearchOpen')" />
<main class="workspace">
<KbxWorkspaceTabs :tabs="props.tabs" :active-key="props.activeTabKey" :max-tabs="props.workspaceMaxTabs" @select="emit('selectTab',$event)" @close-request="emit('closeTab',$event)" @toggle-pin="emit('toggleTabPin',$event)" />
<aside v-if="props.shellNotice" class="shell-notice" role="status" aria-live="polite"><span>{{props.shellNotice}}</span><button type="button" aria-label="안내 닫기" @click="emit('dismissShellNotice')">×</button></aside>
<section class="runtime"><slot name="runtime" /></section>
<section id="kbx-main-workspace" class="content" tabindex="-1"><slot /></section>
</main>
</div>
<KbxMenuSearch :open="props.menuSearchOpen" :entries="props.allEntries" :priority-screen-ids="priorityScreenIds" @close="emit('menuSearchClose')" @select="emit('open',$event)" />
</div>
</template>
<style scoped>.skip-link{position:fixed;left:var(--kbx-space-2);top:calc(var(--kbx-control-lg) * -2);z-index:300;padding:var(--kbx-space-2) var(--kbx-space-3);background:var(--kbx-color-text);color:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm)}.skip-link:focus{top:var(--kbx-space-2)}.app-shell{height:100vh;min-height:var(--kbx-app-shell-min-height);display:flex;flex-direction:column;background:var(--kbx-color-workspace);color:var(--kbx-color-text)}.below-header{flex:1;min-height:0;display:flex}.workspace{flex:1;min-width:0;display:flex;flex-direction:column;background:var(--kbx-color-workspace)}.runtime{background:var(--kbx-color-surface)}.content{flex:1;min-height:0;overflow:auto;background:var(--kbx-color-workspace)}.shell-notice{min-height:var(--kbx-shell-notice-height);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.shell-notice button{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;background:transparent;color:var(--kbx-color-text-muted)}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
productName?: string
activeModule?: string
modules?: string[]
menuSearchLabel?: string
operationCount?: number
notificationCount?: number
operationsOpen?: boolean
notificationsOpen?: boolean
homeActive?: boolean
themeMode?: 'light' | 'dark'
}>(), { productName:'KBX', activeModule:'', modules:()=>[], menuSearchLabel:'메뉴 검색', operationCount:0, notificationCount:0, operationsOpen:false, notificationsOpen:false, homeActive:false, themeMode:'light' })
const emit = defineEmits<{ home:[]; moduleSelect:[module:string]; menuSearch:[]; operations:[]; notifications:[]; themeToggle:[]; profile:[] }>()
const label=(module:string)=>module==='COMMON'?'공통':module
</script>
<template>
<header class="kbx-global-header">
<div class="brand" aria-label="제품 모듈">
<button type="button" class="brand__home" :aria-current="homeActive?'page':undefined" @click="emit('home')"><strong>{{ props.productName }}</strong><span class="brand__home-label"></span></button>
<label v-if="props.modules.length" class="module-select"><span class="sr-only">업무 모듈</span><select :value="props.activeModule" @change="emit('moduleSelect',($event.target as HTMLSelectElement).value)"><option value="" disabled>모듈</option><option v-for="module in props.modules" :key="module" :value="module">{{label(module)}}</option></select></label>
</div>
<button class="menu-search" type="button" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>{{ props.menuSearchLabel }}</span><kbd>Ctrl K</kbd></button>
<div class="actions"><button id="kbx-runtime-operations-trigger" type="button" aria-controls="kbx-runtime-panel" :aria-expanded="props.operationsOpen" @click="emit('operations')">작업 <b v-if="props.operationCount">{{ props.operationCount }}</b></button><button id="kbx-runtime-notifications-trigger" type="button" aria-controls="kbx-runtime-panel" :aria-expanded="props.notificationsOpen" @click="emit('notifications')">알림 <b v-if="props.notificationCount">{{ props.notificationCount }}</b></button><button type="button" class="theme-toggle" :aria-label="props.themeMode==='dark'?'밝은 테마로 변경':'어두운 테마로 변경'" :title="props.themeMode==='dark'?'KBX Business Dark · 밝게 전환':'KBX Business Light · 어둡게 전환'" @click="emit('themeToggle')"><span aria-hidden="true">{{props.themeMode==='dark'?'☾':'☀'}}</span><span class="theme-toggle__label">{{props.themeMode==='dark'?'Dark':'Light'}}</span></button><button type="button" @click="emit('profile')">사용자</button></div>
</header>
</template>
<style scoped>
.kbx-global-header{height:var(--kbx-side-nav-collapsed-width);display:grid;grid-template-columns:var(--kbx-side-nav-width) minmax(var(--kbx-master-list-min-width),var(--kbx-access-max-width)) 1fr;align-items:center;gap:var(--kbx-space-4);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-shell-chrome);box-shadow:var(--kbx-shadow-shell);z-index:20}.brand{display:flex;align-items:center;gap:var(--kbx-space-2);min-width:0}.brand__home{height:var(--kbx-control-height);display:flex;align-items:center;gap:var(--kbx-space-2);border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:0 var(--kbx-space-2);color:var(--kbx-color-text)}.brand__home:hover,.brand__home[aria-current="page"]{background:var(--kbx-color-surface-hover)}.brand__home strong{font-size:var(--kbx-font-lg);color:var(--kbx-color-module-accent);letter-spacing:-.01em}.brand__home-label{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.module-select{padding-left:var(--kbx-space-2);border-left:var(--kbx-border-width) solid var(--kbx-color-border)}.module-select select{width:var(--kbx-global-header-module-selector-width);height:var(--kbx-control-sm);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);color:var(--kbx-color-text);font-size:var(--kbx-font-sm);padding:0 var(--kbx-space-2)}.menu-search{height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;padding:0 var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface-muted);color:var(--kbx-color-text-muted);text-align:left}.menu-search:hover{border-color:var(--kbx-color-module-accent);background:var(--kbx-color-module-accent-surface)}.menu-search kbd{font-size:var(--kbx-font-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);padding:var(--kbx-space-1);background:var(--kbx-color-surface)}.actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-1)}.actions button{height:var(--kbx-control-sm);border:0;background:transparent;border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.actions button:hover{background:var(--kbx-color-surface-hover)}.actions b{min-width:var(--kbx-checkbox-track);display:inline-block;margin-left:var(--kbx-space-1);color:var(--kbx-color-module-accent)}.theme-toggle{display:inline-flex;align-items:center;gap:var(--kbx-space-1);border:var(--kbx-border-width) solid transparent!important}.theme-toggle:hover{border-color:var(--kbx-color-border)!important}.theme-toggle__label{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.sr-only{position:absolute;width:var(--kbx-visually-hidden-size);height:var(--kbx-visually-hidden-size);padding:0;margin:calc(var(--kbx-visually-hidden-size) * -1);overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:56rem){.kbx-global-header{grid-template-columns:auto minmax(0,1fr) auto;gap:var(--kbx-space-2);padding-inline:var(--kbx-space-2)}.brand__home-label,.menu-search kbd{display:none}.module-select{padding-left:var(--kbx-space-1)}.module-select select{width:var(--kbx-control-lg);padding:0}.menu-search{min-width:0;padding-inline:var(--kbx-space-2)}.menu-search span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{min-width:0}.actions button{padding-inline:var(--kbx-space-1)}}@media(max-width:40rem){.kbx-global-header{grid-template-columns:auto minmax(0,1fr)}.actions{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,1fr);border-top:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-global-header{height:auto;min-height:var(--kbx-side-nav-collapsed-width);align-content:center}.actions button{width:100%}}
</style>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
import { searchNavigationEntries } from './navigationSearch'
const props=withDefaults(defineProps<{open:boolean;entries:KbxNavigationResolvedEntry[];priorityScreenIds?:string[]}>(),{priorityScreenIds:()=>[]})
const emit=defineEmits<{close:[];select:[entry:KbxNavigationResolvedEntry]}>()
const query=ref('');const selected=ref(0);const input=ref<HTMLInputElement|null>(null);const dialog=ref<HTMLElement|null>(null);let restoreFocus:HTMLElement|null=null
const results=computed(()=>searchNavigationEntries(props.entries,query.value,12,props.priorityScreenIds));const byId=computed(()=>new Map(props.entries.map(x=>[x.screenId,x])))
const activeOptionId=computed(()=>results.value[selected.value]?`kbx-menu-result-${results.value[selected.value].screenId}`:undefined)
function choose(index=selected.value){const result=results.value[index];if(!result)return;const entry=byId.value.get(result.screenId);if(entry){emit('select',entry);emit('close')}}
function trapTab(e:KeyboardEvent){const root=dialog.value;if(!root)return;const nodes=[...root.querySelectorAll<HTMLElement>('input,button,[tabindex]:not([tabindex="-1"])')].filter(node=>!node.hasAttribute('disabled'));if(!nodes.length)return;const first=nodes[0],last=nodes.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}
function onKeydown(e:KeyboardEvent){if(e.key==='Escape'){e.preventDefault();emit('close')}else if(e.key==='ArrowDown'){e.preventDefault();selected.value=Math.min(selected.value+1,results.value.length-1)}else if(e.key==='ArrowUp'){e.preventDefault();selected.value=Math.max(0,selected.value-1)}else if(e.key==='Enter'){e.preventDefault();choose()}else if(e.key==='Tab'){trapTab(e)}}
watch(()=>props.open,async (value,previous)=>{if(value){restoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;query.value='';selected.value=0;await nextTick();input.value?.focus()}else if(previous){await nextTick();restoreFocus?.focus();restoreFocus=null}});watch(results,()=>{selected.value=0})
</script>
<template><div v-if="props.open" class="overlay" @mousedown.self="emit('close')"><section ref="dialog" class="dialog" role="dialog" aria-modal="true" aria-labelledby="kbx-menu-search-title" @keydown="onKeydown"><h2 id="kbx-menu-search-title" class="sr-only">메뉴·화면 검색</h2><input ref="input" v-model="query" type="search" placeholder="메뉴명, 화면코드, 업무명 검색" autocomplete="off" role="combobox" aria-autocomplete="list" aria-controls="kbx-menu-results" :aria-expanded="results.length>0" :aria-activedescendant="activeOptionId"/><div class="hint"><span v-if="!query">즐겨찾기·최근업무를 우선 표시합니다.</span><span v-else role="status" aria-live="polite">검색 결과 {{results.length}}건</span><span>↑↓ 이동 · Enter 열기 · Esc 닫기</span></div><div id="kbx-menu-results" role="listbox" aria-label="검색 결과"><button v-for="(result,index) in results" :id="`kbx-menu-result-${result.screenId}`" :key="result.screenId" type="button" role="option" :aria-selected="index===selected" class="result" :class="{selected:index===selected}" @mouseenter="selected=index" @click="choose(index)"><span class="main"><b>{{result.title}}</b><small>{{result.module}} · {{result.section}}</small></span><code>{{result.screenId}}</code></button></div><p v-if="!results.length" class="empty" role="status">일치하는 메뉴가 없습니다.</p></section></div></template>
<style scoped>.overlay{position:fixed;inset:0;z-index:200;background:var(--kbx-color-overlay);display:flex;align-items:flex-start;justify-content:center;padding-top:var(--kbx-menu-search-top)}.dialog{width:min(var(--kbx-menu-search-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;background:var(--kbx-color-surface);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);box-shadow:var(--kbx-shadow-overlay);padding:var(--kbx-space-2)}.dialog input{width:100%;height:var(--kbx-control-lg);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font-size:var(--kbx-font-md)}.hint{display:flex;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-1);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.result{width:100%;min-height:var(--kbx-menu-search-result-height);display:flex;align-items:center;justify-content:space-between;text-align:left;border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:var(--kbx-space-1) var(--kbx-space-2)}.result.selected{background:var(--kbx-color-surface-hover)}.main{display:flex;flex-direction:column;gap:var(--kbx-space-1)}.main small,.result code{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.empty{text-align:center;color:var(--kbx-color-text-muted);padding:var(--kbx-space-6)}.sr-only{position:absolute;width:var(--kbx-visually-hidden-size);height:var(--kbx-visually-hidden-size);padding:0;margin:calc(var(--kbx-visually-hidden-size) * -1);overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import KbxButton from '../components/KbxButton.vue'
const emit=defineEmits<{home:[];menuSearch:[]}>()
</script>
<template>
<section class="kbx-route-not-found" aria-labelledby="kbx-route-not-found-title">
<div class="kbx-route-not-found__box">
<strong id="kbx-route-not-found-title">화면을 찾을 없습니다.</strong>
<p>주소가 변경되었거나 이상 제공되지 않는 업무 화면입니다. 저장된 링크를 그대로 신뢰하지 않고 현재 메뉴에서 다시 찾아주세요.</p>
<div class="kbx-route-not-found__actions"><KbxButton label="메뉴 검색" variant="primary" @click="emit('menuSearch')"/><KbxButton label="홈으로 이동" @click="emit('home')"/></div>
</div>
</section>
</template>
<style scoped>.kbx-route-not-found{height:100%;min-height:var(--kbx-content-min-height);display:grid;place-items:center;padding:var(--kbx-space-6)}.kbx-route-not-found__box{max-width:var(--kbx-access-max-width);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-warning);padding:var(--kbx-space-5);background:var(--kbx-color-surface)}.kbx-route-not-found strong{font-size:var(--kbx-font-lg)}.kbx-route-not-found p{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);line-height:1.6}.kbx-route-not-found__actions{display:flex;align-items:center;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { KbxNavigationResolvedEntry, KbxNavigationSection, KbxRecentNavigation } from '@kbx/contracts'
const props=withDefaults(defineProps<{sections:KbxNavigationSection[];favorites?:KbxNavigationResolvedEntry[];recents?:KbxRecentNavigation[];activeScreenId?:string|null;collapsed?:boolean;homeActive?:boolean;preferredModule?:string}>(),{favorites:()=>[],recents:()=>[],activeScreenId:null,collapsed:false,homeActive:false,preferredModule:''})
const emit=defineEmits<{home:[];open:[entry:KbxNavigationResolvedEntry];openRecent:[entry:KbxRecentNavigation];toggleFavorite:[screenId:string];toggleCollapsed:[];menuSearch:[];moduleSelected:[module:string]}>()
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통'}
const moduleOrder=['OMS','ERP','WMS','COMMON'] as const
const grouped=computed(()=>moduleOrder.map(module=>({module,label:moduleLabel[module],sections:props.sections.filter(section=>section.module===module)})).filter(group=>group.sections.length))
const activeModule=computed(()=>props.sections.find(section=>section.entries.some(entry=>entry.screenId===props.activeScreenId))?.module)
const selectedModule=ref<string>('')
watch([()=>props.preferredModule,activeModule,grouped],()=>{if(props.preferredModule&&grouped.value.some(x=>x.module===props.preferredModule))selectedModule.value=props.preferredModule;else if(activeModule.value)selectedModule.value=activeModule.value;else if(!grouped.value.some(x=>x.module===selectedModule.value))selectedModule.value=grouped.value[0]?.module??''},{immediate:true})
const selectedGroup=computed(()=>grouped.value.find(x=>x.module===selectedModule.value)??grouped.value[0])
const isFavorite=(id:string)=>props.favorites.some(x=>x.screenId===id)
function chooseModule(module:string){selectedModule.value=module;emit('moduleSelected',module);if(props.collapsed)emit('toggleCollapsed')}
</script>
<template>
<aside class="side-nav" :class="{collapsed:props.collapsed}" aria-label="업무 메뉴">
<div class="side-nav__top">
<button class="home" type="button" :class="{active:homeActive}" :aria-current="homeActive?'page':undefined" @click="emit('home')"><span aria-hidden="true"></span><span v-if="!collapsed"></span></button>
<button class="collapse" type="button" @click="emit('toggleCollapsed')" :aria-label="props.collapsed?'메뉴 펼치기':'메뉴 접기'">{{props.collapsed?'':''}}</button>
</div>
<template v-if="!props.collapsed">
<button class="side-nav__search" type="button" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>메뉴·화면 검색</span><kbd>Ctrl K</kbd></button>
<nav class="module-switcher" aria-label="업무 모듈"><button v-for="group in grouped" :key="group.module" type="button" :aria-pressed="selectedModule===group.module" @click="chooseModule(group.module)">{{group.label}}</button></nav>
<section v-if="props.favorites.length" class="personal"><h2>즐겨찾기</h2><button v-for="entry in props.favorites.slice(0,6)" :key="entry.screenId" class="nav-item favorite" :class="{active:entry.screenId===props.activeScreenId}" :aria-current="entry.screenId===props.activeScreenId?'page':undefined" @click="emit('open',entry)"><span aria-hidden="true"></span><span>{{entry.title}}</span></button></section>
<section v-if="props.recents.length" class="personal"><h2>최근메뉴</h2><button v-for="entry in props.recents.slice(0,4)" :key="entry.screenId+entry.path" class="nav-item" @click="emit('openRecent',entry)"><span aria-hidden="true"></span><span>{{entry.title}}</span></button></section>
<section v-if="selectedGroup" class="module-block active">
<h2 class="module-title">{{selectedGroup.label}} 업무</h2>
<div v-for="section in selectedGroup.sections" :key="section.key" class="section-block">
<h3>{{section.label.replace(`${selectedGroup.label} · `,'')}}</h3>
<div v-for="entry in section.entries" :key="entry.screenId" class="row">
<button class="nav-item main" :class="{active:entry.screenId===props.activeScreenId}" :aria-current="entry.screenId===props.activeScreenId?'page':undefined" @click="emit('open',entry)"><span>{{entry.title}}</span></button>
<button v-if="entry.favoriteAllowed!==false" class="star" type="button" :aria-label="`${entry.title} ${isFavorite(entry.screenId)?'즐겨찾기 해제':'즐겨찾기 추가'}`" :aria-pressed="isFavorite(entry.screenId)" @click="emit('toggleFavorite',entry.screenId)">{{isFavorite(entry.screenId)?'':''}}</button>
</div>
</div>
</section>
</template>
<div v-else class="collapsed-actions"><button v-for="group in grouped" :key="group.module" type="button" :class="{active:activeModule===group.module}" :title="`${group.label} 메뉴 열기`" @click="chooseModule(group.module)">{{group.module==='COMMON'?'공':group.module.charAt(0)}}</button><button type="button" title="메뉴 검색" aria-label="메뉴 검색" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"></button></div>
</aside>
</template>
<style scoped>
.side-nav{position:relative;width:var(--kbx-side-nav-width);min-width:var(--kbx-side-nav-width);border-right:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-navigation-surface);overflow:auto;padding:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-5)}.side-nav.collapsed{width:var(--kbx-side-nav-collapsed-width);min-width:var(--kbx-side-nav-collapsed-width);overflow:hidden}.side-nav__top{position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:var(--kbx-space-1);padding-bottom:var(--kbx-space-2);background:var(--kbx-color-navigation-surface)}.home{height:var(--kbx-control-sm);flex:1;display:flex;align-items:center;gap:var(--kbx-space-2);border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:0 var(--kbx-space-2);text-align:left}.home:hover,.home.active{background:var(--kbx-color-module-accent-surface);color:var(--kbx-color-module-accent)}.collapse{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm)}.side-nav__search{width:100%;height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface-muted);color:var(--kbx-color-text-muted)}.side-nav__search kbd{font-size:var(--kbx-font-xs)}.module-switcher{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--kbx-space-1);margin-top:var(--kbx-space-2)}.module-switcher button{height:var(--kbx-control-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);font-size:var(--kbx-font-xs)}.module-switcher button[aria-pressed="true"]{border-color:var(--kbx-color-module-accent-border);color:var(--kbx-color-module-accent);font-weight:600;background:var(--kbx-color-module-accent-surface)}.side-nav h2{margin:var(--kbx-space-3) var(--kbx-space-2) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.module-block{margin-top:var(--kbx-space-2);padding-top:var(--kbx-space-1);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.module-block.active>.module-title{color:var(--kbx-color-module-accent)}.section-block h3{margin:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.row{display:flex;align-items:center}.nav-item{min-height:var(--kbx-control-sm);border:0;background:transparent;border-radius:var(--kbx-radius-sm);display:flex;align-items:center;gap:var(--kbx-space-2);width:100%;padding:0 var(--kbx-space-2);text-align:left;color:var(--kbx-color-text)}.nav-item:hover,.nav-item.active{background:var(--kbx-color-surface-hover)}.nav-item.active{color:var(--kbx-color-module-accent);font-weight:600;box-shadow:inset var(--kbx-accent-border-width) 0 0 var(--kbx-color-module-accent)}.nav-item.main{min-width:0;flex:1}.nav-item.main span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.star{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;background:transparent;color:var(--kbx-color-text-muted);border-radius:var(--kbx-radius-sm)}.star:hover{background:var(--kbx-color-module-accent-surface);color:var(--kbx-color-module-accent)}.collapsed .side-nav__top{flex-direction:column}.collapsed .home{width:var(--kbx-control-md);flex:none;justify-content:center;padding:0}.collapsed .collapse{width:var(--kbx-control-md)}.collapsed-actions{display:flex;flex-direction:column;align-items:center;gap:var(--kbx-space-2);padding-top:var(--kbx-space-2);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.collapsed-actions button{width:var(--kbx-control-md);height:var(--kbx-control-md);border:0;border-radius:var(--kbx-radius-sm);background:transparent;font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.collapsed-actions button:hover,.collapsed-actions button.active{background:var(--kbx-color-module-accent-surface);color:var(--kbx-color-module-accent)}
@media(max-width:56rem){.side-nav:not(.collapsed){width:var(--kbx-side-nav-collapsed-width);min-width:var(--kbx-side-nav-collapsed-width);overflow:hidden;padding-inline:var(--kbx-space-2)}.side-nav:not(.collapsed) .side-nav__top{flex-direction:column}.side-nav:not(.collapsed) .home{width:var(--kbx-control-md);flex:none;justify-content:center;padding:0}.side-nav:not(.collapsed) .home span:last-child{display:none}.side-nav:not(.collapsed) .collapse{width:var(--kbx-control-md)}.side-nav:not(.collapsed) .side-nav__search,.side-nav:not(.collapsed) .module-switcher,.side-nav:not(.collapsed) .personal,.side-nav:not(.collapsed) .module-block{display:none}}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
open:boolean
title?:string
canSave?:boolean
saving?:boolean
}>(), {
title:'저장하지 않은 변경사항이 있습니다.',
canSave:true,
saving:false,
})
const emit = defineEmits<{ stay:[]; discard:[]; save:[] }>()
const dialog = ref<HTMLElement | null>(null)
let restoreFocus:HTMLElement | null = null
function focusables() {
if (!dialog.value) return []
return Array.from(dialog.value.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'))
.filter(element => element.offsetParent !== null)
}
function onKeydown(event:KeyboardEvent) {
if (event.key === 'Escape' && !props.saving) {
event.preventDefault()
emit('stay')
return
}
if (event.key !== 'Tab') return
const nodes = focusables()
if (!nodes.length) return
const first = nodes[0]
const last = nodes[nodes.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
watch(() => props.open, async (open, previous) => {
if (open) {
restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
focusables()[0]?.focus()
} else if (previous) {
await nextTick()
restoreFocus?.focus()
restoreFocus = null
}
})
</script>
<template>
<div v-if="props.open" class="overlay" @mousedown.self="!props.saving && emit('stay')">
<section
ref="dialog"
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="unsaved-title"
aria-describedby="unsaved-detail"
@keydown="onKeydown"
>
<h2 id="unsaved-title">{{props.title}}</h2>
<p id="unsaved-detail">변경사항을 어떻게 처리할지 선택하세요.</p>
<div class="actions">
<button type="button" :disabled="props.saving" @click="emit('stay')">계속 편집</button>
<button type="button" class="danger" :disabled="props.saving" @click="emit('discard')">변경 버리기</button>
<button v-if="props.canSave" type="button" class="primary" :disabled="props.saving" @click="emit('save')">{{props.saving?'저장 중...':'저장 이동'}}</button>
</div>
</section>
</div>
</template>
<style scoped>
.overlay{position:fixed;inset:0;z-index:220;display:flex;align-items:center;justify-content:center;background:var(--kbx-color-overlay)}
.dialog{width:min(440px,calc(100vw - 24px));padding:18px;border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}
h2{font-size:var(--kbx-font-lg);margin:0 0 8px}p{color:var(--kbx-color-text-muted)}
.actions{display:flex;justify-content:flex-end;gap:6px;margin-top:18px}.actions button{height:34px;padding:0 12px;border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}
.actions .primary{background:var(--kbx-color-primary);border-color:var(--kbx-color-primary);color:white}.actions .danger{color:var(--kbx-color-danger)}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import type { KbxWorkspaceTab } from '@kbx/contracts'
const props=withDefaults(defineProps<{tabs:KbxWorkspaceTab[];activeKey?:string|null;maxTabs?:number}>(),{activeKey:null,maxTabs:10})
const emit=defineEmits<{select:[tab:KbxWorkspaceTab];closeRequest:[tab:KbxWorkspaceTab];togglePin:[tab:KbxWorkspaceTab]}>()
const tabButtons=ref<Record<string,HTMLButtonElement|null>>({})
const visibleTabs=computed(()=>{if(props.tabs.length<=props.maxTabs)return props.tabs;const first=props.tabs.slice(0,props.maxTabs);const active=props.tabs.find(x=>x.key===props.activeKey);if(active&&!first.some(x=>x.key===active.key))first[first.length-1]=active;return first})
const overflowTabs=computed(()=>props.tabs.filter(tab=>!visibleTabs.value.some(x=>x.key===tab.key)))
const focusableKey=computed(()=>props.activeKey??visibleTabs.value[0]?.key??null)
async function selectAndFocus(tab:KbxWorkspaceTab){emit('select',tab);await nextTick();tabButtons.value[tab.key]?.focus()}
function move(tab:KbxWorkspaceTab,direction:'prev'|'next'|'first'|'last'){const list=visibleTabs.value;const index=list.findIndex(x=>x.key===tab.key);if(index<0)return;const target=direction==='first'?list[0]:direction==='last'?list.at(-1):list[(index+(direction==='next'?1:-1)+list.length)%list.length];if(target)void selectAndFocus(target)}
function keydown(event:KeyboardEvent,tab:KbxWorkspaceTab){if(event.key==='ArrowLeft'){event.preventDefault();move(tab,'prev')}else if(event.key==='ArrowRight'){event.preventDefault();move(tab,'next')}else if(event.key==='Home'){event.preventDefault();move(tab,'first')}else if(event.key==='End'){event.preventDefault();move(tab,'last')}}
function setTabButton(key:string,el:unknown){tabButtons.value[key]=el instanceof HTMLButtonElement?el:null}
</script>
<template>
<nav class="workspace-tabs" role="tablist" aria-label="열린 업무">
<div v-if="props.tabs.length===0" class="empty">열린 업무가 없습니다.</div>
<div v-for="tab in visibleTabs" :key="tab.key" class="tab-shell" :class="{active:tab.key===props.activeKey}">
<button :ref="el=>setTabButton(tab.key,el)" type="button" class="tab" role="tab" :aria-selected="tab.key===props.activeKey" :tabindex="tab.key===focusableKey?0:-1" @click="emit('select',tab)" @keydown="keydown($event,tab)" @auxclick.middle.prevent="emit('closeRequest',tab)"><span v-if="tab.pinned" class="pin-mark" aria-label="고정됨">◆</span><span v-if="tab.resumed&&!tab.dirty" class="resume-mark" title="이전 세션에서 복원된 업무 위치" aria-label="이전 세션에서 복원됨">↺</span><span class="title">{{tab.title}}</span><span v-if="tab.dirty" class="dirty" title="저장하지 않은 변경" aria-label="저장하지 않은 변경"></span></button>
<button type="button" class="pin" :aria-pressed="Boolean(tab.pinned)" :aria-label="tab.pinned?`${tab.title} 고정 해제`:`${tab.title} 고정`" @click="emit('togglePin',tab)">{{tab.pinned?'':''}}</button>
<button type="button" class="close" :aria-label="`${tab.title} 닫기`" @click="emit('closeRequest',tab)">×</button>
</div>
<details v-if="overflowTabs.length" class="overflow"><summary>더보기 {{ overflowTabs.length }}</summary><div class="overflow-menu"><button v-for="tab in overflowTabs" :key="tab.key" type="button" @click="emit('select',tab)"><span>{{tab.title}}</span><small v-if="tab.pinned">고정</small><small v-if="tab.resumed&&!tab.dirty">복원</small><small v-if="tab.dirty">미저장</small></button></div></details>
</nav>
</template>
<style scoped>
.workspace-tabs{height:var(--kbx-workspace-tabs-height);display:flex;align-items:flex-end;gap:var(--kbx-border-width);overflow-x:auto;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:0 var(--kbx-space-2)}.tab-shell{height:var(--kbx-workspace-tab-height);max-width:var(--kbx-workspace-tab-max-width);display:flex;align-items:center;border:var(--kbx-border-width) solid transparent;border-bottom:0;background:transparent}.tab-shell.active{background:var(--kbx-color-surface);border-color:var(--kbx-color-border);box-shadow:inset 0 calc(var(--kbx-border-width) * 2) 0 var(--kbx-color-module-accent)}.tab{height:100%;min-width:0;display:flex;align-items:center;gap:var(--kbx-space-2);border:0;background:transparent;padding:0 var(--kbx-space-2);color:var(--kbx-color-text-muted);white-space:nowrap}.tab-shell.active .tab{color:var(--kbx-color-text);font-weight:500}.title{overflow:hidden;text-overflow:ellipsis}.dirty{font-size:var(--kbx-font-xs);color:var(--kbx-color-warning)}.pin-mark{font-size:var(--kbx-font-xs);color:var(--kbx-color-module-accent)}.resume-mark{font-size:var(--kbx-font-sm);color:var(--kbx-color-info);font-weight:600}.pin,.close{width:var(--kbx-control-xs);height:100%;border:0;background:transparent;color:var(--kbx-color-text-muted)}.pin{font-size:var(--kbx-font-xs)}.close{font-size:var(--kbx-font-xl)}.pin:hover,.close:hover,.pin:focus-visible,.close:focus-visible{background:var(--kbx-color-surface-hover)}.pin[aria-pressed="true"]{color:var(--kbx-color-module-accent)}.empty{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);padding:0 var(--kbx-space-2) var(--kbx-space-2);margin:auto}.overflow{position:relative;margin-left:auto;padding-bottom:var(--kbx-space-1)}.overflow summary{list-style:none;cursor:pointer;font-size:var(--kbx-font-xs);padding:var(--kbx-space-2)}.overflow-menu{position:absolute;right:0;top:var(--kbx-control-height);z-index:80;min-width:var(--kbx-workspace-overflow-width);padding:var(--kbx-space-1);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}.overflow-menu button{width:100%;min-height:var(--kbx-control-xs);display:flex;align-items:center;gap:var(--kbx-space-2);border:0;background:transparent;text-align:left;padding:0 var(--kbx-space-2)}.overflow-menu button span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.overflow-menu button small{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.overflow-menu button:hover{background:var(--kbx-color-surface-hover)}
</style>
@@ -0,0 +1,128 @@
import type { KbxHomeAttentionItem, KbxHomeAttentionQueue, KbxHomeLaunchItem, KbxNavigationResolvedEntry, KbxOperationRun, KbxRecentNavigation, KbxUserNotification, KbxWorkspaceTab } from '@kbx/contracts'
const sourceLabel:Record<KbxHomeLaunchItem['source'],string>={dirty:'미저장',pinned:'고정',open:'열림',favorite:'즐겨찾기',recent:'최근',priority:'권장'}
function routeInstanceLabel(path:string, canonicalPath:string){
if(path===canonicalPath)return undefined
try{
const pathname=new URL(path,'https://kbx.local').pathname
const canonical=new URL(canonicalPath,'https://kbx.local').pathname
const parts=pathname.split('/').filter(Boolean)
const tail=parts.at(-1)??''
const actionSegments=new Set(['edit','view','detail','modify'])
const suffix=pathname.startsWith(canonical.endsWith('/')?canonical:`${canonical}/`)?pathname.slice(canonical.length).replace(/^\//,''):(actionSegments.has(tail.toLocaleLowerCase())?(parts.at(-2)??tail):tail)
if(!suffix)return '개별 업무'
const value=decodeURIComponent(suffix).replace(/[\r\n\t]/g,' ').trim()
return value.length>32?`${value.slice(0,29)}`:value
}catch{return '개별 업무'}
}
/**
* Build one Home workbench without duplicating catalog-level entries.
*
* Open workspace instances are intentionally NOT deduplicated by ScreenId: two different order/detail
* routes are two different pieces of work. Favorites/recents/priorities are then suppressed when that
* screen already has an open instance, avoiding the old "same screen repeated in several card groups" problem.
*/
export function buildKbxHomeWorkbench(
entries:KbxNavigationResolvedEntry[],
favorites:KbxNavigationResolvedEntry[],
recents:KbxRecentNavigation[],
tabs:KbxWorkspaceTab[],
limit=10,
):KbxHomeLaunchItem[]{
const byId=new Map(entries.filter(entry=>entry.menuVisible!==false).map(entry=>[entry.screenId,entry]))
const ordered:KbxHomeLaunchItem[]=[]
const seenLaunchKeys=new Set<string>()
const coveredScreenIds=new Set<string>()
const addTab=(tab:KbxWorkspaceTab,source:'dirty'|'pinned'|'open')=>{
const entry=byId.get(tab.screenId)
if(!entry||seenLaunchKeys.has(tab.key))return
seenLaunchKeys.add(tab.key)
coveredScreenIds.add(entry.screenId)
ordered.push({
launchKey:tab.key,
screenId:entry.screenId,
title:entry.title,
module:entry.module,
section:entry.section,
path:tab.path,
source,
sourceLabel:tab.resumed&&!tab.dirty?'복원':sourceLabel[source],
tabKey:tab.key,
instanceLabel:routeInstanceLabel(tab.path,entry.path),
homeGroup:entry.homeGroup,
})
}
const addCatalog=(entry:KbxNavigationResolvedEntry|undefined,source:'favorite'|'recent'|'priority',path?:string)=>{
if(!entry||entry.menuVisible===false||coveredScreenIds.has(entry.screenId))return
const launchKey=`screen:${entry.screenId}`
if(seenLaunchKeys.has(launchKey))return
seenLaunchKeys.add(launchKey)
coveredScreenIds.add(entry.screenId)
ordered.push({launchKey,screenId:entry.screenId,title:entry.title,module:entry.module,section:entry.section,path:path??entry.path,source,sourceLabel:sourceLabel[source],homeGroup:entry.homeGroup})
}
;[...tabs].filter(tab=>tab.dirty).sort((a,b)=>b.lastActivatedAt.localeCompare(a.lastActivatedAt)).forEach(tab=>addTab(tab,'dirty'))
;[...tabs].filter(tab=>!tab.dirty).sort((a,b)=>(Number(Boolean(b.pinned))-Number(Boolean(a.pinned)))||b.lastActivatedAt.localeCompare(a.lastActivatedAt)).forEach(tab=>addTab(tab,tab.pinned?'pinned':'open'))
favorites.forEach(entry=>addCatalog(entry,'favorite'))
recents.forEach(recent=>addCatalog(byId.get(recent.screenId),'recent',recent.path))
entries.filter(entry=>entry.homePriority!=null).sort((a,b)=>(a.homePriority??999)-(b.homePriority??999)||(a.order??999)-(b.order??999)).forEach(entry=>addCatalog(entry,'priority'))
return ordered.slice(0,Math.max(1,limit))
}
/** Backward-compatible entry-only view for callers that do not need launch-source metadata. */
export function buildKbxHomeQuickStart(entries:KbxNavigationResolvedEntry[],favorites:KbxNavigationResolvedEntry[],recents:KbxRecentNavigation[],tabs:KbxWorkspaceTab[],limit=8){
const byId=new Map(entries.map(entry=>[entry.screenId,entry]))
return buildKbxHomeWorkbench(entries,favorites,recents,tabs,limit).map(item=>byId.get(item.screenId)).filter((entry):entry is KbxNavigationResolvedEntry=>Boolean(entry))
}
/**
* Build the action-oriented Home attention queue.
* Runtime entities are display inputs only; callers must revalidate any route before navigation.
* Priority is business-defined, while items within one priority are newest-first for predictable recovery.
*/
export function buildKbxHomeAttentionQueue(
entries:KbxNavigationResolvedEntry[],
tabs:KbxWorkspaceTab[],
operations:KbxOperationRun[],
notifications:KbxUserNotification[],
limit=8,
):KbxHomeAttentionQueue{
const allowedIds=new Set(entries.filter(entry=>entry.menuVisible!==false).map(entry=>entry.screenId))
const all:KbxHomeAttentionItem[]=[]
for(const tab of tabs.filter(tab=>tab.dirty)){
if(!allowedIds.has(tab.screenId))continue
all.push({key:`dirty:${tab.key}`,source:'dirty',priority:10,occurredAt:tab.lastActivatedAt,title:tab.title,detail:'저장하지 않은 변경사항이 있습니다.',actionLabel:'계속 편집',screenId:tab.screenId,path:tab.path,tabKey:tab.key})
}
for(const operation of operations){
if(operation.sourceScreenId&&!allowedIds.has(operation.sourceScreenId))continue
const occurredAt=operation.completedAt??operation.startedAt??operation.requestedAt
if(operation.status==='failed'||operation.status==='partially-completed')all.push({key:`operation:${operation.id}`,source:'operation-failed',priority:20,occurredAt,title:operation.title,detail:operation.resultMessage??(operation.status==='failed'?'작업이 실패했습니다.':'일부 항목이 처리되지 않았습니다.'),actionLabel:'작업 보기',screenId:operation.sourceScreenId,operationId:operation.id})
else if(operation.status==='running'||operation.status==='queued')all.push({key:`operation:${operation.id}`,source:'operation-running',priority:40,occurredAt,title:operation.title,detail:operation.total?`${operation.processed??0} / ${operation.total} 처리`:'처리 중입니다.',actionLabel:'진행 보기',screenId:operation.sourceScreenId,operationId:operation.id})
}
for(const notification of notifications.filter(item=>!item.readAt)){
if(notification.screenId&&!allowedIds.has(notification.screenId))continue
const urgent=notification.severity==='warning'||notification.severity==='error'
all.push({key:`notification:${notification.id}`,source:urgent?'notification-urgent':'notification',priority:urgent?30:50,occurredAt:notification.createdAt,title:notification.title,detail:notification.message,actionLabel:notification.action?.label??'알림 보기',screenId:notification.screenId,path:notification.action?.route,notificationId:notification.id})
}
all.sort((a,b)=>a.priority-b.priority||b.occurredAt.localeCompare(a.occurredAt)||a.key.localeCompare(b.key))
const cap=Math.max(1,limit)
const sourceCounts:KbxHomeAttentionQueue['sourceCounts']={}
for(const item of all)sourceCounts[item.source]=(sourceCounts[item.source]??0)+1
return {items:all.slice(0,cap),totalCount:all.length,overflowCount:Math.max(all.length-cap,0),sourceCounts}
}
/** Backward-compatible item-only view. Prefer buildKbxHomeAttentionQueue for exact counts/overflow. */
export function buildKbxHomeAttention(
entries:KbxNavigationResolvedEntry[],
tabs:KbxWorkspaceTab[],
operations:KbxOperationRun[],
notifications:KbxUserNotification[],
limit=8,
):KbxHomeAttentionItem[]{
return buildKbxHomeAttentionQueue(entries,tabs,operations,notifications,limit).items
}
@@ -0,0 +1,12 @@
import type { KbxMenuSearchResult, KbxNavigationResolvedEntry } from '@kbx/contracts'
function normalize(value:string){return value.toLocaleLowerCase().replace(/\s+/g,'')}
export function searchNavigationEntries(entries:KbxNavigationResolvedEntry[], query:string, limit=12, priorityScreenIds:string[]=[]):KbxMenuSearchResult[]{
const q=normalize(query);const priority=new Map(priorityScreenIds.map((id,index)=>[id,index]))
return entries.map(entry=>{
const values=[entry.title,entry.screenId,entry.module,entry.section,...(entry.keywords??[])].map(normalize)
let score=q?0:(priority.has(entry.screenId)?1000-(priority.get(entry.screenId)??0):1)
for(const value of values){if(!q)continue;if(value===q)score=Math.max(score,100);else if(value.startsWith(q))score=Math.max(score,70);else if(value.includes(q))score=Math.max(score,40)}
if(q&&priority.has(entry.screenId)&&score>0)score+=5
return {screenId:entry.screenId,title:entry.title,module:entry.module,section:entry.section,path:entry.path,keywords:entry.keywords??[],score}
}).filter(x=>x.score>0).sort((a,b)=>b.score-a.score||a.title.localeCompare(b.title)).slice(0,limit)
}
@@ -0,0 +1,47 @@
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
const MAX_WORKSPACE_URL_LENGTH=2048
function normalizePathname(value:string){const clean=value.replace(/\/+$/,'')||'/';return clean.startsWith('/')?clean:`/${clean}`}
function hasUnsafeEncodedPath(value:string){return value.length>MAX_WORKSPACE_URL_LENGTH || /[\u0000-\u001f\u007f]|\\|%2f|%5c|%00|%0d|%0a|%09|%2e%2e/i.test(value)}
/** Match a Vue-Router-style path pattern without trusting persisted route strings. */
export function matchKbxRoutePattern(pattern:string,candidatePath:string){
if(hasUnsafeEncodedPath(candidatePath))return false
const p=normalizePathname(pattern).split('/').filter(Boolean)
const c=normalizePathname(candidatePath).split('/').filter(Boolean)
if(p.length!==c.length)return false
return p.every((segment,index)=>segment.startsWith(':') ? Boolean(c[index]) : segment===c[index])
}
/**
* Re-resolve an in-memory workspace path against the screen's declared route capabilities.
* Query/hash are retained only for the current in-memory workspace after the pathname passes the capability check.
*/
export function resolveKbxSafeWorkspacePath(patterns:readonly string[],candidatePath:string,fallbackPath:string){
try{
if(hasUnsafeEncodedPath(candidatePath))return fallbackPath
const candidate=new URL(candidatePath,'https://kbx.local')
if(candidate.origin!=='https://kbx.local'||candidate.username||candidate.password)return fallbackPath
const allowed=patterns.some(pattern=>matchKbxRoutePattern(pattern,candidate.pathname))
return allowed?`${candidate.pathname}${candidate.search}${candidate.hash}`:fallbackPath
}catch{return fallbackPath}
}
/**
* Re-resolve untrusted persisted recent navigation against the current authorized catalog entry.
* Query/hash are deliberately stripped before persistence/replay so transient filters, tokens, and sensitive
* values do not leak into local preference storage. Dynamic route path persistence remains explicit opt-in.
*/
export function resolveKbxSafeRecentPath(entry:KbxNavigationResolvedEntry, storedPath:string){
if(entry.recentPolicy!=='route')return entry.path
try{
if(hasUnsafeEncodedPath(storedPath))return entry.path
const base=new URL(entry.path,'https://kbx.local')
const candidate=new URL(storedPath,'https://kbx.local')
if(candidate.origin!==base.origin||candidate.username||candidate.password)return entry.path
const basePath=base.pathname.replace(/\/$/,'')||'/'
const candidatePath=candidate.pathname.replace(/\/$/,'')||'/'
const within=candidatePath===basePath || (basePath!=='/' && candidatePath.startsWith(`${basePath}/`))
return within?candidate.pathname:entry.path
}catch{return entry.path}
}