Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/packages/kbx-ui/src/shell/KbxHomePage.vue
T

122 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>