V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { KbxScreenDefinition, KbxScreenTemplateCode, KbxScreenType } from '@kbx/contracts'
import KbxCommandBar from './KbxCommandBar.vue'
import KbxDataState from './KbxDataState.vue'
import KbxPageHeader from './KbxPageHeader.vue'
import { KbxScreenUtilityHostKey, type KbxScreenUtilityTab } from '../utility/host'
import { KbxPermissionHostKey } from '../permission/host'
const props = withDefaults(defineProps<{
screen: KbxScreenDefinition
/** Template wrapper must state the canonical screen type it implements. */
expectedType?: KbxScreenType
templateCode?: KbxScreenTemplateCode
selectionCount?: number
can?: (permission: string) => boolean
breadcrumb?: string
status?: string
dirty?: boolean
stickyCommands?: boolean
commandBar?: boolean
suppressDefaultUtility?: boolean
}>(), {
expectedType: undefined,
templateCode: undefined,
selectionCount: 0,
breadcrumb: '',
status: '',
dirty: false,
stickyCommands: true,
commandBar: true,
suppressDefaultUtility: false,
})
const emit = defineEmits<{ command: [string] }>()
const utilityHost=inject(KbxScreenUtilityHostKey,null)
const permissionHost=inject(KbxPermissionHostKey,null)
const defaultUtilities=computed(()=>utilityHost?.available(props.screen)??[])
const utilityLabel:Record<KbxScreenUtilityTab,string>={help:'도움말',ai:'AI',suggestion:'제안'}
const templateMismatch=computed(()=>Boolean((props.expectedType&&props.screen.type!==props.expectedType)||(props.templateCode&&props.screen.templateCode!==props.templateCode)))
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
const permissionDenied=computed(()=>Boolean(props.screen.permissions?.some(permission=>!canPermission(permission))))
</script>
<template>
<section
class="kbx-screen-frame"
:data-screen-id="screen.id"
:data-screen-type="screen.type"
:data-template="templateCode"
:data-status="status || undefined"
:data-dirty="dirty || undefined"
:data-access="permissionDenied?'denied':'allowed'"
>
<KbxPageHeader
data-kbx-surface="page-header"
:title="screen.title"
:description="screen.description"
:breadcrumb="breadcrumb"
:status="status"
:dirty="dirty"
>
<template #utility><slot name="utility" /><nav v-if="!suppressDefaultUtility && defaultUtilities.length" class="kbx-screen-frame__utility" aria-label="화면 보조기능"><button v-for="tab in defaultUtilities" :key="tab" type="button" @click="utilityHost?.open(screen,tab)">{{utilityLabel[tab]}}</button></nav></template>
</KbxPageHeader>
<KbxDataState v-if="templateMismatch" class="kbx-screen-frame__contract-error" state="error" title="화면 템플릿 구성이 올바르지 않습니다." detail="화면 정의와 표준 화면 유형이 일치하지 않습니다. 안전을 위해 업무 영역을 표시하지 않습니다." />
<KbxDataState v-else-if="permissionDenied" class="kbx-screen-frame__contract-error" state="error" title="이 화면을 사용할 권한이 없습니다." detail="현재 권한으로는 이 업무 화면을 열 수 없습니다. 메뉴 검색에서 사용 가능한 업무를 선택하세요." />
<template v-else>
<div
v-if="commandBar && ((screen.commands?.length ?? 0) > 0 || $slots.commands)"
class="kbx-screen-frame__commands"
data-kbx-surface="command-bar"
:class="{ 'is-sticky': stickyCommands }"
>
<slot name="commands">
<KbxCommandBar
:commands="screen.commands ?? []"
:selection-count="selectionCount"
:status="status"
:dirty="dirty"
:can="canPermission"
@command="emit('command', $event)"
/>
</slot>
</div>
<div v-if="$slots.notice" class="kbx-screen-frame__notice"><slot name="notice" /></div>
<div class="kbx-screen-frame__body"><slot /></div>
</template>
</section>
</template>
<style scoped>
.kbx-screen-frame {display:flex;flex-direction:column;min-height:0;height:100%;background:var(--kbx-color-surface)}
.kbx-screen-frame__commands{min-height:var(--kbx-command-bar-height);padding:0 var(--kbx-screen-inline-padding);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);z-index:8}
.kbx-screen-frame__commands.is-sticky{position:sticky;top:0}.kbx-screen-frame__notice{padding:var(--kbx-space-2) var(--kbx-screen-inline-padding) 0}.kbx-screen-frame__body{min-height:0;flex:1;display:flex;flex-direction:column;gap:var(--kbx-screen-section-gap);padding:var(--kbx-space-2) var(--kbx-screen-inline-padding) var(--kbx-space-3)}
.kbx-screen-frame__utility{display:flex;align-items:center;gap:var(--kbx-space-1)}.kbx-screen-frame__utility button{height:var(--kbx-control-xs);padding:0 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);color:var(--kbx-color-text);font-size:var(--kbx-font-xs)}.kbx-screen-frame__utility button:hover{background:var(--kbx-color-surface-hover);border-color:var(--kbx-color-border-strong)}.kbx-screen-frame__contract-error{margin:var(--kbx-space-3) var(--kbx-screen-inline-padding)}
</style>