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,194 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
KbxAccessDenied,
KbxApplicationShell,
KbxHomePage,
KbxPermissionHostKey,
KbxNotificationCenter,
KbxOperationCenter,
KbxRuntimeBanner,
KbxUnsavedChangesDialog,
KbxUtilityRail,
KbxScreenUtilityHostKey,
resolveKbxSafeRecentPath,
resolveKbxSafeWorkspacePath,
useKbxShortcuts,
applyKbxTheme,
} from '@kbx/ui'
import type { KbxAiAnswer, KbxAiScreenContext, KbxHomeAttentionItem, KbxHomeLaunchItem, KbxNavigationSection, KbxRecentNavigation, KbxScreenDefinition, KbxSuggestionRequest, KbxWorkspaceTab } from '@kbx/contracts'
import type { KbxScreenUtilityHost, KbxScreenUtilityTab } from '@kbx/ui'
import { generatedScreens } from '../registry/screens.generated'
import { navigationSections, resolvedNavigationEntries } from './navigationCatalog'
import { screenRoutePatterns } from '../router/appRoutes'
import { useKbxWorkspaceStore } from './workspaceStore'
import { canWorkspaceSave, requestWorkspaceSave } from './workspaceLifecycleRegistry'
import { useKbxRuntime } from '../runtime/useKbxRuntime'
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
import { helpRegistry } from '../help/helpRegistry'
import { askKbxAssistant } from '../utility/aiAssistantApi'
import { submitKbxSuggestion } from '../utility/suggestionApi'
import { kbxAiCapabilities } from '../permissions/authorizationPolicy'
import { KbxScreenPreferenceScopeKey } from '../preferences/screenPreferenceStore'
import { loadWorkspaceSession, saveWorkspaceSession } from './workspaceSessionStore'
const props=withDefaults(defineProps<{productName?:string;grantedPermissions?:string[];preferenceScope?:string;appVersion?:string;userRole?:string}>(),{productName:'KBX',grantedPermissions:()=>[],preferenceScope:'',appVersion:'unknown',userRole:'user'})
const frameEmit=defineEmits<{profile:[]}>()
const router=useRouter();const route=useRoute();const store=useKbxWorkspaceStore();const pendingClose=ref<KbxWorkspaceTab|null>(null);const savingClose=ref(false);const runtimePanel=ref<'operations'|'notifications'|null>(null);const runtimePanelTargetId=ref<string|null>(null);const runtimePanelEl=ref<HTMLElement|null>(null);const runtimeReturnFocus=ref<HTMLElement|null>(null)
const { notice, operations, notifications, unreadCount, refresh, markRead }=useKbxRuntime()
const themeMode=computed(()=>(store.preference.themeMode==='dark'?'dark':'light') as 'light'|'dark')
const screenPreferenceScope=computed(()=>props.preferenceScope)
provide(KbxScreenPreferenceScopeKey,screenPreferenceScope)
watch(themeMode,mode=>applyKbxTheme(mode),{immediate:true})
const screenById=new Map(generatedScreens.map(screen=>[screen.id,screen]))
const hasPermission=(required?:string[])=>!required?.length || required.every(permission=>props.grantedPermissions.includes(permission))
provide(KbxPermissionHostKey,{has:(permission:string)=>props.grantedPermissions.includes(permission)})
const allowedEntries=computed(()=>resolvedNavigationEntries.filter(entry=>hasPermission(entry.permissions)&&entry.menuVisible!==false))
const allowedIds=computed(()=>new Set(allowedEntries.value.map(x=>x.screenId)))
const sections=computed<KbxNavigationSection[]>(()=>navigationSections.map(section=>({...section,entries:section.entries.filter(entry=>allowedIds.value.has(entry.screenId))})).filter(section=>section.entries.length))
const favorites=computed(()=>store.favoriteEntries(allowedEntries.value))
function screenFallback(screenId:string){return allowedEntries.value.find(entry=>entry.screenId===screenId)?.path??'/home'}
function safeWorkspacePath(screenId:string,candidate:string){return resolveKbxSafeWorkspacePath(screenRoutePatterns[screenId]??[],candidate,screenFallback(screenId))}
const recents=computed<KbxRecentNavigation[]>(()=>store.preference.recents.flatMap(item=>{const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId);if(!nav)return [];const recent=resolveKbxSafeRecentPath(nav,item.path);return [{...item,title:nav.title,path:safeWorkspacePath(item.screenId,recent)}]}))
const allowedTabs=computed(()=>store.tabs.filter(tab=>{const screen=screenById.get(tab.screenId);return Boolean(screen&&hasPermission(screen.permissions)&&safeWorkspacePath(tab.screenId,tab.path)===tab.path)}))
const isHome=computed(()=>route.path==='/home')
const activeScreenId=computed(()=>String(route.meta.screenId??''))
const activeScreen=computed(()=>screenById.get(activeScreenId.value))
const activeScreenKnown=computed(()=>!activeScreenId.value || Boolean(activeScreen.value))
const activeScreenAllowed=computed(()=>activeScreenKnown.value && (!activeScreen.value || hasPermission(activeScreen.value.permissions)))
const activeModule=computed(()=>isHome.value?'':activeScreen.value?.module??'')
const runningOperationCount=computed(()=>operations.value.filter(x=>x.status==='running'||x.status==='queued').length)
const failedOperationCount=computed(()=>operations.value.filter(x=>x.status==='failed'||x.status==='partially-completed').length)
const urgentUnreadCount=computed(()=>notifications.value.filter(x=>!x.readAt&&(x.severity==='warning'||x.severity==='error')).length)
const runtimeScreenAllowed=(screenId?:string)=>!screenId||Boolean(screenById.get(screenId)&&hasPermission(screenById.get(screenId)?.permissions))
const homeOperations=computed(()=>operations.value.filter(item=>runtimeScreenAllowed(item.sourceScreenId)))
const homeNotifications=computed(()=>notifications.value.filter(item=>runtimeScreenAllowed(item.screenId)))
const utilityTab=ref<KbxScreenUtilityTab|null>(null)
const utilityScreen=ref<KbxScreenDefinition|null>(null)
const utilityAiAnswer=ref<KbxAiAnswer|null>(null)
const utilityAiLoading=ref(false)
const utilityAiError=ref('')
const utilitySuggestionSubmitting=ref(false)
const utilityHelp=computed(()=>utilityScreen.value?.helpKey?helpRegistry[utilityScreen.value.helpKey]:undefined)
const utilityCapabilities=computed(()=>kbxAiCapabilities(props.grantedPermissions,['explain','suggest','draft']))
const utilityAiContext=computed<KbxAiScreenContext|undefined>(()=>utilityScreen.value&&utilityCapabilities.value.length?{screenId:utilityScreen.value.id,screenVersion:utilityScreen.value.version,allowedCapabilities:utilityCapabilities.value}:undefined)
const utilitySuggestionContext=computed(()=>utilityScreen.value&&props.grantedPermissions.includes('common.suggestion.create')?{screenId:utilityScreen.value.id,screenVersion:utilityScreen.value.version,route:route.fullPath,appVersion:props.appVersion,userRole:props.userRole,activeFilters:[]}:undefined)
const utilityHost:KbxScreenUtilityHost={
available(screen){const tabs:KbxScreenUtilityTab[]=[];if(screen.helpKey&&helpRegistry[screen.helpKey])tabs.push('help');if(utilityCapabilities.value.length)tabs.push('ai');if(props.grantedPermissions.includes('common.suggestion.create'))tabs.push('suggestion');return tabs},
open(screen,tab){closeRuntimePanel(false);utilityScreen.value=screen;utilityTab.value=tab;utilityAiAnswer.value=null;utilityAiError.value=''},
}
provide(KbxScreenUtilityHostKey,utilityHost)
async function askUtilityAi(question:string){if(!utilityAiContext.value)return;utilityAiLoading.value=true;utilityAiError.value='';try{utilityAiAnswer.value=await askKbxAssistant({question,context:utilityAiContext.value})}catch{utilityAiError.value='네트워크 상태를 확인한 후 다시 질문하세요.'}finally{utilityAiLoading.value=false}}
async function submitUtilitySuggestion(request:KbxSuggestionRequest){utilitySuggestionSubmitting.value=true;try{await submitKbxSuggestion(request);utilityTab.value=null}finally{utilitySuggestionSubmitting.value=false}}
const permittedScreenIds=computed(()=>new Set(generatedScreens.filter(screen=>hasPermission(screen.permissions)).map(screen=>screen.id)))
function restoreWorkspaceForScope(scope:string){
const snapshot=loadWorkspaceSession(scope)
if(!snapshot)return {restored:0,dirtyDiscarded:0}
const restored:KbxWorkspaceTab[]=[]
for(const item of snapshot.tabs){
const screen=screenById.get(item.screenId)
if(!screen||!hasPermission(screen.permissions))continue
const safe=safeWorkspacePath(item.screenId,item.path)
if(safe!==item.path)continue
restored.push({key:`${item.screenId}:${safe}`,screenId:item.screenId,title:screen.title,path:safe,pinned:item.pinned,dirty:false,resumed:true,openedAt:item.openedAt,lastActivatedAt:item.lastActivatedAt})
}
return {restored:store.restoreTabs(restored),dirtyDiscarded:snapshot.dirtyDiscardedCount}
}
function persistWorkspaceSession(){
const scope=screenPreferenceScope.value
if(scope)saveWorkspaceSession(scope,store.tabs)
}
watch(permittedScreenIds,ids=>{const removed=store.pruneTabs(ids);if(removed)store.shellNotice='권한 변경으로 더 이상 사용할 수 없는 열린 업무를 안전하게 닫았습니다.'},{immediate:true})
watch(allowedIds,ids=>store.reconcilePreference(ids),{immediate:true})
async function syncCurrentRoute(){
if(isHome.value)return
const screenId=activeScreenId.value
if(!screenId)return
const screen=screenById.get(screenId)
if(!screen||!hasPermission(screen.permissions))return
const safe=safeWorkspacePath(screenId,route.fullPath)
if(safe!==route.fullPath){store.shellNotice='허용되지 않은 화면 경로를 차단했습니다.';await router.replace('/home');return}
const navEntry=resolvedNavigationEntries.find(entry=>entry.screenId===screenId&&entry.menuVisible!==false&&hasPermission(entry.permissions))
const recent=navEntry&&navEntry.recentPolicy!=='none'?{screenId,title:screen.title,path:navEntry.recentPolicy==='route'?resolveKbxSafeRecentPath(navEntry,safe):navEntry.path,visitedAt:new Date().toISOString()}:null
const synced=store.syncRoute(screenId,screen.title,safe,recent)
if(!synced&&route.path!=='/home')await router.replace('/home')
}
watch(()=>props.preferenceScope,(scope,previous)=>{
store.setPreferenceScope(scope);utilityTab.value=null;utilityScreen.value=null;closeRuntimePanel(false)
const resume=scope?restoreWorkspaceForScope(scope):{restored:0,dirtyDiscarded:0}
if(resume.restored||resume.dirtyDiscarded)void nextTick(()=>store.notifyShell(`${resume.restored?`이전 세션 업무 ${resume.restored}개를 위치만 안전하게 복원했습니다. `:''}${resume.dirtyDiscarded?`미저장 편집 ${resume.dirtyDiscarded}개는 데이터 정합성을 위해 복원하지 않았습니다.`:''}`.trim()))
if(previous!==undefined&&scope!==previous&&route.path!=='/home')void router.replace('/home')
},{immediate:true})
watch(()=>props.grantedPermissions.join('|'),()=>{utilityTab.value=null;utilityScreen.value=null;closeRuntimePanel(false)})
watch(()=>store.tabs.map(tab=>[tab.key,Boolean(tab.dirty),Boolean(tab.pinned),Boolean(tab.resumed),tab.lastActivatedAt]).join('|'),persistWorkspaceSession,{flush:'post'})
watch(()=>route.fullPath,()=>{closeRuntimePanel(false);void syncCurrentRoute()},{immediate:true})
watch(activeScreen,(screen,previous)=>{utilityTab.value=null;utilityScreen.value=null;
if(previous?.telemetry?.enabled&&hasPermission(previous.permissions))kbxTelemetry.track('screen.close',{screenId:previous.id,screenVersion:previous.version,attributes:{module:previous.module,closeReason:'navigate'}})
if(screen?.telemetry?.enabled&&hasPermission(screen.permissions))kbxTelemetry.track('screen.open',{screenId:screen.id,screenVersion:screen.version,attributes:{module:screen.module,launchMode:'tab'}})
},{immediate:true})
function closeRuntimePanel(restoreFocus=true){
const target=runtimeReturnFocus.value
runtimePanel.value=null
runtimePanelTargetId.value=null
runtimeReturnFocus.value=null
if(restoreFocus&&target)void nextTick(()=>target.focus())
}
function openMenuSearch(){closeRuntimePanel(false);store.menuSearchOpen=true}
async function toggleRuntimePanel(kind:'operations'|'notifications',targetId:string|null=null){
if(runtimePanel.value===kind&&!targetId){closeRuntimePanel();return}
utilityTab.value=null;utilityScreen.value=null
runtimeReturnFocus.value=document.activeElement instanceof HTMLElement?document.activeElement:null
runtimePanel.value=kind
runtimePanelTargetId.value=targetId
await nextTick();if(!targetId)runtimePanelEl.value?.focus()
}
async function openHome(){store.deactivate();if(route.path!=='/home')await router.push('/home')}
async function openEntry(requested:(typeof resolvedNavigationEntries)[number]){const entry=allowedEntries.value.find(item=>item.screenId===requested.screenId);if(!entry)return;store.menuSearchOpen=false;await router.push(entry.path)}
async function openRecent(entry:KbxRecentNavigation){const nav=allowedEntries.value.find(item=>item.screenId===entry.screenId);if(!nav)return;const candidate=safeWorkspacePath(entry.screenId,resolveKbxSafeRecentPath(nav,entry.path));await router.push(candidate)}
async function selectTab(tab:KbxWorkspaceTab){const stored=store.tabs.find(item=>item.key===tab.key);if(!stored||!permittedScreenIds.value.has(stored.screenId))return;const safe=safeWorkspacePath(stored.screenId,stored.path);if(safe==='/home'&&stored.path!=='/home'){store.close(stored.key);store.shellNotice='변조되었거나 더 이상 유효하지 않은 열린 업무 경로를 제거했습니다.';await openHome();return}store.activate(stored.key);if(route.fullPath!==safe)await router.push(safe)}
async function launchHomeItem(item:KbxHomeLaunchItem){if(item.tabKey){const tab=store.tabs.find(tab=>tab.key===item.tabKey);if(tab)return selectTab(tab)}const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId);if(!nav)return;const candidate=item.source==='recent'?resolveKbxSafeRecentPath(nav,item.path):nav.path;await router.push(safeWorkspacePath(item.screenId,candidate))}
async function openHomeAttention(item:KbxHomeAttentionItem){
if(item.tabKey){const tab=store.tabs.find(tab=>tab.key===item.tabKey);if(tab)return selectTab(tab)}
if(item.operationId){await toggleRuntimePanel('operations',item.operationId);return}
if(item.notificationId){
await markRead(item.notificationId)
if(item.screenId){
const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId)
if(nav){
const candidate=item.path??nav.path
const safe=safeWorkspacePath(item.screenId,candidate)
if(item.path&&safe!==item.path){store.shellNotice='알림에 포함된 허용되지 않은 이동 경로를 차단했습니다.';await toggleRuntimePanel('notifications');return}
await router.push(safe);return
}
}
await toggleRuntimePanel('notifications',item.notificationId)
}
}
function closeTab(tab:KbxWorkspaceTab){if(tab.dirty){pendingClose.value=tab;return}void discardAndClose(tab)}
async function discardAndClose(tab:KbxWorkspaceTab){const wasCurrent=route.fullPath===tab.path;store.close(tab.key);pendingClose.value=null;if(wasCurrent){const next=store.activeTab;if(next)await selectTab(next);else await openHome()}}
async function saveAndClose(){const tab=pendingClose.value;if(!tab)return;savingClose.value=true;try{const saved=await requestWorkspaceSave(tab.path);if(saved)await discardAndClose(tab)}finally{savingClose.value=false}}
useKbxShortcuts([{key:'Ctrl+K',scope:'application',execute:openMenuSearch},{key:'Esc',scope:'application',priority:20,enabled:()=>Boolean(runtimePanel.value),execute:()=>closeRuntimePanel()}])
onMounted(()=>kbxTelemetry.start());onBeforeUnmount(()=>kbxTelemetry.stop())
</script>
<template>
<KbxApplicationShell
:product-name="props.productName" :active-module="activeModule" :active-screen-id="activeScreenId" :sections="sections" :all-entries="allowedEntries" :favorites="favorites" :recents="recents" :tabs="allowedTabs" :active-tab-key="store.activeKey" :side-nav-collapsed="store.preference.sideNavCollapsed" :menu-search-open="store.menuSearchOpen" :operation-count="runningOperationCount" :notification-count="unreadCount" :operations-open="runtimePanel==='operations'" :notifications-open="runtimePanel==='notifications'" :home-active="isHome" :workspace-max-tabs="store.preference.workspaceMaxTabs" :shell-notice="store.shellNotice" :theme-mode="themeMode"
@home="openHome" @open="openEntry" @open-recent="openRecent" @select-tab="selectTab" @close-tab="closeTab" @toggle-tab-pin="tab=>store.togglePinned(tab.key)" @toggle-favorite="store.toggleFavorite" @toggle-side-nav="store.toggleSideNav" @menu-search-open="openMenuSearch" @menu-search-close="store.menuSearchOpen=false" @operations="toggleRuntimePanel('operations')" @notifications="toggleRuntimePanel('notifications')" @theme-toggle="store.toggleTheme" @profile="frameEmit('profile')" @dismiss-shell-notice="store.dismissShellNotice"
>
<template #runtime><KbxRuntimeBanner :notice="notice" @retry="refresh" /><aside v-if="runtimePanel" id="kbx-runtime-panel" ref="runtimePanelEl" class="runtime-panel" tabindex="-1" role="region" :aria-labelledby="runtimePanel==='operations'?'kbx-runtime-panel-title-operations':'kbx-runtime-panel-title-notifications'"><header class="runtime-panel__header"><strong :id="runtimePanel==='operations'?'kbx-runtime-panel-title-operations':'kbx-runtime-panel-title-notifications'">{{runtimePanel==='operations'?'작업 센터':'알림 센터'}}</strong><button type="button" aria-label="패널 닫기" @click="closeRuntimePanel()">×</button></header><KbxOperationCenter v-if="runtimePanel==='operations'" :operations="operations" :active-id="runtimePanelTargetId" /><KbxNotificationCenter v-else :notifications="notifications" :active-id="runtimePanelTargetId" @read="markRead" /></aside></template>
<KbxHomePage v-if="isHome" :entries="allowedEntries" :favorites="favorites" :recents="recents" :operation-count="runningOperationCount" :operation-failure-count="failedOperationCount" :notification-count="unreadCount" :urgent-notification-count="urgentUnreadCount" :operations="homeOperations" :notifications="homeNotifications" :tabs="allowedTabs" @open="openEntry" @launch="launchHomeItem" @attention="openHomeAttention" @toggle-favorite="store.toggleFavorite" @menu-search="openMenuSearch" @operations="toggleRuntimePanel('operations')" @notifications="toggleRuntimePanel('notifications')" />
<KbxAccessDenied v-else-if="!activeScreenAllowed" @home="openHome" @menu-search="openMenuSearch" />
<router-view v-else />
</KbxApplicationShell>
<KbxUnsavedChangesDialog :open="Boolean(pendingClose)" :can-save="pendingClose?canWorkspaceSave(pendingClose.path):false" :saving="savingClose" @stay="pendingClose=null" @discard="pendingClose&&discardAndClose(pendingClose)" @save="saveAndClose" />
<KbxUtilityRail triggerless :open-tab="utilityTab" :help="utilityHelp" :ai-context="utilityAiContext" :ai-answer="utilityAiAnswer" :ai-loading="utilityAiLoading" :ai-error="utilityAiError" :ai-current-screen-label="utilityScreen?.title" :suggestion-context="utilitySuggestionContext" :suggestion-submitting="utilitySuggestionSubmitting" @close="utilityTab=null" @ai-ask="askUtilityAi" @suggestion-submit="submitUtilitySuggestion" />
</template>
<style scoped>.runtime-panel__header{min-height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.runtime-panel__header button{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;border-radius:var(--kbx-radius-sm);background:transparent;color:var(--kbx-color-text)}.runtime-panel__header button:hover{background:var(--kbx-color-surface-hover)}.runtime-panel{position:fixed;z-index:var(--kbx-z-runtime-panel);right:var(--kbx-space-2);top:var(--kbx-runtime-panel-top);width:min(var(--kbx-runtime-panel-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}</style>
@@ -0,0 +1,35 @@
import type { KbxNavigationEntry, KbxNavigationResolvedEntry, KbxNavigationSection, KbxScreenDefinition } from '@kbx/contracts'
import { generatedScreens } from '../registry/screens.generated'
export const navigationCatalog: KbxNavigationEntry[] = [
{ screenId:'OMS-ORD-001', path:'/oms/orders', section:'주문', keywords:['주문조회','주문관리','출고대기'], order:10, homePriority:10, homeGroup:'주문 처리' },
{ screenId:'OMS-ORD-002', path:'/oms/orders/new', section:'주문', keywords:['주문등록','신규주문'], order:20, homePriority:20, homeGroup:'주문 처리', permissions:['oms.order.create','erp.item.read'] },
{ screenId:'OMS-ORD-003', path:'/oms/orders/import', section:'주문', keywords:['엑셀','업로드','대량주문'], order:30, homePriority:30, homeGroup:'대량 업무' },
{ screenId:'OMS-CLM-001', path:'/oms/claims', section:'CS/클레임', keywords:['반품','교환','취소','클레임'], order:40 },
{ screenId:'ERP-MST-ITEM-001', path:'/erp/items', section:'기준정보', keywords:['품목','상품','SKU','마스터'], order:10, homePriority:10, homeGroup:'기준정보' },
{ screenId:'ERP-PRICE-001', path:'/erp/item-prices', section:'기준정보', keywords:['품목단가','단가','가격','일괄입력','Fast Entry'], order:15, homePriority:20, homeGroup:'대량 업무' },
{ screenId:'ERP-PUR-001', path:'/erp/purchases/new', section:'구매', keywords:['구매','발주','매입'], order:20 },
{ screenId:'ERP-INV-001', path:'/erp/inventory', section:'재고', keywords:['재고','현재고','가용재고','로케이션'], order:30, homePriority:30, homeGroup:'재고' },
{ screenId:'ERP-INV-MOVE-001', path:'/erp/inventory-moves/new', section:'재고', keywords:['재고이동','창고이동'], order:40 },
{ screenId:'WMS-WORK-001', path:'/wms/work', section:'작업관리', keywords:['입고','적치','피킹','실사','작업'], order:10, homePriority:10, homeGroup:'현장 운영' },
{ screenId:'COMMON-OPS-001', path:'/operations/exceptions', section:'운영', keywords:['예외','오류','실패','미처리'], order:10, homePriority:5, homeGroup:'예외 처리' },
{ screenId:'COMMON-REC-001', path:'/operations/reconcile', section:'운영', keywords:['대사','정합성','불일치','비교'], order:20, homePriority:15, homeGroup:'예외 처리' },
{ screenId:'COMMON-EXP-001', path:'/internal/kbx/experiments', section:'KBX', keywords:['UX','실험','A/B','점진배포','롤백'], order:75, menuVisible:false, favoriteAllowed:false },
{ screenId:'COMMON-UX-001', path:'/internal/kbx/ux-metrics', section:'KBX', keywords:['UX','지표','텔레메트리','자동화','개입률'], order:80, menuVisible:false, favoriteAllowed:false },
{ screenId:'COMMON-DATA-001', path:'/internal/kbx/external-data', section:'KBX', keywords:['외부데이터','KRX','OPENDART','KIS','신선도','출처'], order:85, menuVisible:false, favoriteAllowed:false },
{ screenId:'COMMON-DS-001', path:'/internal/kbx/catalog', section:'KBX', keywords:['컴포넌트','디자인시스템','카탈로그'], order:90, menuVisible:false, favoriteAllowed:false },
]
const screenById = new Map<string,KbxScreenDefinition>(generatedScreens.map(screen => [screen.id, screen]))
export const resolvedNavigationEntries: KbxNavigationResolvedEntry[] = navigationCatalog.map(entry => {
const screen=screenById.get(entry.screenId)
if(!screen) throw new Error(`Navigation references unknown screen: ${entry.screenId}`)
return { ...entry, title:screen.title, module:screen.module, permissions:entry.permissions ?? screen.permissions, launchMode:entry.launchMode ?? 'tab', menuVisible:entry.menuVisible ?? true, favoriteAllowed:entry.favoriteAllowed ?? true, recentPolicy:entry.recentPolicy ?? 'screen' }
})
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통 운영'}
export const navigationSections:KbxNavigationSection[] = ['OMS','ERP','WMS','COMMON'].flatMap(module => {
const moduleEntries=resolvedNavigationEntries.filter(x=>x.module===module && x.menuVisible!==false)
const groups=[...new Set(moduleEntries.map(x=>x.section))]
return groups.map(group=>({ key:`${module}:${group}`, label:`${moduleLabel[module]} · ${group}`, module:module as KbxNavigationSection['module'], entries:moduleEntries.filter(x=>x.section===group).sort((a,b)=>(a.order??0)-(b.order??0)) }))
})
@@ -0,0 +1,5 @@
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import { useKbxWorkspaceStore } from './workspaceStore'
import { registerWorkspaceLifecycle } from './workspaceLifecycleRegistry'
export function useKbxWorkspaceBinding(dirty:Ref<boolean>, save?:()=>boolean|Promise<boolean>){const route=useRoute();const store=useKbxWorkspaceStore();let unregister:(()=>void)|undefined;onMounted(()=>{unregister=registerWorkspaceLifecycle(route.fullPath,{save});store.setActiveDirty(dirty.value)});watch(dirty,value=>store.setActiveDirty(value));onBeforeUnmount(()=>unregister?.())}
@@ -0,0 +1,7 @@
export interface KbxWorkspaceLifecycle {
save?: () => boolean | Promise<boolean>
}
const registry = new Map<string,KbxWorkspaceLifecycle>()
export function registerWorkspaceLifecycle(path:string, lifecycle:KbxWorkspaceLifecycle){ registry.set(path,lifecycle); return () => registry.delete(path) }
export async function requestWorkspaceSave(path:string):Promise<boolean>{ const handler=registry.get(path)?.save; return handler ? await handler() : false }
export function canWorkspaceSave(path:string){ return Boolean(registry.get(path)?.save) }
@@ -0,0 +1,94 @@
import type { KbxWorkspaceTab } from '@kbx/contracts'
const PREFIX = 'kbx.workspace.session.v1'
const MAX_SCOPE_LENGTH = 160
const MAX_TABS = 12
const MAX_PATH_LENGTH = 2048
const MAX_ID_LENGTH = 128
const MAX_TIME_LENGTH = 64
const MAX_STORED_BYTES = 48_000
export interface KbxWorkspaceSessionSnapshot {
tabs: Array<Pick<KbxWorkspaceTab, 'screenId' | 'path' | 'pinned' | 'openedAt' | 'lastActivatedAt'>>
dirtyDiscardedCount: number
savedAt: string
}
function bounded(value: unknown, max: number) {
if (typeof value !== 'string') return ''
const clean = value.trim()
return clean.length > 0 && clean.length <= max ? clean : ''
}
function scopeKey(scopeValue: unknown) {
const scope = bounded(scopeValue, MAX_SCOPE_LENGTH)
return scope ? `${PREFIX}:${encodeURIComponent(scope)}` : ''
}
function safeTime(value: unknown) {
const time = bounded(value, MAX_TIME_LENGTH)
return time && !Number.isNaN(Date.parse(time)) ? time : new Date().toISOString()
}
function safePath(value: unknown) {
const raw = bounded(value, MAX_PATH_LENGTH)
if (!raw || /[\u0000-\u001f\u007f\\]|%2f|%5c|%00|%0d|%0a|%09|%2e%2e/i.test(raw)) return ''
try {
const parsed = new URL(raw, 'https://kbx.local')
if (parsed.origin !== 'https://kbx.local' || parsed.username || parsed.password) return ''
// Workspace resume deliberately persists only route identity. Query/hash may contain transient
// filters, tokens, customer/order text or other sensitive context and must not survive refresh.
return parsed.pathname
} catch { return '' }
}
function normalizeSnapshot(value: unknown): KbxWorkspaceSessionSnapshot | null {
if (!value || typeof value !== 'object') return null
const raw = value as Partial<KbxWorkspaceSessionSnapshot>
if (!Array.isArray(raw.tabs)) return null
const tabs: KbxWorkspaceSessionSnapshot['tabs'] = []
const seen = new Set<string>()
for (const candidate of raw.tabs.slice(0, MAX_TABS)) {
if (!candidate || typeof candidate !== 'object') continue
const item = candidate as Partial<KbxWorkspaceTab>
const screenId = bounded(item.screenId, MAX_ID_LENGTH)
const path = safePath(item.path)
if (!screenId || !path) continue
const identity = `${screenId}:${path}`
if (seen.has(identity)) continue
seen.add(identity)
tabs.push({screenId, path, pinned:Boolean(item.pinned), openedAt:safeTime(item.openedAt), lastActivatedAt:safeTime(item.lastActivatedAt)})
}
const dirtyDiscardedCount = Math.min(MAX_TABS, Math.max(0, Number.isFinite(Number(raw.dirtyDiscardedCount)) ? Number(raw.dirtyDiscardedCount) : 0))
return { tabs, dirtyDiscardedCount, savedAt:safeTime(raw.savedAt) }
}
export function loadWorkspaceSession(scopeValue: string): KbxWorkspaceSessionSnapshot | null {
const key = scopeKey(scopeValue)
if (!key || typeof sessionStorage === 'undefined') return null
let raw = ''
try { raw = sessionStorage.getItem(key) ?? '' } catch { return null }
if (!raw || raw.length > MAX_STORED_BYTES) return null
try { return normalizeSnapshot(JSON.parse(raw)) } catch { return null }
}
export function saveWorkspaceSession(scopeValue: string, tabs: readonly KbxWorkspaceTab[]) {
const key = scopeKey(scopeValue)
if (!key || typeof sessionStorage === 'undefined') return false
const cleanTabs = tabs.filter(tab => !tab.dirty).slice(0, MAX_TABS).flatMap(tab => {
const screenId = bounded(tab.screenId, MAX_ID_LENGTH)
const path = safePath(tab.path)
if (!screenId || !path) return []
return [{screenId, path, pinned:Boolean(tab.pinned), openedAt:safeTime(tab.openedAt), lastActivatedAt:safeTime(tab.lastActivatedAt)}]
})
const snapshot:KbxWorkspaceSessionSnapshot = {
tabs: cleanTabs,
dirtyDiscardedCount: Math.min(MAX_TABS, tabs.filter(tab => tab.dirty).length),
savedAt: new Date().toISOString(),
}
const payload = JSON.stringify(snapshot)
if (payload.length > MAX_STORED_BYTES) return false
try { sessionStorage.setItem(key, payload); return true } catch { return false }
}
export function clearWorkspaceSession(scopeValue: string) {
const key = scopeKey(scopeValue)
if (!key || typeof sessionStorage === 'undefined') return false
try { sessionStorage.removeItem(key); return true } catch { return false }
}
@@ -0,0 +1,123 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { KbxNavigationPreference, KbxNavigationResolvedEntry, KbxRecentNavigation, KbxWorkspaceTab } from '@kbx/contracts'
const STORAGE_PREFIX='kbx.navigation.preference.v3'
const LEGACY_STORAGE_PREFIX='kbx.navigation.preference.v2'
const MAX_STORED_PREFERENCE_BYTES=64_000
const MAX_SCOPE_LENGTH=160
const MAX_ID_LENGTH=128
const MAX_TITLE_LENGTH=120
const MAX_RECENT_PATH_LENGTH=1024
const defaultPreference=():KbxNavigationPreference=>({favorites:[],recents:[],sideNavCollapsed:false,workspaceMaxTabs:10,themeMode:'light'})
function boundedString(value:unknown,max:number,truncate=false){if(typeof value!=='string')return '';const clean=value.trim();if(clean.length<=max)return clean;return truncate?clean.slice(0,max):''}
function normalizeScope(value?:string|null){const scope=boundedString(value,MAX_SCOPE_LENGTH);return scope||null}
function storageKey(scope:string,prefix=STORAGE_PREFIX){return `${prefix}:${encodeURIComponent(scope)}`}
function uniqueStrings(values:unknown[],limit:number){return [...new Set(values.map(value=>boundedString(value,MAX_ID_LENGTH)).filter(Boolean))].slice(0,limit)}
function normalizePreference(value:unknown,scope:string|null):KbxNavigationPreference{
const raw=(value&&typeof value==='object'?value:{}) as Partial<KbxNavigationPreference>
const favorites=Array.isArray(raw.favorites)?uniqueStrings(raw.favorites,50):[]
const recentsRaw=Array.isArray(raw.recents)?raw.recents.flatMap(item=>{
if(!item||typeof item!=='object')return []
const candidate=item as Partial<KbxRecentNavigation>
const screenId=boundedString(candidate.screenId,MAX_ID_LENGTH)
const title=boundedString(candidate.title,MAX_TITLE_LENGTH,true)
const path=boundedString(candidate.path,MAX_RECENT_PATH_LENGTH)
const visitedAt=boundedString(candidate.visitedAt,64)
if(!screenId||!title||!path.startsWith('/')||!visitedAt||Number.isNaN(Date.parse(visitedAt)))return []
return [{screenId,title,path,visitedAt} satisfies KbxRecentNavigation]
}):[]
const recents=[...new Map(recentsRaw.map(item=>[item.screenId,item])).values()].sort((a,b)=>b.visitedAt.localeCompare(a.visitedAt)).slice(0,12)
const workspaceMaxTabs=Math.min(12,Math.max(8,Number.isFinite(Number(raw.workspaceMaxTabs))?Number(raw.workspaceMaxTabs):10))
const themeMode=raw.themeMode==='dark'?'dark':'light'
return {favorites,recents,sideNavCollapsed:Boolean(raw.sideNavCollapsed),workspaceMaxTabs,themeMode,...(scope?{scopeKey:scope}:{})}
}
function parseStoredPreference(raw:string|null,scope:string){
if(!raw||raw.length>MAX_STORED_PREFERENCE_BYTES)return null
try{return normalizePreference(JSON.parse(raw),scope)}catch{return null}
}
function loadPreference(scope:string|null):KbxNavigationPreference{
if(!scope || typeof localStorage==='undefined')return defaultPreference()
let currentRaw:string|null=null;let legacyRaw:string|null=null
try{currentRaw=localStorage.getItem(storageKey(scope));legacyRaw=localStorage.getItem(storageKey(scope,LEGACY_STORAGE_PREFIX))}catch{return normalizePreference({},scope)}
const current=parseStoredPreference(currentRaw,scope)
if(current)return current
const legacy=parseStoredPreference(legacyRaw,scope)
if(legacy){try{localStorage.setItem(storageKey(scope),JSON.stringify(legacy))}catch{};return legacy}
return normalizePreference({},scope)
}
export const useKbxWorkspaceStore=defineStore('kbx-workspace',()=>{
const scopeKey=ref<string|null>(null)
const preference=ref<KbxNavigationPreference>(defaultPreference())
const tabs=ref<KbxWorkspaceTab[]>([])
const activeKey=ref<string|null>(null)
const menuSearchOpen=ref(false)
const shellNotice=ref<string|null>(null)
function persist(){
if(!scopeKey.value||typeof localStorage==='undefined')return
try{localStorage.setItem(storageKey(scopeKey.value),JSON.stringify(normalizePreference(preference.value,scopeKey.value)))}
catch{shellNotice.value='개인화 설정을 저장하지 못했습니다. 현재 세션에서는 계속 사용할 수 있습니다.'}
}
function clearWorkspaceSession(){tabs.value=[];activeKey.value=null;menuSearchOpen.value=false;shellNotice.value=null}
function setPreferenceScope(value?:string|null){
const next=normalizeScope(value)
if(next===scopeKey.value)return
clearWorkspaceSession()
scopeKey.value=next
preference.value=loadPreference(next)
}
function key(screenId:string,path:string){return `${screenId}:${path}`}
function evictForCapacity(){
if(tabs.value.length<preference.value.workspaceMaxTabs)return true
const candidate=[...tabs.value]
.filter(tab=>!tab.dirty&&!tab.pinned&&tab.key!==activeKey.value)
.sort((a,b)=>a.lastActivatedAt.localeCompare(b.lastActivatedAt))[0]
if(!candidate){shellNotice.value=`열린 업무가 최대 ${preference.value.workspaceMaxTabs}개입니다. 미저장 또는 고정된 업무를 정리한 후 다시 여세요.`;return false}
close(candidate.key)
return true
}
function restoreTabs(restored:KbxWorkspaceTab[]){
const limit=preference.value.workspaceMaxTabs
const normalized=restored.filter(tab=>!tab.dirty).slice(0,limit).map(tab=>({...tab,dirty:false,resumed:true}))
tabs.value=normalized
activeKey.value=null
return normalized.length
}
function notifyShell(message:string){shellNotice.value=boundedString(message,240,true)||null}
function syncRoute(screenId:string,title:string,path:string,recent?:KbxRecentNavigation|null){
const now=new Date().toISOString();const tabKey=key(screenId,path);let tab=tabs.value.find(x=>x.key===tabKey)
if(!tab){if(!evictForCapacity())return false;tab={key:tabKey,screenId,title,path,openedAt:now,lastActivatedAt:now,dirty:false};tabs.value.push(tab)}else{tab.title=title;tab.path=path;tab.lastActivatedAt=now;tab.resumed=false}
activeKey.value=tabKey;shellNotice.value=null
if(recent)recordRecent(recent)
return true
}
function setDirty(tabKey:string,dirty:boolean){const tab=tabs.value.find(x=>x.key===tabKey);if(tab)tab.dirty=dirty}
function setActiveDirty(dirty:boolean){if(activeKey.value)setDirty(activeKey.value,dirty)}
function togglePinned(tabKey:string){const tab=tabs.value.find(x=>x.key===tabKey);if(tab)tab.pinned=!tab.pinned}
function close(tabKey:string){const index=tabs.value.findIndex(x=>x.key===tabKey);if(index<0)return;const wasActive=activeKey.value===tabKey;tabs.value.splice(index,1);if(wasActive){const next=tabs.value[Math.min(index,tabs.value.length-1)]??tabs.value.at(-1);activeKey.value=next?.key??null}}
function activate(tabKey:string){const tab=tabs.value.find(x=>x.key===tabKey);if(tab){activeKey.value=tabKey;tab.lastActivatedAt=new Date().toISOString();tab.resumed=false}}
function deactivate(){activeKey.value=null}
function pruneTabs(allowedScreenIds:Set<string>){
const active=activeKey.value;const before=tabs.value.length
tabs.value=tabs.value.filter(tab=>allowedScreenIds.has(tab.screenId))
if(active&&!tabs.value.some(tab=>tab.key===active))activeKey.value=tabs.value.at(-1)?.key??null
return before-tabs.value.length
}
function reconcilePreference(allowedScreenIds:Set<string>){
const favorites=preference.value.favorites.filter(id=>allowedScreenIds.has(id))
const recents=preference.value.recents.filter(item=>allowedScreenIds.has(item.screenId))
const changed=favorites.length!==preference.value.favorites.length||recents.length!==preference.value.recents.length
if(changed){preference.value={...preference.value,favorites,recents};persist()}
return changed
}
function toggleFavorite(screenId:string){const normalized=boundedString(screenId,MAX_ID_LENGTH);if(!normalized)return;const list=preference.value.favorites;const index=list.indexOf(normalized);if(index>=0)list.splice(index,1);else list.unshift(normalized);preference.value.favorites=uniqueStrings(list,50);persist()}
function recordRecent(recent:KbxRecentNavigation){const normalized=normalizePreference({...preference.value,recents:[recent,...preference.value.recents.filter(x=>x.screenId!==recent.screenId)]},scopeKey.value).recents;preference.value.recents=normalized;persist()}
function toggleSideNav(){preference.value.sideNavCollapsed=!preference.value.sideNavCollapsed;persist()}
function toggleTheme(){preference.value.themeMode=preference.value.themeMode==='dark'?'light':'dark';persist()}
function favoriteEntries(entries:KbxNavigationResolvedEntry[]){const map=new Map(entries.filter(x=>x.favoriteAllowed!==false).map(x=>[x.screenId,x]));return preference.value.favorites.map(id=>map.get(id)).filter((x):x is KbxNavigationResolvedEntry=>Boolean(x))}
function dismissShellNotice(){shellNotice.value=null}
const activeTab=computed(()=>tabs.value.find(x=>x.key===activeKey.value)??null)
return {scopeKey,preference,tabs,activeKey,activeTab,menuSearchOpen,shellNotice,setPreferenceScope,clearWorkspaceSession,restoreTabs,notifyShell,syncRoute,setDirty,setActiveDirty,togglePinned,close,activate,deactivate,pruneTabs,reconcilePreference,toggleFavorite,recordRecent,toggleSideNav,toggleTheme,favoriteEntries,dismissShellNotice}
})