129 lines
7.7 KiB
TypeScript
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
|
|
}
|
|
|