Files
KArtSell.Aegis/docs/Design/kbx-foundation-v60-status-canonical-contract-hardening/packages/kbx-ui/src/shell/homeNavigation.ts
T
kjh2064 3f293d8aa8
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s
V13-FE-006: consolidate approved UI and contract hardening
2026-08-13 02:41:00 +09:00

129 lines
7.7 KiB
TypeScript

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
}