122 lines
18 KiB
Vue
122 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import type { KbxHomeAttentionItem, KbxHomeLaunchItem, KbxNavigationResolvedEntry, KbxOperationRun, KbxRecentNavigation, KbxUserNotification, KbxWorkspaceTab } from '@kbx/contracts'
|
||
import { buildKbxHomeAttentionQueue, buildKbxHomeWorkbench } from './homeNavigation'
|
||
|
||
const props=withDefaults(defineProps<{
|
||
entries:KbxNavigationResolvedEntry[]
|
||
favorites?:KbxNavigationResolvedEntry[]
|
||
recents?:KbxRecentNavigation[]
|
||
tabs?:KbxWorkspaceTab[]
|
||
operationCount?:number
|
||
operationFailureCount?:number
|
||
notificationCount?:number
|
||
urgentNotificationCount?:number
|
||
operations?:KbxOperationRun[]
|
||
notifications?:KbxUserNotification[]
|
||
}>(),{favorites:()=>[],recents:()=>[],tabs:()=>[],operationCount:0,operationFailureCount:0,notificationCount:0,urgentNotificationCount:0,operations:()=>[],notifications:()=>[]})
|
||
const emit=defineEmits<{
|
||
open:[entry:KbxNavigationResolvedEntry]
|
||
launch:[item:KbxHomeLaunchItem]
|
||
attention:[item:KbxHomeAttentionItem]
|
||
menuSearch:[]
|
||
operations:[]
|
||
notifications:[]
|
||
toggleFavorite:[screenId:string]
|
||
}>()
|
||
const moduleOrder=['OMS','ERP','WMS','COMMON'] as const
|
||
const moduleLabel:Record<string,string>={OMS:'주문·채널',ERP:'기준정보·구매·재고',WMS:'물류 작업',COMMON:'공통 운영'}
|
||
const selectedModule=ref<string>('ALL')
|
||
const modules=computed(()=>moduleOrder.map(module=>({module,label:moduleLabel[module],groups:[...new Set(props.entries.filter(x=>x.module===module&&x.menuVisible!==false).map(x=>x.section))].map(section=>({section,entries:props.entries.filter(x=>x.module===module&&x.section===section&&x.menuVisible!==false).sort((a,b)=>(a.order??0)-(b.order??0))}))})).filter(x=>x.groups.some(group=>group.entries.length)))
|
||
const visibleModules=computed(()=>selectedModule.value==='ALL'?modules.value:modules.value.filter(x=>x.module===selectedModule.value))
|
||
const workbench=computed(()=>buildKbxHomeWorkbench(props.entries,props.favorites,props.recents,props.tabs,10))
|
||
const attentionQueue=computed(()=>buildKbxHomeAttentionQueue(props.entries,props.tabs,props.operations,props.notifications,8))
|
||
const attentionItems=computed(()=>attentionQueue.value.items)
|
||
const exactAttentionCount=computed(()=>attentionQueue.value.totalCount)
|
||
const attentionOverflowCount=computed(()=>attentionQueue.value.overflowCount)
|
||
const dirtyCount=computed(()=>props.tabs.filter(tab=>tab.dirty).length)
|
||
const pinnedCount=computed(()=>props.tabs.filter(tab=>tab.pinned).length)
|
||
const regularOpenCount=computed(()=>props.tabs.filter(tab=>!tab.pinned).length)
|
||
const regularNotificationCount=computed(()=>Math.max(props.notificationCount-props.urgentNotificationCount,0))
|
||
const attentionCount=computed(()=>props.operationCount+props.operationFailureCount+props.notificationCount+dirtyCount.value)
|
||
const sourceSummary=computed(()=>({favorites:props.favorites.length,open:regularOpenCount.value,pinned:pinnedCount.value,recent:props.recents.length}))
|
||
const firstDirty=computed(()=>workbench.value.find(item=>item.source==='dirty'))
|
||
const favoriteIds=computed(()=>new Set(props.favorites.map(entry=>entry.screenId)))
|
||
const openCounts=computed(()=>props.tabs.reduce((map,tab)=>map.set(tab.screenId,(map.get(tab.screenId)??0)+1),new Map<string,number>()))
|
||
function isFavorite(screenId:string){return favoriteIds.value.has(screenId)}
|
||
const visibleScreenCount=computed(()=>visibleModules.value.reduce((sum,module)=>sum+module.groups.reduce((groupSum,group)=>groupSum+group.entries.length,0),0))
|
||
watch(modules,value=>{if(selectedModule.value!=='ALL'&&!value.some(item=>item.module===selectedModule.value))selectedModule.value='ALL'})
|
||
function openCount(screenId:string){return openCounts.value.get(screenId)??0}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="kbx-home" aria-labelledby="kbx-home-title">
|
||
<header class="kbx-home__header">
|
||
<div><p>KBX 업무 시작</p><h1 id="kbx-home-title">홈</h1><span>검색하고, 이어서 처리하고, 예외만 확인하는 업무 시작점입니다.</span></div>
|
||
<button type="button" class="kbx-home__search" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>메뉴명 · 화면코드 · 업무명 검색</span><kbd>Ctrl K</kbd></button>
|
||
</header>
|
||
|
||
<section class="kbx-home__attention" aria-labelledby="kbx-home-attention-title">
|
||
<div class="kbx-home__attention-head"><strong id="kbx-home-attention-title">확인 필요</strong><span v-if="!exactAttentionCount && !attentionCount" class="quiet">현재 확인할 작업이나 알림이 없습니다.</span><small v-else-if="attentionOverflowCount">전체 {{exactAttentionCount}}개 · 상위 {{attentionItems.length}}개 표시</small><small v-else>{{exactAttentionCount || attentionCount}}개 우선 항목</small></div>
|
||
<div v-if="attentionItems.length" class="kbx-home__attention-list" role="list">
|
||
<button v-for="item in attentionItems" :key="item.key" type="button" role="listitem" :class="{critical:item.source==='operation-failed'||item.source==='notification-urgent'}" @click="emit('attention',item)">
|
||
<span class="attention-source">{{ item.source==='dirty'?'미저장':item.source==='operation-failed'?'실패 작업':item.source==='notification-urgent'?'중요 알림':item.source==='operation-running'?'진행 작업':'알림' }}</span>
|
||
<span class="attention-copy"><b>{{item.title}}</b><small v-if="item.detail">{{item.detail}}</small></span>
|
||
<span class="attention-action">{{item.actionLabel}}</span><span aria-hidden="true">›</span>
|
||
</button>
|
||
</div>
|
||
<div v-else-if="attentionCount" class="kbx-home__attention-legacy">
|
||
<button v-if="dirtyCount" type="button" @click="firstDirty && emit('launch',firstDirty)">미저장 업무 <b>{{dirtyCount}}</b></button>
|
||
<button v-if="operationFailureCount" type="button" class="critical" @click="emit('operations')">실패·부분완료 작업 <b>{{operationFailureCount}}</b></button>
|
||
<button v-if="urgentNotificationCount" type="button" class="critical" @click="emit('notifications')">중요 알림 <b>{{urgentNotificationCount}}</b></button>
|
||
<button v-if="operationCount" type="button" @click="emit('operations')">진행 중 작업 <b>{{operationCount}}</b></button>
|
||
<button v-if="regularNotificationCount" type="button" @click="emit('notifications')">새 알림 <b>{{regularNotificationCount}}</b></button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="kbx-home__section kbx-home__workbench" aria-labelledby="kbx-home-workbench-title">
|
||
<header>
|
||
<div><h2 id="kbx-home-workbench-title">바로 시작</h2><span>미저장·고정·열린 업무를 먼저 보여주고, 즐겨찾기·최근·권장 업무를 중복 없이 이어 붙입니다.</span></div>
|
||
<small>열림 {{sourceSummary.open}} · 고정 {{sourceSummary.pinned}} · 즐겨찾기 {{sourceSummary.favorites}} · 최근 {{sourceSummary.recent}}</small>
|
||
</header>
|
||
<div v-if="workbench.length" class="kbx-home__workbench-list" role="list">
|
||
<button v-for="item in workbench" :key="item.launchKey" type="button" role="listitem" @click="emit('launch',item)">
|
||
<span class="source" :data-source="item.source">{{item.sourceLabel}}</span>
|
||
<span class="main"><b>{{item.title}}</b><small>{{item.module}} · {{item.homeGroup ?? item.section}}<template v-if="item.instanceLabel"> · {{item.instanceLabel}}</template></small></span>
|
||
<span class="resume">{{item.tabKey ? '이어서' : '열기'}}</span><span aria-hidden="true">›</span>
|
||
</button>
|
||
</div>
|
||
<p v-else class="kbx-home__empty">사용 가능한 주요 업무가 없습니다. 메뉴 검색에서 권한이 허용된 화면을 찾아보세요.</p>
|
||
</section>
|
||
|
||
<section class="kbx-home__all" aria-label="전체 업무">
|
||
<header class="kbx-home__all-header">
|
||
<div><h2>모듈별 업무</h2><span>업무 분류에서 화면을 찾고, 자주 쓰는 화면은 여기서 바로 즐겨찾기에 고정합니다.</span></div>
|
||
<nav aria-label="홈 모듈 필터"><button type="button" :aria-pressed="selectedModule==='ALL'" @click="selectedModule='ALL'">전체</button><button v-for="item in modules" :key="item.module" type="button" :aria-pressed="selectedModule===item.module" @click="selectedModule=item.module">{{item.module==='COMMON'?'공통':item.module}}</button></nav>
|
||
<small class="kbx-home__filter-status" aria-live="polite">{{visibleScreenCount}}개 화면</small>
|
||
</header>
|
||
<div class="kbx-home__modules">
|
||
<section v-for="item in visibleModules" :key="item.module" class="kbx-home__module">
|
||
<header><strong>{{item.module}}</strong><span>{{item.label}}</span><small>{{item.groups.reduce((sum,group)=>sum+group.entries.length,0)}}개 화면</small></header>
|
||
<div v-for="group in item.groups" :key="group.section" class="kbx-home__group">
|
||
<h3>{{group.section}}</h3>
|
||
<div class="kbx-home__group-links">
|
||
<div v-for="entry in group.entries" :key="entry.screenId" class="kbx-home__group-row">
|
||
<button class="launch" type="button" @click="emit('open',entry)">
|
||
<span>{{entry.title}}</span>
|
||
<small>{{entry.homeGroup ?? entry.section}}<template v-if="openCount(entry.screenId)"> · 열림 {{openCount(entry.screenId)}}</template></small>
|
||
</button>
|
||
<button v-if="entry.favoriteAllowed!==false" class="favorite" type="button" :aria-label="isFavorite(entry.screenId)?`${entry.title} 즐겨찾기 해제`:`${entry.title} 즐겨찾기 추가`" :aria-pressed="isFavorite(entry.screenId)" @click="emit('toggleFavorite',entry.screenId)">{{isFavorite(entry.screenId)?'★':'☆'}}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.kbx-home{max-width:var(--kbx-home-max-width);margin:0 auto;padding:var(--kbx-space-5) var(--kbx-space-5) var(--kbx-space-8);display:flex;flex-direction:column;gap:var(--kbx-space-4)}.kbx-home__header{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-6);min-height:var(--kbx-home-header-min-height);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-home__header p,.kbx-home__header span{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-home__header h1{margin:0;font-size:var(--kbx-font-2xl)}.kbx-home__search{width:min(var(--kbx-home-search-max-width),45vw);height:var(--kbx-control-lg);display:flex;justify-content:space-between;align-items:center;padding:0 var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);color:var(--kbx-color-text-muted);text-align:left}.kbx-home__search kbd{font-size:var(--kbx-font-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);padding:var(--kbx-space-1);background:var(--kbx-color-surface-muted)}.kbx-home__attention{display:grid;gap:var(--kbx-space-2);min-height:var(--kbx-home-attention-height);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-warning-surface)}.kbx-home__attention-head{display:flex;align-items:center;gap:var(--kbx-space-2)}.kbx-home__attention-head small{margin-left:auto;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-home__attention .quiet{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-home__attention-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--kbx-space-1)}.kbx-home__attention-list button{min-height:var(--kbx-control-lg);display:grid;grid-template-columns:var(--kbx-home-source-label-width) minmax(0,1fr) auto var(--kbx-checkbox-track);align-items:center;gap: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);padding:var(--kbx-space-1) var(--kbx-space-2);text-align:left}.kbx-home__attention-list button.critical{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.attention-source,.attention-action{font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted);white-space:nowrap}.critical .attention-source{color:var(--kbx-color-danger)}.attention-copy{display:grid;min-width:0}.attention-copy b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--kbx-color-text)}.attention-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-home__attention-legacy{display:flex;align-items:center;gap:var(--kbx-space-2);flex-wrap:wrap}.kbx-home__attention-legacy 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);padding:0 var(--kbx-space-3)}.kbx-home__attention-legacy button.critical{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.kbx-home__section,.kbx-home__all{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-home__section>header,.kbx-home__all-header{min-height:var(--kbx-control-lg);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);padding:var(--kbx-space-2) var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.kbx-home__section>header>div,.kbx-home__all-header>div{display:grid;gap:var(--kbx-space-1)}.kbx-home__section h2,.kbx-home__all h2{margin:0;font-size:var(--kbx-font-lg);font-weight:600}.kbx-home__section>header span,.kbx-home__section>header small,.kbx-home__all-header span{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-home__workbench-list{display:flex;flex-direction:column}.kbx-home__workbench-list button{min-height:var(--kbx-home-workbench-row-height);display:grid;grid-template-columns:var(--kbx-home-source-label-width) minmax(0,1fr) auto var(--kbx-checkbox-track);align-items:center;gap:var(--kbx-space-2);padding:0 var(--kbx-space-3);border:0;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:transparent;text-align:left}.kbx-home__workbench-list button:last-child{border-bottom:0}.kbx-home__workbench-list button:hover{background:var(--kbx-color-surface-hover)}.kbx-home__workbench-list .source{font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.kbx-home__workbench-list .source[data-source="dirty"]{color:var(--kbx-color-warning-text)}.kbx-home__workbench-list .source[data-source="pinned"],.kbx-home__workbench-list .source[data-source="favorite"]{color:var(--kbx-color-primary)}.kbx-home__workbench-list .main{display:grid;min-width:0}.kbx-home__workbench-list .main b{font-size:var(--kbx-font-md);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kbx-home__workbench-list .main small,.kbx-home__workbench-list .resume{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-home__workbench-list .resume{white-space:nowrap}.kbx-home__empty{margin:0;padding:var(--kbx-space-5) var(--kbx-space-3);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-home__all-header nav{display:flex;gap:var(--kbx-space-1);margin-left:auto}.kbx-home__filter-status{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);white-space:nowrap}.kbx-home__all-header nav 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);padding:0 var(--kbx-space-2)}.kbx-home__all-header nav button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}.kbx-home__modules{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.kbx-home__module{min-width:0;border-right:var(--kbx-border-width) solid var(--kbx-color-border);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-home__module:nth-child(2n){border-right:0}.kbx-home__module>header{height:var(--kbx-home-module-header-height);display:flex;align-items:center;gap:var(--kbx-space-2);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-home__module>header strong{font-size:var(--kbx-font-lg)}.kbx-home__module>header span,.kbx-home__module>header small{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-home__module>header small{margin-left:auto}.kbx-home__group{padding:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-3)}.kbx-home__group h3{margin:0 var(--kbx-space-1) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.kbx-home__group-links{display:flex;flex-direction:column}.kbx-home__group-row{min-height:var(--kbx-control-height);display:flex;align-items:center;border-radius:var(--kbx-radius-sm)}.kbx-home__group-row:hover{background:var(--kbx-color-surface-hover)}.kbx-home__group-row .launch{min-height:var(--kbx-control-height);min-width:0;flex:1;display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-2);border:0;background:transparent;text-align:left}.kbx-home__group-row .launch span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kbx-home__group-row .launch small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);white-space:nowrap}.kbx-home__group-row .favorite{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-muted)}.kbx-home__group-row .favorite[aria-pressed="true"]{color:var(--kbx-color-primary)}.kbx-home__group-row .favorite:hover{background:var(--kbx-color-surface)}@media(max-width:56rem){.kbx-home__attention-list{grid-template-columns:1fr}.kbx-home__modules{grid-template-columns:1fr}.kbx-home__module{border-right:0}.kbx-home__header,.kbx-home__all-header{align-items:flex-start;flex-direction:column;padding-bottom:var(--kbx-space-3)}.kbx-home__search{width:100%}}
|
||
</style>
|