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,82 @@
import type { KbxComponentCatalogEntry } from '@kbx/contracts'
const commonInputStates = [
{ id:'default', label:'기본', state:'default' as const },
{ id:'required', label:'필수', state:'required' as const },
{ id:'readonly', label:'읽기전용', state:'readonly' as const },
{ id:'disabled', label:'사용불가', state:'disabled' as const },
{ id:'changed', label:'변경됨', state:'changed' as const },
{ id:'warning', label:'주의', state:'warning' as const },
{ id:'ai-suggested', label:'AI 제안', state:'ai-suggested' as const },
{ id:'error', label:'오류', state:'error' as const },
{ id:'keyboard', label:'키보드', state:'keyboard' as const },
]
export const kbxComponentCatalog: KbxComponentCatalogEntry[] = [
{ component:'KbxInput', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxNumberField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxMoneyField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxQuantityField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxDateField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxDateRange', group:'input', scenarios:[...commonInputStates.slice(0,4),{id:'keyboard',label:'키보드',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxSelect', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxLookup', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxButton', group:'command', scenarios:[{id:'default',label:'기본',state:'default'},{id:'disabled',label:'사용불가',state:'disabled'},{id:'loading',label:'처리중',state:'loading'},{id:'keyboard',label:'단축키',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxCommandBar', group:'command', scenarios:[{id:'default',label:'기본',state:'default'},{id:'keyboard',label:'F3/F8',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxSearchPanel', group:'command', scenarios:[{id:'default',label:'기본 조회조건',state:'default'},{id:'keyboard',label:'F3/Enter',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxPageHeader', group:'template', scenarios:[{id:'default',label:'화면명/경로/Utility',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxFormSection', group:'template', scenarios:[{id:'default',label:'기본정보',state:'default'},{id:'description',label:'설명/보조 Action',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxFormGrid', group:'template', scenarios:[{id:'two-column',label:'Desktop 2-column',state:'default',density:'compact'},{id:'narrow',label:'좁은 화면 1-column',state:'default'}], accessibility:{keyboard:false,focusVisible:false} },
{ component:'KbxFormSpan', group:'template', scenarios:[{id:'full',label:'주소/비고 Full span',state:'default'}], accessibility:{keyboard:false,focusVisible:false} },
{ component:'KbxBulkActionBar', group:'command', scenarios:[{id:'default',label:'17건 선택',state:'default'},{id:'disabled',label:'조건 불충족',state:'disabled'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxIntegrationState', group:'feedback', scenarios:[{id:'queued',label:'전송 대기',state:'default'},{id:'retrying',label:'자동 재시도',state:'loading'},{id:'delivered',label:'전송 완료',state:'default'},{id:'failed',label:'연계 실패',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxDataProvenance', group:'feedback', scenarios:[{id:'fresh',label:'최신',state:'default'},{id:'stale',label:'Stale',state:'loading'},{id:'expired',label:'만료',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxStatus', group:'feedback', scenarios:[{id:'default',label:'상태 의미',state:'default'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxToast', group:'feedback', scenarios:[{id:'default',label:'성공',state:'default'},{id:'error',label:'주의',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxConfirm', group:'overlay', scenarios:[{id:'default',label:'위험 확인',state:'default'},{id:'keyboard',label:'Esc/Primary',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxDialog', group:'overlay', scenarios:[{id:'default',label:'기본',state:'default'},{id:'keyboard',label:'Esc/Focus Trap',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxDrawer', group:'overlay', scenarios:[{id:'default',label:'상세',state:'default'},{id:'keyboard',label:'Esc/Focus Restore',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxDataGrid', group:'grid', scenarios:[{id:'default',label:'Compact',state:'default',density:'compact'},{id:'loading',label:'조회 중',state:'loading'},{id:'empty',label:'결과 없음',state:'empty'},{id:'keyboard',label:'Keyboard/F2',state:'keyboard'},{id:'fast-entry',label:'Paste/Fill Down/행복제',state:'keyboard'},{id:'all-filtered',label:'검색결과 전체선택',state:'default'},{id:'changed',label:'Changed Cell',state:'changed'},{id:'error',label:'Cell/API Error 이동',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxExcelMenu', group:'command', scenarios:[{id:'default',label:'다운로드/업로드 메뉴',state:'default'},{id:'keyboard',label:'키보드 이동',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxExcelImport', group:'template', scenarios:[{id:'default',label:'파일',state:'default'},{id:'error',label:'검증 오류',state:'error'},{id:'loading',label:'반영중',state:'loading'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxJobProgress', group:'feedback', scenarios:[{id:'default',label:'진행중',state:'default'},{id:'loading',label:'장시간 처리',state:'loading'},{id:'error',label:'부분실패',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxAuditTrail', group:'feedback', scenarios:[{id:'default',label:'변경이력',state:'default'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxHelpPanel', group:'overlay', scenarios:[{id:'default',label:'화면 도움말',state:'default'},{id:'keyboard',label:'Esc/Focus',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxAiPanel', group:'overlay', scenarios:[{id:'default',label:'AI 보조',state:'default'},{id:'loading',label:'답변 준비',state:'loading'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxProposalPanel', group:'overlay', scenarios:[{id:'default',label:'AI 변경안 검토',state:'default'},{id:'error',label:'검증 실패',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxListPage', group:'template', scenarios:[{id:'default',label:'Search/List',state:'default',density:'compact'},{id:'keyboard',label:'F3',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxMasterPage', group:'template', scenarios:[{id:'default',label:'Master CRUD',state:'default',density:'compact'},{id:'keyboard',label:'F8',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxTransactionPage', group:'template', scenarios:[{id:'default',label:'Desktop Compact',state:'default',density:'compact'},{id:'keyboard',label:'F2/F8',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxMasterDetailPage', group:'template', scenarios:[{id:'default',label:'Master/Detail',state:'default',density:'compact'},{id:'context',label:'선택 Context + 이력',state:'default',density:'compact'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxQueuePage', group:'template', scenarios:[{id:'default',label:'Work Queue',state:'default',density:'compact'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxReconcilePage', group:'template', scenarios:[{id:'default',label:'Reconcile',state:'default',density:'compact'},{id:'error',label:'불일치',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxWmsMobilePage', group:'wms', scenarios:[{id:'default',label:'Touch',state:'default',density:'touch'},{id:'error',label:'현장 오류',state:'error'},{id:'keyboard',label:'Scanner',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxApplicationShell', group:'shell', scenarios:[{id:'default',label:'Desktop',state:'default',density:'compact'},{id:'module',label:'Header↔SideNav 모듈 동기화',state:'default'},{id:'keyboard',label:'메뉴검색',state:'keyboard'},{id:'overflow',label:'우선 항목 Overflow·전체 건수',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxCheckbox', group:'input', scenarios:[{id:'default',label:'기본',state:'default'},{id:'disabled',label:'사용불가',state:'disabled'},{id:'readonly',label:'읽기전용',state:'readonly'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxRadio', group:'input', scenarios:[{id:'default',label:'기본',state:'default'},{id:'disabled',label:'사용불가',state:'disabled'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxTextarea', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxTabs', group:'template', scenarios:[{id:'default',label:'상세 Tabs',state:'default'},{id:'keyboard',label:'Keyboard',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxBadge', group:'feedback', scenarios:[{id:'default',label:'상태 보조',state:'default'},{id:'error',label:'오류',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxTooltip', group:'feedback', scenarios:[{id:'default',label:'Hover',state:'default'},{id:'keyboard',label:'Focus',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxBarcodeField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxSectionHeader', group:'template', scenarios:[{id:'default',label:'Detail Section',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxQuickFilterBar', group:'command', scenarios:[{id:'default',label:'KPI Quick Filter',state:'default'},{id:'error',label:'예외 Filter',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxDataState', group:'feedback', scenarios:[{id:'idle',label:'조회 전',state:'idle'},{id:'loading',label:'조회 중',state:'loading'},{id:'empty',label:'결과 없음',state:'empty'},{id:'error',label:'복구 가능 오류',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxTemplateStateBoundary', group:'feedback', scenarios:[{id:'idle',label:'조회 전',state:'idle'},{id:'ready',label:'정상',state:'ready'},{id:'refreshing',label:'재조회 중 Context 유지',state:'refreshing'},{id:'error-retry',label:'오류 복구',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxSummaryBar', group:'feedback', scenarios:[{id:'default',label:'조회/선택/합계',state:'default'}], accessibility:{keyboard:false,focusVisible:false} },
{ component:'KbxHomePage', group:'shell', scenarios:[{id:'default',label:'업무 Workbench Home',state:'default',density:'compact'},{id:'attention',label:'실패 작업·중요 알림 우선',state:'error'},{id:'dirty-first',label:'미저장 업무 우선',state:'warning'},{id:'keyboard',label:'메뉴검색',state:'keyboard'},{id:'overflow',label:'우선 항목 Overflow·전체 건수',state:'default'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxAccessDenied', group:'shell', scenarios:[{id:'error',label:'권한 없음',state:'error'},{id:'secure-default',label:'화면 정보 비노출',state:'default'},{id:'recovery',label:'홈/메뉴검색 복구',state:'default'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxRouteNotFound', group:'shell', scenarios:[{id:'recovery',label:'잘못된 딥링크 복구',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxTemplateContextBar', group:'template', scenarios:[{id:'default',label:'조회/선택/기준시각 문맥',state:'default'},{id:'error',label:'오류 문맥',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxProgressSteps', group:'template', scenarios:[{id:'default',label:'단계 진행',state:'default'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxLookupDialog', group:'overlay', scenarios:[{id:'default',label:'검색/선택',state:'default'},{id:'loading',label:'조회 중',state:'loading'},{id:'empty',label:'결과 없음',state:'empty'},{id:'error',label:'조회 실패/재시도',state:'error'},{id:'keyboard',label:'↑↓/Enter/Esc',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true,colorIndependentStatus:true} },
{ component:'KbxValidationSummary', group:'feedback', scenarios:[{id:'error',label:'입력 오류 요약',state:'error'},{id:'keyboard',label:'오류 위치 이동',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxExceptionSummary', group:'feedback', scenarios:[{id:'default',label:'예외 카운터',state:'default'},{id:'error',label:'중요 예외',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxExceptionDetailDrawer', group:'overlay', scenarios:[{id:'default',label:'원인/대상/후속작업',state:'default'},{id:'error',label:'Critical 예외',state:'error'},{id:'disabled',label:'권한 없는 Action',state:'disabled'},{id:'keyboard',label:'Drawer Focus/Esc',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxRecordLifecycle', group:'feedback', scenarios:[{id:'default',label:'Workflow/Version/Audit',state:'default'},{id:'changed',label:'Dirty',state:'changed'},{id:'error',label:'Conflict',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxBarcodeCapture', group:'wms', scenarios:[{id:'default',label:'Keyboard Wedge',state:'default',density:'touch'},{id:'disabled',label:'Scan Paused',state:'disabled',density:'touch'},{id:'keyboard',label:'Enter 종결/중복 Debounce',state:'keyboard',density:'touch'},{id:'error',label:'Oversized/Invalid 입력 무시',state:'error',density:'touch'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxNetworkIndicator', group:'wms', scenarios:[{id:'default',label:'온라인',state:'default',density:'touch'},{id:'loading',label:'동기화/전송 대기',state:'loading',density:'touch'},{id:'error',label:'오프라인',state:'error',density:'touch'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxWmsActionButton', group:'wms', scenarios:[{id:'default',label:'Touch CTA',state:'default',density:'touch'},{id:'disabled',label:'사용불가',state:'disabled',density:'touch'},{id:'loading',label:'처리중',state:'loading',density:'touch'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxAiAssistant', group:'overlay', scenarios:[{id:'default',label:'현재 업무 질문',state:'default'},{id:'loading',label:'답변 준비',state:'loading'},{id:'error',label:'조회 실패/재질문',state:'error'},{id:'disabled',label:'Capability/Permission Guard',state:'disabled'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxUtilityRail', group:'overlay', scenarios:[{id:'default',label:'Help/AI/제안',state:'default'},{id:'error',label:'AI 오류 복구',state:'error'},{id:'keyboard',label:'Panel Focus/Esc',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
]
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { KbxAiAnswer, KbxAiAnswerAction, KbxAiScreenContext } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
const props = defineProps<{
context: KbxAiScreenContext
answer?: KbxAiAnswer | null
loading?: boolean
error?: string
currentScreenLabel?: string
quickQuestions?: string[]
can?: (permission:string)=>boolean
}>()
const emit = defineEmits<{
ask: [question: string]
action: [actionId: string]
openProposal: []
}>()
const permissionHost=inject(KbxPermissionHostKey,null)
const question = ref('')
const lastSubmitted = ref('')
const canAsk = computed(() => question.value.trim().length > 1 && !props.loading)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??false)}
function actionAllowed(action:KbxAiAnswerAction){
const capabilityAllowed=!action.requiredCapability||props.context.allowedCapabilities.includes(action.requiredCapability)
const permissionAllowed=!action.requiredPermission||canPermission(action.requiredPermission)
return capabilityAllowed&&permissionAllowed
}
const actions=computed(()=>props.answer?.actions?.filter(actionAllowed)??[])
const proposalAllowed=computed(()=>{
const proposal=props.answer?.proposal
if(!proposal)return false
if(!props.context.allowedCapabilities.includes(proposal.capability))return false
if(proposal.requiredPermission&&!canPermission(proposal.requiredPermission))return false
return true
})
const proposalBlocked=computed(()=>Boolean(props.answer?.proposal)&&!proposalAllowed.value)
function ask(value = question.value) {
const normalized = value.trim()
if (normalized.length < 2 || props.loading) return
lastSubmitted.value=normalized
emit('ask', normalized)
question.value = ''
}
function retry(){if(lastSubmitted.value&&!props.loading)emit('ask',lastSubmitted.value)}
</script>
<template>
<section class="kbx-ai" aria-labelledby="kbx-ai-title" data-kbx-component="ai-assistant" :aria-busy="loading || undefined">
<header>
<h2 id="kbx-ai-title">AI 도우미</h2>
<small>{{ currentScreenLabel || '현재 업무' }}</small>
</header>
<div v-if="quickQuestions?.length" class="kbx-ai__quick" aria-label="빠른 질문">
<KbxButton v-for="item in quickQuestions" :key="item" :label="item" variant="secondary" :disabled="loading" @click="ask(item)" />
</div>
<div v-if="error" class="kbx-ai__error" role="alert">
<strong>AI 답변을 가져오지 못했습니다.</strong><span>{{error}}</span><KbxButton v-if="lastSubmitted" label="다시 질문" variant="secondary" :loading="loading" @click="retry" />
</div>
<article v-if="answer" class="kbx-ai__answer" aria-live="polite">
<p>{{ answer.answer }}</p>
<div v-if="answer.evidence?.length" class="kbx-ai__evidence">
<strong>근거</strong>
<ul>
<li v-for="evidence in answer.evidence" :key="`${evidence.sourceType}:${evidence.reference ?? evidence.label}`">
{{ evidence.label }} <small>({{ evidence.sourceType }})</small>
</li>
</ul>
</div>
<div v-if="actions.length" class="kbx-ai__actions">
<KbxButton v-for="action in actions" :key="action.id" :label="action.label" variant="secondary" @click="emit('action', action.id)" />
</div>
<KbxButton v-if="answer.proposal && proposalAllowed" label="변경 제안 확인" variant="secondary" @click="emit('openProposal')" />
<p v-else-if="proposalBlocked" class="kbx-ai__guard" role="status">현재 사용자 권한 또는 AI 허용 범위를 벗어난 변경 제안은 실행할 없습니다.</p>
</article>
<form class="kbx-ai__ask" @submit.prevent="ask()">
<label for="kbx-ai-question">질문</label>
<textarea id="kbx-ai-question" v-model="question" rows="3" maxlength="1000" placeholder="현재 업무에 대해 질문하세요." />
<KbxButton label="질문" variant="primary" :disabled="!canAsk" :loading="loading" @click="ask()" />
</form>
<p class="kbx-ai__guard">AI 답변은 업무 근거를 설명하거나 제안합니다. 실제 변경은 권한·대상 식별·서버 업무규칙 검증을 거쳐 별도 실행합니다.</p>
</section>
</template>
<style scoped>
.kbx-ai{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-4);font-size:var(--kbx-font-md)}header{display:flex;justify-content:space-between;align-items:baseline;gap:var(--kbx-space-2)}h2{margin:0;font-size:var(--kbx-font-xl)}header small{color:var(--kbx-color-text-muted)}.kbx-ai__quick,.kbx-ai__actions{display:flex;gap:var(--kbx-space-2);flex-wrap:wrap}.kbx-ai__answer{display:grid;gap:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);padding:var(--kbx-space-3);background:var(--kbx-color-surface-muted)}.kbx-ai__answer p{margin:0;white-space:pre-wrap;line-height:1.55}.kbx-ai__evidence{display:grid;gap:var(--kbx-space-1)}.kbx-ai__evidence ul{margin:0;padding-left:var(--kbx-space-5);color:var(--kbx-color-text-muted)}.kbx-ai__error{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.kbx-ai__error span{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-ai__ask{display:grid;gap:var(--kbx-space-2)}textarea{resize:vertical;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);font:inherit}.kbx-ai__ask :deep(.p-button){justify-self:end}.kbx-ai__guard{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);line-height:1.45}
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxAiAnswer, KbxAiScreenContext } from '@kbx/contracts'
import KbxAiAssistant from './KbxAiAssistant.vue'
defineProps<{ context:KbxAiScreenContext; answer?:KbxAiAnswer|null; loading?:boolean; error?:string; currentScreenLabel?:string; quickQuestions?:string[] }>()
const emit=defineEmits<{ ask:[string]; action:[string]; openProposal:[] }>()
</script>
<template><KbxAiAssistant :context="context" :answer="answer" :loading="loading" :error="error" :current-screen-label="currentScreenLabel" :quick-questions="quickQuestions" @ask="emit('ask',$event)" @action="emit('action',$event)" @open-proposal="emit('openProposal')"/></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import type { KbxAuditEntry } from '@kbx/contracts'; defineProps<{entries:KbxAuditEntry[]}>()
</script>
<template><section class="kbx-audit" aria-label="변경이력"><div v-if="!entries.length" class="kbx-audit__empty">변경이력이 없습니다.</div><article v-for="entry in entries" :key="entry.id" class="kbx-audit__entry"><header><strong>{{entry.actor.displayName}}</strong><time>{{entry.occurredAt}}</time><span>{{entry.action}}</span></header><dl v-if="entry.changes?.length"><template v-for="c in entry.changes" :key="c.field"><dt>{{c.label}}</dt><dd><span>{{c.before ?? '-'}}</span><b aria-hidden="true">→</b><span>{{c.after ?? '-'}}</span></dd></template></dl><p v-if="entry.reason">사유: {{entry.reason}}</p></article></section></template>
<style scoped>.kbx-audit{display:grid;gap:var(--kbx-space-3)}.kbx-audit__entry{padding-bottom:var(--kbx-space-3);border-bottom:1px solid var(--kbx-color-border)}header{display:flex;gap:var(--kbx-space-2);align-items:center;font-size:var(--kbx-font-sm)}time{color:var(--kbx-color-text-muted)}dl{display:grid;grid-template-columns:120px 1fr;gap:4px 8px;margin:8px 0}dt{font-weight:500}dd{margin:0;display:flex;gap:8px}.kbx-audit p,.kbx-audit__empty{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
withDefaults(defineProps<{label:string;tone?:'neutral'|'info'|'success'|'warning'|'danger'}>(),{tone:'neutral'})
</script>
<template><span class="kbx-badge" :data-tone="tone">{{label}}</span></template>
<style scoped>.kbx-badge{display:inline-flex;align-items:center;min-height:calc(var(--kbx-space-5) + var(--kbx-space-1));padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-pill);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-xs);white-space:nowrap}.kbx-badge[data-tone="info"]{color:var(--kbx-color-primary);border-color:var(--kbx-color-info-border);background:var(--kbx-color-info-surface)}.kbx-badge[data-tone="success"]{color:var(--kbx-color-success);border-color:var(--kbx-color-success-border);background:var(--kbx-color-success-surface)}.kbx-badge[data-tone="warning"]{color:var(--kbx-color-warning-text);border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-badge[data-tone="danger"]{color:var(--kbx-color-danger);border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
import KbxInput from './KbxInput.vue'
withDefaults(defineProps<{modelValue?:string|null;label?:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;placeholder?:string}>(),{label:'바코드',modelValue:'',state:'default'})
const emit=defineEmits<{ 'update:modelValue':[string]; enter:[] }>()
</script>
<template><KbxInput :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="error" :warning="warning" :help-text="helpText" :state="state" :placeholder="placeholder" @update:model-value="emit('update:modelValue',$event.trim())" @enter="emit('enter')"/></template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { inject } from 'vue'
import type { KbxCommandDefinition } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import { KbxPermissionHostKey } from '../permission/host'
const props=defineProps<{selectionCount:number;actions:KbxCommandDefinition[];can?:(permission:string)=>boolean}>()
const emit=defineEmits<{ command:[string] }>()
const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
function visible(a:KbxCommandDefinition){return !a.permission||canPermission(a.permission)}
function disabled(a:KbxCommandDefinition){return (a.minSelection!=null&&props.selectionCount<a.minSelection)||(a.maxSelection!=null&&props.selectionCount>a.maxSelection)}
</script>
<template><div v-if="selectionCount>0" class="kbx-bulk" role="toolbar" :aria-label="`${selectionCount}건 선택 업무`"><strong>{{selectionCount.toLocaleString()}}건 선택</strong><KbxButton v-for="a in actions.filter(visible)" :key="a.id" :label="a.label" :variant="a.variant" :disabled="disabled(a)" @click="emit('command',a.id)"/></div></template>
<style scoped>.kbx-bulk{display:flex;align-items:center;gap:var(--kbx-space-2);min-height:44px;padding:6px var(--kbx-space-3);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);border-radius:var(--kbx-radius-sm)}.kbx-bulk strong{margin-right:var(--kbx-space-2);font-size:var(--kbx-font-sm)}</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import Button from 'primevue/button'
const props = withDefaults(defineProps<{
label: string
shortcut?: string
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
disabled?: boolean
loading?: boolean
title?: string
}>(), { variant: 'secondary' })
const emit = defineEmits<{ click: [MouseEvent] }>()
</script>
<template>
<Button
:label="shortcut ? `${label} ${shortcut}` : label"
:disabled="disabled"
:loading="loading"
:title="title"
:severity="variant === 'danger' ? 'danger' : undefined"
:outlined="variant === 'secondary'"
:text="variant === 'ghost'"
@click="emit('click', $event)"
/>
</template>
@@ -0,0 +1,10 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{modelValue?:boolean;label:string;disabled?:boolean;readonly?:boolean;helpText?:string;state?:KbxFieldState}>(),{modelValue:false,disabled:false,readonly:false,helpText:'',state:'default'})
const emit=defineEmits<{ 'update:modelValue':[boolean] }>()
const uid=`kbx-checkbox-${Math.random().toString(36).slice(2)}`
function change(event:Event){if(props.readonly||props.disabled)return;emit('update:modelValue',(event.currentTarget as HTMLInputElement).checked)}
function preventReadonly(event:Event){if(props.readonly)event.preventDefault()}
</script>
<template><label class="kbx-checkbox" :data-state="state" :data-readonly="readonly||undefined"><input :id="uid" type="checkbox" :checked="modelValue" :disabled="disabled" :aria-readonly="readonly||undefined" @click="preventReadonly" @keydown.space="preventReadonly" @change="change"><span>{{label}}</span><small v-if="helpText">{{helpText}}</small></label></template>
<style scoped>.kbx-checkbox{min-height:var(--kbx-control-height);display:grid;grid-template-columns:var(--kbx-checkbox-track) auto minmax(0,1fr);align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-md)}.kbx-checkbox input{width:var(--kbx-checkbox-size);height:var(--kbx-checkbox-size);margin:0}.kbx-checkbox small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-checkbox[data-readonly="true"]{color:var(--kbx-color-text-muted)}.kbx-checkbox[data-state="changed"] span{color:var(--kbx-color-primary)}.kbx-checkbox[data-state="warning"] span{color:var(--kbx-color-warning-text)}.kbx-checkbox[data-state="ai-suggested"] span{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { KbxCommandDefinition, KbxCommandGroup } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxConfirm from './KbxConfirm.vue'
import { KbxPermissionHostKey } from '../permission/host'
const props = withDefaults(defineProps<{ commands:KbxCommandDefinition[]; selectionCount?:number; status?:string; dirty?:boolean; can?:(permission:string)=>boolean }>(), { selectionCount:0, status:'', dirty:false })
const emit=defineEmits<{ command:[string] }>()
const pending=ref<KbxCommandDefinition|null>(null)
const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
function invoke(command:KbxCommandDefinition){if(reason(command))return;if(command.confirm){pending.value=command;return}emit('command',command.id)}
function confirmPending(){const command=pending.value;if(!command)return;pending.value=null;emit('command',command.id)}
const groupOrder:KbxCommandGroup[]=['query','edit','workflow','output','more']
function effectivePermission(c:KbxCommandDefinition){return (props.status&&c.permissionByStatus?.[props.status])||c.permission}
function visible(c:KbxCommandDefinition){const permission=effectivePermission(c);return !permission||canPermission(permission)}
function reason(c:KbxCommandDefinition){
const n=props.selectionCount
if(c.requiresSelection&&n===0)return '처리할 항목을 선택하세요.'
if(c.minSelection!=null&&n<c.minSelection)return `${c.minSelection} 이상 선택하세요.`
if(c.maxSelection!=null&&n>c.maxSelection)return `${c.maxSelection}건 이하로 선택하세요.`
if(c.allowedStatuses?.length&&props.status&&!c.allowedStatuses.includes(props.status))return c.disabledReason??`현재 상태(${props.status})에서는 실행할 수 없습니다.`
if(c.requiresDirty&&!props.dirty)return c.disabledReason??'변경된 내용이 없습니다.'
if(c.requiresClean&&props.dirty)return c.disabledReason??'변경사항을 먼저 저장하세요.'
return ''
}
const groups=computed(()=>groupOrder.map(group=>({group,commands:props.commands.filter(c=>c.group===group&&visible(c))})).filter(x=>x.commands.length))
</script>
<template><div class="kbx-command-bar" role="toolbar" aria-label="화면 명령"><template v-for="(item,index) in groups" :key="item.group"><div class="kbx-command-group" :data-group="item.group"><KbxButton v-for="command in item.commands" :key="command.id" :label="command.label" :shortcut="command.shortcut" :variant="command.variant" :disabled="Boolean(reason(command))" :title="reason(command)||undefined" @click="invoke(command)"/></div><span v-if="index<groups.length-1" class="kbx-command-separator" aria-hidden="true"/></template></div><KbxConfirm v-if="pending?.confirm" :open="Boolean(pending)" :title="pending.confirm.title" :detail="pending.confirm.detail" :level="pending.confirm.level" :confirm-label="pending.confirm.confirmLabel??pending.label" @update:open="value=>{if(!value)pending=null}" @confirm="confirmPending"/></template>
<style scoped>.kbx-command-bar{display:flex;align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-command-bar-height);overflow-x:auto}.kbx-command-group{display:flex;align-items:center;gap:var(--kbx-space-2);flex-shrink:0}.kbx-command-separator{width:var(--kbx-border-width);height:var(--kbx-command-separator-height);background:var(--kbx-color-border);flex-shrink:0}</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import KbxDialog from './KbxDialog.vue'
import KbxButton from './KbxButton.vue'
withDefaults(defineProps<{open:boolean;title:string;detail:string;level?:'low'|'medium'|'high';confirmLabel:string}>(),{level:'medium'})
const emit=defineEmits<{ 'update:open':[boolean]; confirm:[] }>()
</script>
<template><KbxDialog :open="open" :title="title" size="sm" @update:open="emit('update:open',$event)"><p class="kbx-confirm__detail">{{detail}}</p><template #footer><KbxButton label="취소" @click="emit('update:open',false)"/><KbxButton :label="confirmLabel" :variant="level==='high'?'danger':'primary'" @click="emit('confirm')"/></template></KbxDialog></template>
<style scoped>.kbx-confirm__detail{white-space:pre-line;line-height:1.5;color:var(--kbx-color-text)}</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { KbxConflictSnapshot } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
defineProps<{ conflict: KbxConflictSnapshot }>()
const emit = defineEmits<{ reload: []; cancel: [] }>()
</script>
<template>
<section class="kbx-conflict" role="alert">
<header>
<strong>{{ conflict.title }}</strong>
<span v-if="conflict.detail">{{ conflict.detail }}</span>
</header>
<div v-if="conflict.changes?.length" class="changes">
<div class="head"><span>항목</span><span> 화면</span><span>최신 </span></div>
<div v-for="change in conflict.changes" :key="change.field" class="row">
<strong>{{ change.label }}</strong>
<span>{{ change.mine ?? '-' }}</span>
<span>{{ change.latest ?? '-' }}</span>
</div>
</div>
<p v-if="conflict.correlationId" class="reference">참조번호 {{ conflict.correlationId }}</p>
<footer>
<KbxButton label="계속 편집" variant="secondary" @click="emit('cancel')" />
<KbxButton label="최신 내용 보기" variant="primary" @click="emit('reload')" />
</footer>
</section>
</template>
<style scoped>
.kbx-conflict { display:grid; gap:12px; padding:16px; border:1px solid var(--kbx-color-warning); border-radius:var(--kbx-radius-md); background:var(--kbx-color-surface); }
header { display:grid; gap:4px; } header span,.reference { color:var(--kbx-color-text-muted); }
.changes { border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); overflow:hidden; }
.head,.row { display:grid; grid-template-columns:minmax(120px,1fr) minmax(120px,1fr) minmax(120px,1fr); gap:8px; padding:8px 10px; }
.head { background:var(--kbx-color-surface-muted); font-size:var(--kbx-font-sm); color:var(--kbx-color-text-muted); }
.row + .row { border-top:1px solid var(--kbx-color-border); }
footer { display:flex; justify-content:flex-end; gap:8px; }
.reference { margin:0; font-size:var(--kbx-font-sm); }
</style>
@@ -0,0 +1,270 @@
<script setup lang="ts" generic="T extends object">
import { computed, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import { kbxGridTheme } from '../theme/kbxGridTheme'
import type {
KbxGridCellRef, KbxGridColumn, KbxGridColumnPreference, KbxGridContextRequest,
KbxGridDensity, KbxGridEditingPolicy, KbxGridPasteResult, KbxGridSummary,
KbxSelectionState, KbxValidationError,
} from '@kbx/contracts'
import { normalizeKbxGridClipboardData } from '../grid/editing'
import KbxButton from './KbxButton.vue'
import KbxDataState from './KbxDataState.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
const props = withDefaults(defineProps<{
rows: T[]
columns: KbxGridColumn<T>[]
rowKey: keyof T & string
loading?: boolean
errorText?: string
emptyText?: string
selection?: 'none' | 'single' | 'multiple'
selectionState?: KbxSelectionState<string>
totalCount?: number
allowAllFilteredSelection?: boolean
editable?: boolean
editingPolicy?: KbxGridEditingPolicy
clipboard?: boolean
personalization?: boolean
savedPreference?: KbxGridColumnPreference[]
exportable?: boolean
exportFileName?: string
density?: KbxGridDensity
errors?: KbxValidationError[]
changedCells?: KbxGridCellRef<string>[]
summary?: KbxGridSummary<T>[]
activeRowKey?: string | number | null
}>(), {
loading:false,
errorText:'',
emptyText:'조회된 데이터가 없습니다.',
selection:'none',
totalCount:0,
allowAllFilteredSelection:false,
editable:false,
clipboard:true,
personalization:false,
exportable:false,
exportFileName:'kbx-grid.csv',
density:'compact',
errors:()=>[],
changedCells:()=>[],
summary:()=>[],
activeRowKey:null,
})
const emit = defineEmits<{
selectionChanged:[T[]]
selectionStateChanged:[KbxSelectionState<string>]
rowDoubleClicked:[T]
drillDownRequested:[T]
cellChanged:[{row:T;field:keyof T & string;oldValue:unknown;newValue:unknown}]
lookupRequested:[{row:T;field:keyof T & string;entity:string}]
rowAddRequested:[]
rowDuplicateRequested:[T[]]
fillDownApplied:[{field:keyof T & string;count:number}]
pasteProcessed:[KbxGridPasteResult]
errorFocusChanged:[KbxGridCellRef<string>]
contextRequested:[KbxGridContextRequest<T>]
retry:[]
preferenceChanged:[KbxGridColumnPreference[]]
preferenceReset:[]
}>()
const gridApi=ref<any>(null)
const selectedRows=ref<T[]>([])
const allFilteredActive=ref(props.selectionState?.mode==='all-filtered')
const excludedIds=ref<string[]>(props.selectionState?.excludedIds??[])
const errorCursor=ref(-1)
watch(()=>props.selectionState, value=>{
allFilteredActive.value=value?.mode==='all-filtered'
excludedIds.value=value?.excludedIds??[]
},{deep:true})
const firstLoading=computed(()=>props.loading&&props.rows.length===0)
const empty=computed(()=>!props.loading&&!props.errorText&&props.rows.length===0)
const blockingError=computed(()=>Boolean(props.errorText)&&props.rows.length===0)
const showToolbar=computed(()=>props.exportable||props.personalization||props.errors.length>0||selectedCount.value>0||Boolean(props.editingPolicy)||canSelectAllFiltered.value)
const selectedCount=computed(()=>allFilteredActive.value?Math.max((props.totalCount||props.rows.length)-excludedIds.value.length,0):selectedRows.value.length)
const canSelectAllFiltered=computed(()=>props.allowAllFilteredSelection&&props.selection==='multiple'&&(props.totalCount||0)>props.rows.length)
const canDuplicate=computed(()=>Boolean(props.editable&&props.editingPolicy?.allowRowDuplicate&&selectedRows.value.length>0&&!allFilteredActive.value))
const canFillDown=computed(()=>Boolean(props.editable&&props.editingPolicy?.fillDown&&selectedRows.value.length>1&&!allFilteredActive.value))
function formatNumber(value:unknown){if(value==null||value==='')return'';return new Intl.NumberFormat('ko-KR').format(Number(value))}
function errorFor(row:T,field:string){const id=String(row[props.rowKey]);return props.errors.find(e=>e.rowKey===id&&e.field===field)}
function changedFor(row:T,field:string){const id=String(row[props.rowKey]);return props.changedCells.some(x=>String(x.rowKey)===id&&x.field===field)}
function columnFor(field:string){return props.columns.find(c=>c.field===field)}
function isEditable(column:KbxGridColumn<T>|undefined,row:T){if(!column||!props.editable)return false;return typeof column.editable==='function'?column.editable(row):(column.editable??false)}
const columnDefs=computed<ColDef<T>[]>(()=>props.columns.map(c=>({
field:c.field,headerName:c.header,width:c.width,minWidth:c.minWidth,maxWidth:c.maxWidth,pinned:c.pinned,
editable:params=>Boolean(params.data&&isEditable(c,params.data)),
sortable:c.sortable??true,filter:c.filterable??true,resizable:true,
valueFormatter:c.type==='money'||c.type==='quantity'?p=>formatNumber(p.value):undefined,
valueParser:c.type==='money'||c.type==='quantity'||c.type==='integer'||c.type==='decimal'||c.type==='percent'?p=>Number(String(p.newValue??'').replace(/,/g,'')):undefined,
cellClass:params=>{
const classes:string[]=[]
if(c.type==='money'||c.type==='quantity'||c.type==='integer'||c.type==='decimal'||c.type==='percent')classes.push('kbx-cell--numeric')
if(c.type==='date'||c.type==='datetime'||c.type==='status'||c.type==='boolean')classes.push('kbx-cell--center')
if(c.type==='link'||c.drilldown)classes.push('kbx-cell--link')
if(params.data&&changedFor(params.data,c.field))classes.push('kbx-cell--changed')
if(params.data&&errorFor(params.data,c.field))classes.push('kbx-cell--invalid')
return classes
},
tooltipValueGetter:params=>params.data?errorFor(params.data,c.field)?.message:undefined,
suppressKeyboardEvent:params=>Boolean(c.lookup&&params.event.key==='F2'),
})))
const summaryItems=computed(()=>props.summary.map(item=>{
let value:string|number=item.value??''
if(item.kind==='count')value=props.totalCount||props.rows.length
else if(item.kind==='sum'&&item.field)value=props.rows.reduce((sum,row)=>sum+(Number(row[item.field!])||0),0)
return {key:item.key,label:item.label,value}
}))
function currentPreference():KbxGridColumnPreference[]{
const state=(gridApi.value?.getColumnState?.()??[]) as any[]
return state.map((x,index)=>({field:String(x.colId),order:index,width:x.width,pinned:x.pinned??null,hidden:Boolean(x.hide),sort:x.sort??null,sortIndex:x.sortIndex??null}))
}
function savePreference(){if(props.personalization&&gridApi.value)emit('preferenceChanged',currentPreference())}
function restorePreference(){
if(!props.personalization||!props.savedPreference?.length||!gridApi.value)return
const ordered=[...props.savedPreference].sort((a,b)=>a.order-b.order)
gridApi.value.applyColumnState({state:ordered.map(x=>({colId:x.field,width:x.width,pinned:x.pinned,hide:x.hidden,sort:x.sort,sortIndex:x.sortIndex})),applyOrder:true})
}
function resetColumns(){gridApi.value?.resetColumnState();emit('preferenceReset')}
function exportCsv(){gridApi.value?.exportDataAsCsv({fileName:props.exportFileName})}
function syncActiveRow(){
if(!gridApi.value||props.activeRowKey==null)return
const node=gridApi.value.getRowNode?.(String(props.activeRowKey));if(!node)return
if(props.selection==='single'){gridApi.value.deselectAll?.();node.setSelected?.(true)}
if(node.rowIndex!=null)gridApi.value.ensureNodeVisible?.(node,'middle')
}
function onGridReady(event:any){gridApi.value=event.api;restorePreference();syncActiveRow()}
watch([()=>props.activeRowKey,()=>props.rows],()=>syncActiveRow(),{deep:true,flush:'post'})
function onSelectionChanged(event:any){
const rows=event.api.getSelectedRows() as T[]
selectedRows.value=rows
emit('selectionChanged',rows)
const ids=rows.map(row=>String(row[props.rowKey]))
if(allFilteredActive.value){
const pageIds=props.rows.map(row=>String(row[props.rowKey]))
const selected=new Set(ids)
const excluded=new Set(excludedIds.value)
for(const id of pageIds) selected.has(id)?excluded.delete(id):excluded.add(id)
excludedIds.value=[...excluded]
emit('selectionStateChanged',{mode:'all-filtered',selectedIds:[],excludedIds:excludedIds.value})
}else emit('selectionStateChanged',{mode:'explicit',selectedIds:ids})
}
function toggleAllFiltered(){
if(allFilteredActive.value){
allFilteredActive.value=false;excludedIds.value=[];gridApi.value?.deselectAll();emit('selectionStateChanged',{mode:'explicit',selectedIds:[]});return
}
allFilteredActive.value=true;excludedIds.value=[];gridApi.value?.selectAll();emit('selectionStateChanged',{mode:'all-filtered',selectedIds:[],excludedIds:[]})
}
function onCellKeyDown(event:any){
if(event.event?.key!=='F2'||!event.data||!event.colDef?.field)return
const column=columnFor(event.colDef.field);if(!column?.lookup)return
event.event.preventDefault();emit('lookupRequested',{row:event.data,field:column.field,entity:column.lookup.entity})
}
function onCellValueChanged(event:any){emit('cellChanged',{row:event.data,field:event.colDef.field,oldValue:event.oldValue,newValue:event.newValue})}
function onCellClicked(event:any){const column=columnFor(event.colDef?.field);if(event.data&&(column?.type==='link'||column?.drilldown))emit('drillDownRequested',event.data)}
function onCellContextMenu(event:any){
if(!event.data||!event.colDef?.field)return
event.event?.preventDefault?.()
emit('contextRequested',{row:event.data,field:event.colDef.field,clientX:event.event?.clientX??0,clientY:event.event?.clientY??0})
}
function duplicateRows(){if(canDuplicate.value)emit('rowDuplicateRequested',[...selectedRows.value])}
function fillDown(){
if(!canFillDown.value||!gridApi.value)return
gridApi.value.stopEditing?.()
const focused=gridApi.value.getFocusedCell?.();if(!focused)return
const field=String(focused.column?.getColId?.()??'');const column=columnFor(field);if(!column)return
const sourceNode=gridApi.value.getDisplayedRowAtIndex?.(focused.rowIndex);const source=sourceNode?.data as T|undefined
if(!source||!isEditable(column,source))return
const value=(source as any)[field]
let count=0
for(const node of gridApi.value.getSelectedNodes?.()??[]){
if(!node?.data||node===sourceNode||!isEditable(column,node.data))continue
node.setDataValue?.(field,value);count++
}
if(count)emit('fillDownApplied',{field:column.field,count})
}
function focusError(direction:1|-1=1){
if(!props.errors.length||!gridApi.value)return
errorCursor.value=(errorCursor.value+direction+props.errors.length)%props.errors.length
const error=props.errors[errorCursor.value];if(!error?.rowKey||!error.field)return
const node=gridApi.value.getRowNode?.(String(error.rowKey));if(node?.rowIndex==null)return
gridApi.value.ensureNodeVisible?.(node,'middle');gridApi.value.setFocusedCell?.(node.rowIndex,error.field)
emit('errorFocusChanged',{rowKey:String(error.rowKey),field:error.field})
}
function processClipboardData(params:any){
if(!props.clipboard||props.editingPolicy?.paste===false)return null
const displayed=(params.api?.getAllDisplayedColumns?.()??[]).map((c:any)=>columnFor(String(c.getColId?.()))).filter(Boolean) as KbxGridColumn<T>[]
const focused=params.api?.getFocusedCell?.();const start=displayed.findIndex(c=>c.field===String(focused?.column?.getColId?.()??''))
const normalized=normalizeKbxGridClipboardData(params.data??[],displayed,Math.max(start,0));emit('pasteProcessed',normalized.result);return normalized.data
}
</script>
<template>
<section class="kbx-grid" :data-density="density" :aria-busy="loading">
<KbxDataState v-if="firstLoading" state="loading" />
<KbxDataState v-else-if="blockingError" state="error" :detail="errorText" action-label="다시 조회" @action="emit('retry')" />
<KbxDataState v-else-if="empty" state="empty" :title="emptyText" action-label="다시 조회" @action="emit('retry')" />
<template v-else>
<div v-if="showToolbar" class="kbx-grid__toolbar" role="toolbar" aria-label="그리드 도구">
<strong v-if="selectedCount" class="kbx-grid__selection">{{selectedCount.toLocaleString('ko-KR')}}건 선택<span v-if="allFilteredActive"> · 검색결과 전체</span></strong>
<span v-if="errors.length" class="kbx-grid__error-count">{{errors.length.toLocaleString('ko-KR')}} 오류</span>
<span v-if="loading" class="kbx-grid__busy" role="status">재조회 중...</span>
<KbxButton v-if="editable&&editingPolicy?.allowRowAdd" label="행 추가" variant="ghost" @click="emit('rowAddRequested')" />
<KbxButton v-if="editable&&editingPolicy?.allowRowDuplicate" label="행 복제" variant="ghost" :disabled="!canDuplicate" @click="duplicateRows" />
<KbxButton v-if="editable&&editingPolicy?.fillDown" label="아래 채우기" variant="ghost" :disabled="!canFillDown" @click="fillDown" />
<KbxButton v-if="errors.length&&editingPolicy?.errorNavigation" label="첫/다음 오류" variant="ghost" @click="focusError(1)" />
<KbxButton v-if="canSelectAllFiltered" :label="allFilteredActive?'전체선택 해제':`검색결과 ${(totalCount||0).toLocaleString('ko-KR')}건 전체선택`" variant="ghost" @click="toggleAllFiltered" />
<span class="kbx-grid__spacer" />
<KbxButton v-if="personalization" label="열 배치 초기화" variant="ghost" @click="resetColumns" />
<KbxButton v-if="exportable" label="CSV 내보내기" variant="ghost" @click="exportCsv" />
</div>
<KbxDataState v-if="errorText" state="error" compact :detail="errorText" action-label="다시 조회" @action="emit('retry')" />
<div class="kbx-grid__body">
<AgGridVue
class="kbx-grid__ag"
:theme="kbxGridTheme"
:row-data="rows"
:column-defs="columnDefs"
:get-row-id="(p:any)=>String(p.data[rowKey])"
:row-selection="selection==='multiple'?'multiple':selection==='single'?'single':undefined"
:loading="loading"
:enter-navigates-vertically="editable"
:enter-navigates-vertically-after-edit="editable"
:enable-cell-text-selection="clipboard"
:suppress-clipboard-paste="!clipboard"
:process-data-from-clipboard="processClipboardData"
@grid-ready="onGridReady"
@column-moved="savePreference"
@column-resized="savePreference"
@column-pinned="savePreference"
@sort-changed="savePreference"
@selection-changed="onSelectionChanged"
@row-double-clicked="(e:any)=>emit('rowDoubleClicked',e.data)"
@cell-clicked="onCellClicked"
@cell-context-menu="onCellContextMenu"
@cell-value-changed="onCellValueChanged"
@cell-key-down="onCellKeyDown"
/>
</div>
<KbxSummaryBar v-if="summaryItems.length" :items="summaryItems" />
</template>
</section>
</template>
<style scoped>
.kbx-grid{min-height:var(--kbx-grid-min-height);height:100%;display:flex;flex-direction:column;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-grid__toolbar{min-height:var(--kbx-grid-toolbar-height);display:flex;align-items:center;gap:var(--kbx-space-2);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);flex-wrap:wrap}.kbx-grid__selection{color:var(--kbx-color-text);font-weight:600}.kbx-grid__spacer{flex:1}.kbx-grid__error-count{color:var(--kbx-color-danger);font-weight:600}.kbx-grid__busy{color:var(--kbx-color-primary)}.kbx-grid__body{min-height:0;flex:1}.kbx-grid__ag{width:100%;height:100%;min-height:var(--kbx-grid-min-height)}.kbx-grid[data-density="compact"] :deep(.ag-row){font-size:var(--kbx-font-sm)}.kbx-grid[data-density="comfortable"] :deep(.ag-row){font-size:var(--kbx-font-md)}:deep(.kbx-cell--numeric){text-align:right}:deep(.kbx-cell--center){text-align:center}:deep(.kbx-cell--link){text-decoration:underline;text-underline-offset:var(--kbx-space-1);cursor:pointer}:deep(.kbx-cell--changed){box-shadow:inset 0 0 0 var(--kbx-border-width) var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}:deep(.kbx-cell--invalid){box-shadow:inset 0 0 0 var(--kbx-border-width) var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}
</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxExternalDataProvenance, KbxDataFreshness } from '@kbx/contracts'
import KbxFreshnessIndicator from './KbxFreshnessIndicator.vue'
const props=defineProps<{ provenance:KbxExternalDataProvenance; compact?:boolean }>()
const emit=defineEmits<{ refresh:[] }>()
const freshness=computed<KbxDataFreshness>(()=>({ observedAt:props.provenance.providerObservedAt ?? props.provenance.receivedAt, staleAfterSeconds:props.provenance.freshUntil?Math.max(0,Math.floor((new Date(props.provenance.freshUntil).getTime()-new Date(props.provenance.receivedAt).getTime())/1000)):undefined, source:props.provenance.sourceLabel }))
const stateLabel=computed(()=>({fresh:'최신',stale:'최신 데이터 확인 중',expired:'만료',unavailable:'사용 불가'}[props.provenance.state]))
</script>
<template>
<section class="kbx-provenance" :class="{compact}" aria-label="데이터 출처와 신선도">
<div class="summary">
<strong>{{ provenance.sourceLabel }}</strong>
<span>{{ stateLabel }}</span>
<KbxFreshnessIndicator :freshness="freshness" @refresh="emit('refresh')" />
</div>
<dl v-if="!compact">
<div><dt>데이터셋</dt><dd>{{ provenance.datasetId }}</dd></div>
<div v-if="provenance.providerObservedAt"><dt>공급자 기준</dt><dd>{{ provenance.providerObservedAt }}</dd></div>
<div><dt>수신</dt><dd>{{ provenance.receivedAt }}</dd></div>
<div><dt>정규화</dt><dd>{{ provenance.ingestedAt }}</dd></div>
<div><dt>정규화 버전</dt><dd>{{ provenance.normalizerVersion }}</dd></div>
<div><dt>증거 Hash</dt><dd class="hash">{{ provenance.payloadSha256 }}</dd></div>
</dl>
<p v-if="provenance.warning" class="warning">{{ provenance.warning }}</p>
</section>
</template>
<style scoped>
.kbx-provenance{display:grid;gap:var(--kbx-space-2);border:thin solid var(--kbx-color-border);padding:var(--kbx-space-2);background:var(--kbx-color-surface)}
.summary{display:flex;gap:var(--kbx-space-2);align-items:center;flex-wrap:wrap}.summary>span{color:var(--kbx-color-text-muted)}
dl{display:grid;gap:var(--kbx-space-1);margin:0}dl>div{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:var(--kbx-space-2)}dt{color:var(--kbx-color-text-muted)}dd{margin:0}.hash{overflow-wrap:anywhere;font-family:monospace}.warning{margin:0;color:var(--kbx-color-warning)}
.compact{border:0;padding:0;background:transparent}.compact dl{display:none}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { KbxAsyncState } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
const props = withDefaults(defineProps<{
state: Exclude<KbxAsyncState, 'ready'>
title?: string
detail?: string
actionLabel?: string
compact?: boolean
}>(), { title:'', detail:'', actionLabel:'', compact:false })
const emit = defineEmits<{ action:[] }>()
const defaults = {
idle: { title:'조회 전입니다.', detail:'조회조건을 확인한 후 조회하세요.' },
loading: { title:'조회 중...', detail:'잠시 후 결과를 표시합니다.' },
empty: { title:'조회된 데이터가 없습니다.', detail:'조회조건을 변경하거나 다시 조회하세요.' },
error: { title:'데이터를 불러오지 못했습니다.', detail:'네트워크 연결과 조회조건을 확인한 후 다시 시도하세요.' },
} as const
</script>
<template>
<section class="kbx-data-state" :class="{compact}" :data-state="state" :aria-busy="state==='loading'" :role="state==='error'?'alert':'status'">
<div class="kbx-data-state__mark" aria-hidden="true">{{ state==='loading' ? '…' : state==='empty' ? '—' : state==='idle' ? '○' : '!' }}</div>
<div class="kbx-data-state__body">
<strong>{{ title || defaults[state].title }}</strong>
<span v-if="detail || defaults[state].detail">{{ detail || defaults[state].detail }}</span>
</div>
<KbxButton v-if="actionLabel" :label="actionLabel" variant="secondary" @click="emit('action')" />
</section>
</template>
<style scoped>
.kbx-data-state{min-height:var(--kbx-data-state-min-height);display:flex;align-items:center;justify-content:center;gap:var(--kbx-space-3);padding:var(--kbx-space-5);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);text-align:left}.kbx-data-state.compact{min-height:var(--kbx-control-lg);justify-content:flex-start;padding:var(--kbx-space-2) var(--kbx-space-3)}.kbx-data-state__mark{width:var(--kbx-control-sm);height:var(--kbx-control-sm);display:grid;place-items:center;border-radius:var(--kbx-radius-pill);background:var(--kbx-color-surface-muted);font-weight:700;color:var(--kbx-color-text-muted)}.kbx-data-state[data-state="error"] .kbx-data-state__mark{background:var(--kbx-color-danger-surface);color:var(--kbx-color-danger)}.kbx-data-state__body{display:flex;flex-direction:column;gap:var(--kbx-space-1);min-width:0}.kbx-data-state__body strong{font-size:var(--kbx-font-md)}.kbx-data-state__body span{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}.kbx-data-state.compact .kbx-data-state__body span{display:none}
</style>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{modelValue?:string|null;label:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState}>(),{state:'default'})
const emit=defineEmits<{ 'update:modelValue':[string] }>()
const uid=`kbx-date-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function normalize(value:string){const digits=value.replace(/\D/g,'');return digits.length===8?`${digits.slice(0,4)}-${digits.slice(4,6)}-${digits.slice(6,8)}`:value}
</script>
<template><div class="kbx-field" :data-state="effectiveState"><label :for="uid" class="kbx-field__label">{{label}}<span v-if="required" aria-hidden="true"> *</span></label><input :id="uid" class="kbx-input" inputmode="numeric" :value="modelValue??''" :readonly="readonly" :disabled="disabled" :required="required" placeholder="YYYY-MM-DD" :aria-invalid="Boolean(error)" :aria-describedby="message?messageId:undefined" @change="emit('update:modelValue',normalize(($event.target as HTMLInputElement).value))"><span v-if="message" :id="messageId" class="kbx-field__message" :role="error?'alert':undefined">{{message}}</span></div></template>
<style scoped>.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;min-height:var(--kbx-control-height);font-size:var(--kbx-font-md)}.kbx-field__label{padding-top:var(--kbx-space-2);font-weight:500;white-space:nowrap}.kbx-input{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] .kbx-input{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] .kbx-input{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] .kbx-input{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] .kbx-input{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-input[readonly]{background:var(--kbx-color-surface-muted)}.kbx-input:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);margin-top:calc(var(--kbx-space-1) * -1)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{from:string|null;to:string|null;label:string;disabled?:boolean;readonly?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;presets?:boolean}>(),{state:'default',presets:true})
const emit=defineEmits<{ 'update:from':[string|null]; 'update:to':[string|null] }>()
const uid=`kbx-range-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`;const preset=ref('custom')
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function fmt(d:Date){const y=d.getFullYear();const m=String(d.getMonth()+1).padStart(2,'0');const day=String(d.getDate()).padStart(2,'0');return`${y}-${m}-${day}`}
function setRange(from:Date,to:Date){emit('update:from',fmt(from));emit('update:to',fmt(to))}
function applyPreset(value:string){
preset.value=value;if(value==='custom')return
const today=new Date();today.setHours(0,0,0,0);const from=new Date(today);const to=new Date(today)
if(value==='yesterday'){from.setDate(from.getDate()-1);to.setDate(to.getDate()-1)}
if(value==='last7'){from.setDate(from.getDate()-6)}
if(value==='thisMonth'){from.setDate(1)}
if(value==='lastMonth'){from.setDate(1);from.setMonth(from.getMonth()-1);to.setDate(0)}
setRange(from,to)
}
</script>
<template>
<div class="kbx-range-field" :data-state="effectiveState">
<span :id="`${uid}-label`" class="kbx-range__label">{{label}}</span>
<div class="kbx-range" role="group" :aria-labelledby="`${uid}-label`" :aria-describedby="message?messageId:undefined">
<input type="date" :value="from??''" :disabled="disabled" :readonly="readonly" aria-label="시작일" @input="emit('update:from',($event.target as HTMLInputElement).value||null);preset='custom'">
<span aria-hidden="true">~</span>
<input type="date" :value="to??''" :disabled="disabled" :readonly="readonly" aria-label="종료일" @input="emit('update:to',($event.target as HTMLInputElement).value||null);preset='custom'">
<select v-if="presets" :value="preset" :disabled="disabled||readonly" aria-label="빠른 기간 선택" @change="applyPreset(($event.target as HTMLSelectElement).value)">
<option value="today">오늘</option><option value="yesterday">어제</option><option value="last7">최근 7</option><option value="thisMonth">이번달</option><option value="lastMonth">지난달</option><option value="custom">직접선택</option>
</select>
</div>
<span v-if="message" :id="messageId" class="kbx-range__message" :role="error?'alert':undefined">{{message}}</span>
</div>
</template>
<style scoped>.kbx-range-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-range__label{padding-top:var(--kbx-space-2);font-weight:500}.kbx-range{display:flex;align-items:center;gap:var(--kbx-space-1)}.kbx-range input,.kbx-range select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-range-field[data-state="changed"] input{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-range-field[data-state="warning"] input{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-range-field[data-state="ai-suggested"] input{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-range-field[data-state="error"] input{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-range__message{grid-column:2;font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-range-field[data-state="warning"] .kbx-range__message{color:var(--kbx-color-warning-text)}.kbx-range-field[data-state="error"] .kbx-range__message{color:var(--kbx-color-danger)}.kbx-range-field[data-state="ai-suggested"] .kbx-range__message{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
const props=withDefaults(defineProps<{open:boolean;title:string;size?:'sm'|'md'|'lg';closeable?:boolean}>(),{size:'md',closeable:true})
const emit=defineEmits<{ 'update:open':[boolean] }>()
const widths={sm:'420px',md:'600px',lg:'840px'} as const
</script>
<template><Dialog :visible="open" modal :header="title" :closable="closeable" :style="{width:widths[size]}" @update:visible="emit('update:open',$event)"><slot/><template #footer><slot name="footer"/></template></Dialog></template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import Drawer from 'primevue/drawer'
withDefaults(defineProps<{open:boolean;title:string;position?:'left'|'right';width?:string}>(),{position:'right',width:'560px'})
const emit=defineEmits<{ 'update:open':[boolean] }>()
</script>
<template><Drawer :visible="open" :header="title" :position="position" :style="{width}" @update:visible="emit('update:open',$event)"><slot/><template #footer><slot name="footer"/></template></Drawer></template>
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
KbxImportDefinition,
KbxImportMapping,
KbxImportSession,
KbxImportProgressEvent,
} from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxConfirm from './KbxConfirm.vue'
import KbxJobProgress from './KbxJobProgress.vue'
import KbxProgressSteps from './KbxProgressSteps.vue'
import { validateKbxImportFileCandidate, validateKbxImportMappings } from '../excel/importGuard'
const props = defineProps<{
definition: KbxImportDefinition
session: KbxImportSession | null
busy?: boolean
progress?: KbxImportProgressEvent | null
}>()
const emit = defineEmits<{
upload: [File]
saveMapping: [KbxImportMapping[]]
saveNamedMapping: [string, KbxImportMapping[]]
validate: []
commit: []
downloadTemplate: []
downloadErrors: []
cancel: []
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const localMapping = ref<KbxImportMapping[] | null>(null)
const dragActive = ref(false)
const mappingName = ref('')
const clientIssue = ref('')
const commitConfirmOpen = ref(false)
const importSteps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]
const currentStepKey=computed(()=>step.value===1?'file':step.value===2?'mapping':step.value===3?'validation':'commit')
const step = computed(() => {
const status = props.session?.status
if (!status || status === 'created') return 1
if (status === 'uploaded' || status === 'mapping-required') return 2
if (status === 'validating' || status === 'validated') return 3
return 4
})
watch(() => props.session?.id, () => {
localMapping.value = null
mappingName.value = ''
clientIssue.value = ''
commitConfirmOpen.value = false
})
const mappings = computed({
get: () => localMapping.value ?? (props.session?.mapping ?? []),
set: value => { localMapping.value = value },
})
const importableFields = computed(() => props.definition.fields.filter(field => field.importable !== false))
const mappingIssues = computed(() => validateKbxImportMappings(props.definition,mappings.value))
const canValidate = computed(() => mappings.value.length > 0 && mappingIssues.value.length === 0 && !props.busy)
const canCommit = computed(() => (props.session?.validRows ?? 0) > 0 && props.session?.status === 'validated' && !props.busy)
const isFailed = computed(() => props.session?.status === 'failed')
const isCancelled = computed(() => props.session?.status === 'cancelled')
const isPartial = computed(() => props.session?.status === 'partially-completed')
function chooseFile() { clientIssue.value=''; fileInput.value?.click() }
function onFiles(files: FileList | null) {
const file = files?.item(0)
if (!file) return
clientIssue.value=''
const issue=validateKbxImportFileCandidate(props.definition,file)
if(issue){clientIssue.value=issue;return}
emit('upload', file)
}
function onDrop(event: DragEvent) {
dragActive.value = false
onFiles(event.dataTransfer?.files ?? null)
}
function setTarget(index: number, targetField: string) {
const next = mappings.value.map((item, itemIndex) => itemIndex === index
? { ...item, targetField: targetField || null, source: 'manual' as const, confidence: undefined, reason:undefined }
: item)
mappings.value = next
emit('saveMapping', next)
}
function requestCommit(){if(canCommit.value)commitConfirmOpen.value=true}
function confirmCommit(){commitConfirmOpen.value=false;emit('commit')}
</script>
<template>
<section class="kbx-import" data-kbx-component="excel-import" :aria-busy="busy || undefined">
<header class="kbx-import__header">
<div>
<h2>{{ definition.title }}</h2>
<p>파일 매핑 검증 반영 순서로 처리합니다. 원본 번호와 오류 이력은 유지됩니다.</p>
</div>
<KbxButton label="업로드 양식 다운로드" variant="secondary" @click="emit('downloadTemplate')" />
</header>
<KbxProgressSteps data-kbx-surface="progress-steps" :steps="importSteps" :current="currentStepKey" />
<div v-if="step === 1" class="kbx-import__drop" data-kbx-surface="file"
:class="{ 'is-dragging': dragActive }"
@dragover.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="onDrop">
<input ref="fileInput" hidden type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="onFiles(($event.target as HTMLInputElement).files)">
<strong>Excel 파일을 선택하세요.</strong>
<span>.xlsx · 최대 {{ Math.round((definition.maxFileSizeBytes ?? 10485760) / 1048576) }}MB</span>
<KbxButton label="파일 선택" variant="primary" :loading="busy" @click="chooseFile" />
<small>Drag & Drop은 보조 기능이며 파일 선택 버튼은 항상 제공됩니다.</small>
<p v-if="clientIssue" class="kbx-import__client-error" role="alert">{{clientIssue}}</p>
</div>
<section v-else-if="step === 2" class="kbx-import__mapping" data-kbx-surface="mapping">
<div class="mapping-head"><span>Excel </span><span>시스템 필드</span><span>매핑 근거</span></div>
<div v-for="(mapping, index) in mappings" :key="mapping.sourceColumn" class="mapping-row">
<strong>{{ mapping.sourceColumn }}</strong>
<select :value="mapping.targetField ?? ''" :aria-invalid="mapping.targetField ? mappings.filter(item=>item.targetField===mapping.targetField).length>1 : undefined" @change="setTarget(index, ($event.target as HTMLSelectElement).value)">
<option value="">매핑하지 않음</option>
<option v-for="field in importableFields" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</select>
<span class="mapping-reason">
{{ mapping.source === 'ai' ? `AI 추천 ${Math.round((mapping.confidence ?? 0) * 100)}%` : mapping.reason ?? mapping.source }}
</span>
</div>
<div v-if="mappingIssues.length" class="mapping-issues" role="alert">
<strong>매핑을 확인하세요.</strong>
<ul><li v-for="issue in mappingIssues" :key="`${issue.code}:${issue.fieldKey}`">{{issue.message}}</li></ul>
</div>
<div class="mapping-save">
<label>다음에도 사용할 매핑 이름 <input v-model="mappingName" maxlength="80" placeholder=": 쿠팡 주문양식"></label>
<KbxButton label="매핑 저장" variant="secondary" :disabled="!mappingName.trim() || Boolean(mappingIssues.length)" @click="emit('saveNamedMapping', mappingName.trim(), mappings)" />
</div>
<div class="kbx-import__actions">
<KbxButton label="취소" variant="secondary" @click="emit('cancel')" />
<KbxButton label="검증 시작" variant="primary" :disabled="!canValidate" :loading="busy" @click="emit('validate')" />
</div>
</section>
<section v-else-if="step === 3" class="kbx-import__validation" data-kbx-surface="validation">
<KbxJobProgress v-if="session?.status === 'validating'" :progress="progress ?? null" />
<template v-else>
<div class="summary" aria-label="검증 결과">
<div><span>전체</span><strong>{{ session?.totalRows.toLocaleString() }}</strong></div>
<div><span>정상</span><strong>{{ session?.validRows.toLocaleString() }}</strong></div>
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
<div><span>경고</span><strong>{{ session?.warningRows.toLocaleString() }}</strong></div>
</div>
<div v-if="session?.errors?.length" class="errors">
<div class="errors__head"><span>행</span><span>필드</span><span>사유</span></div>
<div v-for="error in session.errors.slice(0, 100)" :key="`${error.rowNumber}-${error.field}-${error.code}`" class="errors__row" :data-severity="error.severity">
<span>{{ error.rowNumber }}</span><span>{{ error.sourceColumn ?? error.field ?? '-' }}</span><span>{{ error.message }}</span>
</div>
<small v-if="session.errors.length > 100">화면에는 처음 100건만 표시합니다. 전체 오류는 오류파일로 확인하세요.</small>
</div>
<div class="kbx-import__actions">
<KbxButton v-if="session?.invalidRows" label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" />
<KbxButton label="정상 데이터 반영" variant="primary" :disabled="!canCommit" @click="requestCommit" />
</div>
</template>
</section>
<section v-else class="kbx-import__commit" data-kbx-surface="result" :data-result="session?.status">
<KbxJobProgress v-if="session?.status === 'committing'" :progress="progress ?? null" />
<div v-else-if="isFailed" class="kbx-import__terminal is-error" role="alert">
<strong>{{session?.failure?.title ?? '반영 작업을 완료하지 못했습니다.'}}</strong>
<p>{{session?.failure?.detail ?? '원본 업로드와 작업 이력은 유지됩니다. 원인을 확인한 뒤 다시 진행하세요.'}}</p>
<small v-if="session?.failure?.code">오류코드 {{session.failure.code}}</small>
<div class="kbx-import__actions"><KbxButton label=" 파일로 다시 시작" variant="secondary" @click="emit('cancel')" /></div>
</div>
<div v-else-if="isCancelled" class="kbx-import__terminal">
<strong>반영 작업이 취소되었습니다.</strong><p>새 파일을 선택해 다시 시작할 수 있습니다.</p>
<div class="kbx-import__actions"><KbxButton label=" 파일 선택" variant="secondary" @click="emit('cancel')" /></div>
</div>
<template v-else>
<div class="summary summary--result" :class="{'is-partial':isPartial}">
<div><span>신규</span><strong>{{ session?.createdRows.toLocaleString() }}</strong></div>
<div><span>수정</span><strong>{{ session?.updatedRows.toLocaleString() }}</strong></div>
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
</div>
<p v-if="isPartial" class="kbx-import__partial">정상 데이터는 반영되었고 오류 데이터는 제외되었습니다. 오류파일로 실패 건만 다시 처리할 수 있습니다.</p>
<div v-if="isPartial && session?.invalidRows" class="kbx-import__actions"><KbxButton label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" /></div>
</template>
</section>
<KbxConfirm
:open="commitConfirmOpen"
title="정상 데이터를 반영하시겠습니까?"
:detail="`${(session?.validRows ?? 0).toLocaleString()}건을 반영합니다. 오류 ${(session?.invalidRows ?? 0).toLocaleString()}건은 제외됩니다.`"
level="high"
:confirm-label="`${(session?.validRows ?? 0).toLocaleString()}건 반영`"
@update:open="commitConfirmOpen=$event"
@confirm="confirmCommit"
/>
</section>
</template>
<style scoped>
.kbx-import{display:grid;gap:var(--kbx-space-4);min-width:0}.kbx-import__header{display:flex;justify-content:space-between;gap:var(--kbx-space-4);align-items:flex-start}.kbx-import__header h2{margin:0 0 var(--kbx-space-1);font-size:var(--kbx-font-xl)}.kbx-import__header p{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__drop{min-height:var(--kbx-import-drop-min-height);border:var(--kbx-border-width) dashed var(--kbx-color-border-strong);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);background:var(--kbx-color-surface-muted)}.kbx-import__drop.is-dragging{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2)}.kbx-import__drop>span,.kbx-import__drop small{color:var(--kbx-color-text-muted)}.kbx-import__client-error{margin:0;color:var(--kbx-color-danger);font-size:var(--kbx-font-sm)}.kbx-import__mapping,.kbx-import__validation,.kbx-import__commit{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);padding:var(--kbx-space-3)}.mapping-head,.mapping-row{display:grid;grid-template-columns:minmax(var(--kbx-import-source-column-min-width),1fr) minmax(var(--kbx-import-target-column-min-width),1.3fr) minmax(var(--kbx-import-reason-column-min-width),.8fr);gap:var(--kbx-space-3);min-height:var(--kbx-control-md);align-items:center;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.mapping-head{font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.mapping-row select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}.mapping-row select[aria-invalid="true"]{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.mapping-reason{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.mapping-issues{margin-top:var(--kbx-space-3);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface);font-size:var(--kbx-font-sm)}.mapping-issues ul{margin:var(--kbx-space-2) 0 0;padding-left:var(--kbx-space-5)}.mapping-save{display:flex;align-items:center;justify-content:flex-end;gap:var(--kbx-space-2);padding-top:var(--kbx-space-3)}.mapping-save label{display:flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-sm)}.mapping-save input{width:var(--kbx-import-mapping-name-width);height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.summary{display:grid;grid-template-columns:repeat(4,minmax(var(--kbx-summary-item-min-width),1fr));border:var(--kbx-border-width) solid var(--kbx-color-border);margin-bottom:var(--kbx-space-3)}.summary>div{padding:var(--kbx-space-3);display:flex;justify-content:space-between;border-right:var(--kbx-border-width) solid var(--kbx-color-border)}.summary>div:last-child{border-right:0}.summary strong{font-size:var(--kbx-font-xl)}.summary--result{grid-template-columns:repeat(3,minmax(var(--kbx-summary-item-min-width),1fr))}.summary--result.is-partial{border-color:var(--kbx-color-warning-border)}.errors{border:var(--kbx-border-width) solid var(--kbx-color-border)}.errors__head,.errors__row{display:grid;grid-template-columns:var(--kbx-import-error-row-width) var(--kbx-import-error-field-width) 1fr;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);align-items:center;padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm)}.errors__head{font-weight:600;background:var(--kbx-color-surface-muted)}.errors__row[data-severity="warning"]{background:var(--kbx-color-warning-surface)}.errors__row[data-severity="error"]{background:var(--kbx-color-danger-surface)}.errors__row:last-of-type{border-bottom:0}.errors small{display:block;padding:var(--kbx-space-2);color:var(--kbx-color-text-muted)}.kbx-import__terminal{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-4);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-import__terminal.is-error{background:var(--kbx-color-danger-surface);border-color:var(--kbx-color-danger-border)}.kbx-import__terminal p,.kbx-import__partial{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue'
import KbxButton from './KbxButton.vue'
const props = withDefaults(defineProps<{
canImport?: boolean
canExport?: boolean
}>(), { canImport: true, canExport: true })
const emit = defineEmits<{
export: []
template: []
import: []
paste: []
history: []
}>()
const open = ref(false)
function choose(action: 'export' | 'template' | 'import' | 'paste' | 'history') {
open.value = false
emit(action)
}
</script>
<template>
<div class="kbx-excel-menu">
<KbxButton label="엑셀" variant="secondary" @click="open = !open" />
<div v-if="open" class="kbx-excel-menu__popup" role="menu">
<button v-if="props.canExport" type="button" @click="choose('export')">현재 조회결과 다운로드</button>
<button v-if="props.canImport" type="button" @click="choose('template')">업로드 양식 다운로드</button>
<button v-if="props.canImport" type="button" @click="choose('import')">엑셀 업로드</button>
<button v-if="props.canImport" type="button" @click="choose('paste')">Excel에서 붙여넣기</button>
<button v-if="props.canImport" type="button" @click="choose('history')">최근 업로드 결과</button>
</div>
</div>
</template>
<style scoped>
.kbx-excel-menu { position:relative; display:inline-block; }
.kbx-excel-menu__popup { position:absolute; right:0; top:calc(100% + 4px); min-width:220px; padding:4px; background:var(--kbx-color-surface); border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); box-shadow:0 6px 18px rgb(0 0 0 / 12%); z-index:50; }
.kbx-excel-menu__popup button { display:block; width:100%; height:34px; padding:0 10px; text-align:left; border:0; background:transparent; color:var(--kbx-color-text); border-radius:4px; }
.kbx-excel-menu__popup button:hover { background:var(--kbx-color-surface-muted); }
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { KbxWorkItem, KbxWorkItemAction, KbxWorkQueueCounter } from '@kbx/contracts'
import KbxExceptionSummary from './KbxExceptionSummary.vue'
import KbxExceptionDetailDrawer from './KbxExceptionDetailDrawer.vue'
defineProps<{
counters: KbxWorkQueueCounter[]
activeKey?: string | null
selectedItem?: KbxWorkItem | null
can?: (permission: string) => boolean
}>()
const emit = defineEmits<{
filter: [string | null]
closeDetail: []
action: [KbxWorkItemAction]
}>()
</script>
<template>
<section class="kbx-exception-center">
<KbxExceptionSummary :counters="counters" :active-key="activeKey" @select="emit('filter', $event)" />
<div class="kbx-exception-center__queue"><slot /></div>
<KbxExceptionDetailDrawer :item="selectedItem ?? null" :can="can" @close="emit('closeDetail')" @action="emit('action', $event)" />
</section>
</template>
<style scoped>
.kbx-exception-center { display:flex; flex-direction:column; gap:8px; min-height:0; height:100%; }
.kbx-exception-center__queue { min-height:0; flex:1; }
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { inject } from 'vue'
import type { KbxWorkItem, KbxWorkItemAction } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
import KbxDrawer from './KbxDrawer.vue'
const props = defineProps<{ item: KbxWorkItem | null; can?: (permission: string) => boolean }>()
const emit = defineEmits<{ close: []; action: [KbxWorkItemAction] }>()
const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??false)}
function allowed(action:KbxWorkItemAction){return !action.permission||canPermission(action.permission)}
function visible(action:KbxWorkItemAction){return allowed(action)||action.permissionMode==='disable'}
function invoke(action:KbxWorkItemAction){if(allowed(action))emit('action',action)}
</script>
<template>
<KbxDrawer :open="Boolean(item)" :title="item?.title ?? '예외 상세'" @update:open="value=>{if(!value)emit('close')}">
<article v-if="item" class="kbx-exception-detail" data-kbx-component="exception-detail-drawer" :data-severity="item.severity">
<header class="kbx-exception-detail__status">
<span>{{item.code}}</span><strong>{{item.status==='resolved'?'해결':item.status==='claimed'?'처리중':item.status==='ignored'?'제외':'확인 필요'}}</strong>
</header>
<section class="meta" aria-label="예외 정보">
<dl>
<div><dt>모듈</dt><dd>{{ item.sourceModule }}</dd></div>
<div><dt>대상</dt><dd>{{ item.sourceType }} · {{ item.referenceNo }}</dd></div>
<div><dt>발생</dt><dd>{{ item.occurredAt }}</dd></div>
<div><dt>담당</dt><dd>{{ item.ownerName || '미지정' }}</dd></div>
<div v-if="item.dueAt"><dt>기한</dt><dd>{{item.dueAt}}</dd></div>
</dl>
</section>
<section class="detail">
<h2>원인/안내</h2>
<p>{{ item.detail || '추가 설명이 없습니다.' }}</p>
<slot name="context" :item="item" />
</section>
</article>
<template #footer>
<div v-if="item" class="kbx-exception-detail__actions" role="toolbar" aria-label="예외 후속 작업">
<KbxButton
v-for="action in (item.actions ?? []).filter(visible)"
:key="action.id"
:label="action.label"
:variant="action.danger?'danger':'secondary'"
:disabled="!allowed(action)"
:title="!allowed(action)?'이 작업을 실행할 권한이 없습니다.':undefined"
@click="invoke(action)"
/>
</div>
</template>
</KbxDrawer>
</template>
<style scoped>
.kbx-exception-detail{display:grid;gap:var(--kbx-space-4)}.kbx-exception-detail__status{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.kbx-exception-detail[data-severity="warning"] .kbx-exception-detail__status{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-exception-detail[data-severity="critical"] .kbx-exception-detail__status{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.kbx-exception-detail__status span{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.meta,.detail{padding-bottom:var(--kbx-space-4);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}dl{margin:0;display:grid;gap:var(--kbx-space-2)}dl div{display:grid;grid-template-columns:var(--kbx-label-width) 1fr;gap:var(--kbx-space-2)}dt{color:var(--kbx-color-text-muted)}dd{margin:0}h2{margin:0 0 var(--kbx-space-2);font-size:var(--kbx-font-md)}p{margin:0;line-height:1.6}.kbx-exception-detail__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2);flex-wrap:wrap}
</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { KbxWorkQueueCounter } from '@kbx/contracts'
defineProps<{ counters: KbxWorkQueueCounter[]; activeKey?: string | null }>()
const emit = defineEmits<{ select: [string | null] }>()
</script>
<template>
<div class="kbx-exception-summary" aria-label="업무 예외 요약">
<button
v-for="counter in counters"
:key="counter.key"
type="button"
class="kbx-exception-summary__item"
:class="{ active: activeKey === counter.key }"
:data-severity="counter.severity ?? 'info'"
@click="emit('select', activeKey === counter.key ? null : counter.key)"
>
<span class="label">{{ counter.label }}</span>
<strong>{{ counter.count.toLocaleString('ko-KR') }}</strong>
</button>
</div>
</template>
<style scoped>
.kbx-exception-summary{display:flex;flex-wrap:wrap;gap:var(--kbx-space-2)}
.kbx-exception-summary__item{display:grid;grid-template-columns:auto minmax(var(--kbx-control-md),auto);align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-control-lg);padding:var(--kbx-space-1) 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);cursor:pointer}
.kbx-exception-summary__item:hover, .kbx-exception-summary__item.active { border-color:var(--kbx-color-primary); background:var(--kbx-color-surface-hover); }
.kbx-exception-summary__item[data-severity="critical"] strong { color:var(--kbx-color-danger); }
.kbx-exception-summary__item[data-severity="warning"] strong { color:var(--kbx-color-warning); }
.label{font-size:var(--kbx-font-sm)}
strong{font-size:var(--kbx-font-lg);text-align:right}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxValidationError } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
const props=withDefaults(defineProps<{
screen:KbxScreenDefinition
selectionCount?:number
can?:(permission:string)=>boolean
breadcrumb?:string
dirty?:boolean
showKeyboardGuide?:boolean
context?:KbxTemplateContext|null
contentState?:KbxAsyncState
refreshing?:boolean
errors?:KbxValidationError[]
summaryItems?:KbxSummaryItem[]
}>(), { showKeyboardGuide:true, context:null, contentState:'ready', refreshing:false, errors:()=>[], summaryItems:()=>[] })
const emit=defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="fast-entry" template-code="T04" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :dirty="dirty" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<section v-if="showKeyboardGuide" class="kbx-fast-entry__guide" aria-label="빠른 입력 키보드 안내" data-kbx-surface="keyboard-guide"><slot name="guide"><span><kbd>Enter</kbd> 입력 확정/다음</span><span><kbd>F2</kbd> 코드 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span><kbd>Ctrl+D</kbd> Fill Down</span><span>오류는 Grid에서 바로 이동</span></slot></section>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<div v-if="$slots.contextual" class="kbx-fast-entry__contextual" data-kbx-surface="bulk-action"><slot name="contextual" /></div>
<main class="kbx-fast-entry__content" data-kbx-surface="editable-grid"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" @retry="emit('command','reload')"><slot name="content" /></KbxTemplateStateBoundary></main>
<section v-if="$slots.validation || errors.length" class="kbx-fast-entry__validation" data-kbx-surface="validation"><slot name="validation"><KbxValidationSummary :errors="errors" /></slot></section>
<footer v-if="$slots.summary || summaryItems.length" class="kbx-fast-entry__summary" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" align="end" /></slot></footer>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-fast-entry__guide{min-height:var(--kbx-control-sm);display:flex;align-items:center;gap:var(--kbx-space-3);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);flex-wrap:wrap}.kbx-fast-entry__guide kbd{font:inherit;font-weight:600;color:var(--kbx-color-text)}.kbx-fast-entry__contextual{position:sticky;top:var(--kbx-command-bar-height);z-index:6}.kbx-fast-entry__content{min-height:var(--kbx-content-min-height);flex:1}.kbx-fast-entry__validation{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-fast-entry__summary{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border);display:flex;align-items:center}.kbx-fast-entry__summary :deep(.kbx-summary-bar){width:100%;border-top:0}
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(defineProps<{
columns?: 1 | 2
ariaLabel?: string
}>(), { columns:2, ariaLabel:'입력 항목' })
</script>
<template>
<div class="kbx-form-grid" :data-columns="columns" role="group" :aria-label="ariaLabel">
<slot />
</div>
</template>
<style scoped>
.kbx-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--kbx-form-grid-column-gap);row-gap:var(--kbx-form-grid-row-gap);align-items:start}.kbx-form-grid[data-columns="1"]{grid-template-columns:minmax(0,1fr)}@media(max-width:56rem){.kbx-form-grid{grid-template-columns:minmax(0,1fr)}}
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(defineProps<{ title:string; description?:string; labelledBy?:string }>(), { description:'' })
</script>
<template>
<section class="kbx-section" :aria-labelledby="labelledBy">
<header class="kbx-section__header">
<h2 :id="labelledBy">{{ title }}</h2>
<p v-if="description">{{description}}</p>
<div v-if="$slots.actions" class="kbx-section__actions"><slot name="actions" /></div>
</header>
<div class="kbx-section__content"><slot /></div>
</section>
</template>
<style scoped>
.kbx-section{display:flex;flex-direction:column;gap:var(--kbx-form-section-heading-gap);margin-bottom:var(--kbx-form-section-gap)}.kbx-section__header{min-height:var(--kbx-control-xs);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:var(--kbx-space-2);padding-bottom:var(--kbx-form-section-heading-padding);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-section h2{margin:0;font-size:var(--kbx-font-lg);font-weight:600}.kbx-section p{grid-column:1;margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-section__actions{grid-column:2;grid-row:1/-1;display:flex;align-items:center;gap:var(--kbx-space-1)}.kbx-section__content{min-width:0}
</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
withDefaults(defineProps<{ span?: 'cell' | 'full' }>(), { span:'cell' })
</script>
<template><div class="kbx-form-span" :data-span="span"><slot /></div></template>
<style scoped>.kbx-form-span{min-width:0}.kbx-form-span[data-span="full"]{grid-column:1/-1}</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxDataFreshness } from '@kbx/contracts'
const props = withDefaults(defineProps<{
freshness: KbxDataFreshness
now?: number
}>(), { now: () => Date.now() })
const emit = defineEmits<{ refresh: [] }>()
const ageSeconds = computed(() => Math.max(0, Math.floor((props.now - new Date(props.freshness.observedAt).getTime()) / 1000)))
const stale = computed(() => props.freshness.staleAfterSeconds != null && ageSeconds.value > props.freshness.staleAfterSeconds)
const label = computed(() => {
if (ageSeconds.value < 10) return '방금 갱신'
if (ageSeconds.value < 60) return `${ageSeconds.value}초 전`
const minutes = Math.floor(ageSeconds.value / 60)
return `${minutes}분 전`
})
</script>
<template>
<button class="kbx-freshness" :class="{ stale }" type="button" @click="emit('refresh')" :title="freshness.source ? `출처: ${freshness.source}` : undefined">
<span aria-hidden="true"></span>
<span>{{ stale ? '최신 데이터 확인 필요' : label }}</span>
</button>
</template>
<style scoped>
.kbx-freshness { display:inline-flex; align-items:center; gap:4px; border:0; background:transparent; color:var(--kbx-color-text-muted); font-size:var(--kbx-font-sm); cursor:pointer; }
.kbx-freshness.stale { color:var(--kbx-color-warning); font-weight:600; }
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { KbxHelpContent } from '@kbx/contracts'
defineProps<{ content: KbxHelpContent }>()
</script>
<template>
<aside class="kbx-help-panel">
<header><h2>{{ content.title }}</h2></header>
<p>{{ content.purpose }}</p>
<section v-if="content.steps?.length">
<h3>사용순서</h3>
<ol><li v-for="step in content.steps" :key="step">{{ step }}</li></ol>
</section>
<section v-if="content.shortcuts?.length">
<h3>단축키</h3>
<dl><template v-for="item in content.shortcuts" :key="item.key"><dt>{{ item.key }}</dt><dd>{{ item.description }}</dd></template></dl>
</section>
<section v-if="content.cautions?.length">
<h3>주의</h3>
<ul><li v-for="item in content.cautions" :key="item">{{ item }}</li></ul>
</section>
</aside>
</template>
<style scoped>
.kbx-help-panel { width:min(390px, 92vw); padding:16px; font-size:14px; line-height:1.55; }
h2 { margin:0 0 8px; font-size:18px; } h3 { margin:18px 0 6px; font-size:14px; } ol, ul { padding-left:20px; } dl { display:grid; grid-template-columns:72px 1fr; gap:6px 10px; } dt { font-weight:700; } dd { margin:0; }
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxTemplateContext } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean }>(), { contentState:'ready', refreshing:false })
const emit=defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="import" template-code="T08" :screen="screen" :can="can" :breadcrumb="breadcrumb" :command-bar="Boolean(screen.commands?.length || $slots.commands)" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<nav v-if="$slots.steps" class="kbx-import-page__steps" aria-label="가져오기 단계" data-kbx-surface="progress-steps"><slot name="steps" /></nav>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<main class="kbx-import-page__content" data-kbx-surface="import-content"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" @retry="emit('command','retry')"><slot /></KbxTemplateStateBoundary></main>
<section v-if="$slots.result" class="kbx-import-page__result" data-kbx-surface="result"><slot name="result" /></section>
<footer v-if="$slots.footer" class="kbx-import-page__footer" data-kbx-surface="footer"><slot name="footer" /></footer>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-import-page__steps{min-height:var(--kbx-control-lg);display:flex;align-items:center;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:0 var(--kbx-space-3)}.kbx-import-page__content{min-height:0;flex:1}.kbx-import-page__result{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-import-page__footer{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props = withDefaults(defineProps<{
modelValue?: string | null
label: string
required?: boolean
readonly?: boolean
disabled?: boolean
error?: string
warning?: string
helpText?: string
state?: KbxFieldState
placeholder?: string
maxlength?: number
}>(), { modelValue:'', state:'default' })
const emit = defineEmits<{ 'update:modelValue':[string]; enter:[]; blur:[FocusEvent] }>()
const uid = `kbx-input-${Math.random().toString(36).slice(2)}`
const messageId = `${uid}-message`
const effectiveState = computed(() => props.error ? 'error' : props.warning ? 'warning' : props.state)
const message = computed(() => props.error || props.warning || props.helpText || '')
</script>
<template>
<div class="kbx-field" :data-state="effectiveState" :data-readonly="readonly || undefined" :data-disabled="disabled || undefined">
<label class="kbx-field__label" :for="uid">{{ label }}<span v-if="required" aria-hidden="true"> *</span></label>
<input
:id="uid"
class="kbx-input"
:value="modelValue ?? ''"
:readonly="readonly"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:maxlength="maxlength"
:aria-invalid="Boolean(error)"
:aria-describedby="message ? messageId : undefined"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="emit('enter')"
@blur="emit('blur',$event)"
>
<span v-if="message" :id="messageId" class="kbx-field__message" :role="error ? 'alert' : undefined">{{ message }}</span>
</div>
</template>
<style scoped>
.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;min-height:var(--kbx-control-height);font-size:var(--kbx-font-md)}.kbx-field__label{padding-top:var(--kbx-space-2);font-weight:500;white-space:nowrap}.kbx-input{height:var(--kbx-control-height);min-width:0;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] .kbx-input{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] .kbx-input{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] .kbx-input{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] .kbx-input{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-input[readonly]{background:var(--kbx-color-surface-muted)}.kbx-input:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);margin-top:calc(var(--kbx-space-1) * -1)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { KbxIntegrationStatusView } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
const props = defineProps<{ value: KbxIntegrationStatusView }>()
const emit = defineEmits<{ details: []; retry: [] }>()
const stateLabel: Record<KbxIntegrationStatusView['state'], string> = {
queued: '전송 대기',
delivering: '전송 중',
retrying: '자동 재시도',
delivered: '전송 완료',
failed: '연계 실패',
suspended: '연계 중지',
}
</script>
<template>
<section class="kbx-integration-state" :data-state="value.state" role="status" aria-live="polite">
<div class="kbx-integration-state__body">
<strong>{{ value.label || stateLabel[value.state] }}</strong>
<span>{{ stateLabel[value.state] }}</span>
<small v-if="value.state === 'retrying' && value.nextRetryAt">다음 자동 재시도 {{ value.nextRetryAt }}</small>
<small v-if="value.detail">{{ value.detail }}</small>
<small v-if="value.correlationId">참조번호 {{ value.correlationId }}</small>
</div>
<div class="kbx-integration-state__actions">
<KbxButton label="상세보기" variant="tertiary" @click="emit('details')" />
<KbxButton v-if="value.retryAllowed && value.state === 'failed'" label="재처리" variant="secondary" @click="emit('retry')" />
</div>
</section>
</template>
<style scoped>
.kbx-integration-state { display:flex; align-items:center; justify-content:space-between; gap:var(--kbx-space-3); min-height:var(--kbx-control-lg); padding:var(--kbx-space-2) var(--kbx-space-3); border:thin solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface); }
.kbx-integration-state__body { display:flex; align-items:baseline; flex-wrap:wrap; gap:var(--kbx-space-2); }
.kbx-integration-state__body span,.kbx-integration-state__body small { color:var(--kbx-color-text-muted); }
.kbx-integration-state__actions { display:flex; gap:var(--kbx-space-2); }
.kbx-integration-state[data-state="retrying"] { border-color:var(--kbx-color-warning); }
.kbx-integration-state[data-state="failed"],.kbx-integration-state[data-state="suspended"] { border-color:var(--kbx-color-danger); }
.kbx-integration-state[data-state="delivered"] { border-color:var(--kbx-color-success); }
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxImportProgressEvent } from '@kbx/contracts'
const props = defineProps<{ progress: KbxImportProgressEvent | null }>()
const width = computed(() => `${Math.min(100, Math.max(0, props.progress?.progressPercent ?? 0))}%`)
</script>
<template>
<section v-if="progress" class="kbx-job-progress" aria-live="polite">
<header>
<strong>{{ progress.message ?? '처리 중' }}</strong>
<span>{{ progress.progressPercent }}%</span>
</header>
<div class="track"><div class="bar" :style="{ width }" /></div>
<div class="counts">
<span>처리 {{ progress.processedRows.toLocaleString() }} / {{ progress.totalRows.toLocaleString() }}</span>
<span>정상 {{ progress.validRows.toLocaleString() }}</span>
<span v-if="progress.invalidRows">오류 {{ progress.invalidRows.toLocaleString() }}</span>
<span v-if="progress.warningRows">경고 {{ progress.warningRows.toLocaleString() }}</span>
</div>
</section>
</template>
<style scoped>
.kbx-job-progress { border:1px solid var(--kbx-color-border); background:var(--kbx-color-surface); padding:12px; }
.kbx-job-progress header, .counts { display:flex; justify-content:space-between; gap:12px; font-size:13px; }
.track { margin:10px 0; height:8px; background:var(--kbx-color-surface-muted); border-radius:4px; overflow:hidden; }
.bar { height:100%; background:var(--kbx-color-primary); transition:width .18s ease; }
.counts { justify-content:flex-start; color:var(--kbx-color-text-muted); flex-wrap:wrap; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxQuickFilterItem, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext } from '@kbx/contracts'
import KbxQuickFilterBar from './KbxQuickFilterBar.vue'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
const props = withDefaults(defineProps<{
screen: KbxScreenDefinition
selectionCount?: number
can?: (permission: string) => boolean
breadcrumb?: string
context?: KbxTemplateContext | null
contentState?: KbxAsyncState
refreshing?: boolean
quickFilters?: KbxQuickFilterItem[]
summaryItems?: KbxSummaryItem[]
}>(), {
context: null,
contentState: 'ready',
refreshing: false,
quickFilters: () => [],
summaryItems: () => [],
})
const emit = defineEmits<{ command: [string]; quickFilter: [string] }>()
</script>
<template>
<KbxScreenFrame expected-type="list" template-code="T01" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command', $event)">
<template #utility><slot name="utility" /></template>
<template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-list-page__search" data-kbx-surface="search"><slot name="search" /></div>
<div v-if="$slots['quick-filter'] || quickFilters.length" class="kbx-list-page__quick-filter" data-kbx-surface="quick-filter">
<slot name="quick-filter"><KbxQuickFilterBar :items="quickFilters" @select="emit('quickFilter',$event)" /></slot>
</div>
<div v-if="$slots.context || props.context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="props.context" /></slot></div>
<div v-if="$slots.contextual" class="kbx-list-page__contextual" data-kbx-surface="bulk-action"><slot name="contextual" /></div>
<main class="kbx-list-page__content" data-kbx-surface="content"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')"><slot name="content" /></KbxTemplateStateBoundary></main>
<footer v-if="$slots.summary || summaryItems.length" class="kbx-list-page__summary" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" /></slot></footer>
<div v-if="$slots.detail" data-kbx-surface="detail-drawer"><slot name="detail" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-list-page__content{min-height:var(--kbx-content-min-height);flex:1}.kbx-list-page__summary{min-height:var(--kbx-control-sm);display:flex;align-items:center;border-top:var(--kbx-border-width) solid var(--kbx-color-border);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-list-page__summary :deep(.kbx-summary-bar){width:100%;border-top:0}.kbx-list-page__contextual{position:sticky;top:var(--kbx-command-bar-height);z-index:6}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import type { KbxFieldState, KbxLookupColumnDefinition, KbxLookupItem } from '@kbx/contracts'
import { kbxLookupRegistryKey } from '../lookup/registry'
import KbxLookupDialog from './KbxLookupDialog.vue'
const props = withDefaults(defineProps<{
modelValue: string | null
entity: string
label: string
required?: boolean
readonly?: boolean
disabled?: boolean
error?: string
warning?: string
helpText?: string
state?: KbxFieldState
columns?: KbxLookupColumnDefinition[]
pageSize?: number
}>(), { state:'default', pageSize:30 })
const emit = defineEmits<{
'update:modelValue': [string | null]
selected: [KbxLookupItem<string>]
}>()
const registry = inject(kbxLookupRegistryKey, {})
const provider = computed(() => registry[props.entity])
const code = ref('')
const displayName = ref('')
const open = ref(false)
const localError = ref('')
const resolving = ref(false)
let editingCode = false
let triggerElement: HTMLElement | null = null
let resolveSequence = 0
const uid=`kbx-lookup-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error||localError.value?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||localError.value||props.warning||props.helpText||'')
watch(() => [props.modelValue, props.entity] as const, async ([id]) => {
const sequence=++resolveSequence
localError.value=''
if (!id) {
displayName.value = ''
if (!editingCode) code.value = ''
editingCode = false
resolving.value=false
return
}
if(!provider.value){code.value='';displayName.value='';localError.value='조회 공급자가 구성되지 않았습니다.';return}
resolving.value=true
try{
const item = await provider.value.resolveById(id)
if(sequence!==resolveSequence)return
if(item)applyDisplay(item)
else{code.value='';displayName.value='';localError.value='선택된 항목을 다시 확인하세요.'}
}catch{
if(sequence===resolveSequence)localError.value='선택 정보를 불러오지 못했습니다. 다시 시도하세요.'
}finally{if(sequence===resolveSequence)resolving.value=false}
}, { immediate: true })
function applyDisplay(item: KbxLookupItem<string>) { code.value=item.code; displayName.value=item.displayName }
async function resolveCode() {
const normalized=code.value.trim()
localError.value=''
if(!normalized){emit('update:modelValue',null);displayName.value='';return}
if (!provider.value){localError.value='조회 공급자가 구성되지 않았습니다.';return}
const sequence=++resolveSequence
resolving.value=true
try{
const item = await provider.value.resolveByCode(normalized)
if(sequence!==resolveSequence)return
if (item) select(item)
else localError.value = '일치하는 항목이 없습니다. F2로 조회하세요.'
}catch{
if(sequence===resolveSequence)localError.value='코드를 확인하지 못했습니다. 네트워크 상태를 확인하세요.'
}finally{if(sequence===resolveSequence)resolving.value=false}
}
function onCodeInput(value: string) {
code.value=value; displayName.value=''; localError.value=''; editingCode=true
emit('update:modelValue', null)
queueMicrotask(()=>{editingCode=false})
}
function show() {
if (props.readonly || props.disabled) return
if(!provider.value){localError.value='조회 공급자가 구성되지 않았습니다.';return}
triggerElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
open.value = true
}
function select(item: KbxLookupItem<string>) {
++resolveSequence
applyDisplay(item); localError.value=''; resolving.value=false; emit('update:modelValue',item.id); emit('selected',item); open.value=false
}
function restoreFocus(){queueMicrotask(()=>triggerElement?.focus())}
function onRootKeydown(event:KeyboardEvent){if(event.key==='F2'){event.preventDefault();show()}}
</script>
<template>
<div class="kbx-lookup" :data-state="effectiveState" :aria-busy="resolving || undefined" @keydown="onRootKeydown">
<label class="kbx-lookup__label" :for="`${uid}-code`">{{ label }}<span v-if="required" aria-hidden="true"> *</span></label>
<div class="kbx-lookup__control">
<input :id="`${uid}-code`" :value="code" class="kbx-lookup__code" :readonly="readonly" :disabled="disabled" aria-label="코드" autocomplete="off" :aria-invalid="Boolean(error||localError)" :aria-describedby="message?messageId:undefined" @input="onCodeInput(($event.target as HTMLInputElement).value)" @keydown.enter.prevent="resolveCode">
<input :value="displayName" class="kbx-lookup__name" readonly :disabled="disabled" aria-label="선택된 이름">
<button type="button" class="kbx-lookup__search" :disabled="readonly || disabled || resolving" title="조회 F2" @click="show">{{ resolving ? '확인중' : '검색' }}</button>
</div>
<span v-if="message" :id="messageId" class="kbx-lookup__message" :role="error||localError?'alert':undefined">{{message}}</span>
<KbxLookupDialog v-model:visible="open" :entity="entity" :title="label" :initial-query="displayName || code" :columns="columns" :page-size="pageSize" @select="select" @update:visible="value => { open=value; if(!value)restoreFocus() }" />
</div>
</template>
<style scoped>
.kbx-lookup{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-lookup__label{padding-top:var(--kbx-space-2);font-weight:500;white-space:nowrap}.kbx-lookup__control{display:grid;grid-template-columns:minmax(var(--kbx-lookup-code-width),auto) minmax(var(--kbx-lookup-name-min-width),1fr) var(--kbx-lookup-button-width);min-width:0}.kbx-lookup input,.kbx-lookup button{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);font:inherit;color:var(--kbx-color-text)}.kbx-lookup input{padding:0 var(--kbx-space-2);min-width:0;background:var(--kbx-color-surface)}.kbx-lookup__code{border-radius:var(--kbx-radius-sm) 0 0 var(--kbx-radius-sm)}.kbx-lookup__name{background:var(--kbx-color-surface-muted)!important;border-left:0!important}.kbx-lookup__search{border-radius:0 var(--kbx-radius-sm) var(--kbx-radius-sm) 0;background:var(--kbx-color-surface);border-left:0!important}.kbx-lookup[data-state="changed"] .kbx-lookup__code{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-lookup[data-state="warning"] .kbx-lookup__code{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-lookup[data-state="ai-suggested"] .kbx-lookup__code{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-lookup[data-state="error"] .kbx-lookup__code{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-lookup__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-lookup[data-state="warning"] .kbx-lookup__message{color:var(--kbx-color-warning-text)}.kbx-lookup[data-state="error"] .kbx-lookup__message{color:var(--kbx-color-danger)}.kbx-lookup[data-state="ai-suggested"] .kbx-lookup__message{color:var(--kbx-color-ai-text)}
</style>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, inject, nextTick, ref, watch } from 'vue'
import type { KbxLookupColumnDefinition, KbxLookupItem } from '@kbx/contracts'
import { kbxLookupRegistryKey } from '../lookup/registry'
import KbxButton from './KbxButton.vue'
import KbxDialog from './KbxDialog.vue'
const props = withDefaults(defineProps<{
visible: boolean
entity: string
title: string
initialQuery?: string
columns?: KbxLookupColumnDefinition[]
pageSize?: number
}>(), { initialQuery: '', pageSize: 30 })
const emit = defineEmits<{
'update:visible': [boolean]
select: [KbxLookupItem<string>]
}>()
const registry = inject(kbxLookupRegistryKey, {})
const query = ref('')
const items = ref<KbxLookupItem<string>[]>([])
const selectedIndex = ref(0)
const loading = ref(false)
const error = ref('')
const lastSearchedQuery = ref('')
const page = ref(1)
const totalCount = ref(0)
const root = ref<HTMLElement | null>(null)
let requestSequence = 0
const defaultColumns: KbxLookupColumnDefinition[] = [
{ key:'code', label:'코드', source:'code' },
{ key:'displayName', label:'명칭', source:'displayName' },
{ key:'status', label:'상태', source:'status', align:'center' },
]
const columns = computed(() => props.columns?.length ? props.columns : defaultColumns)
const safePageSize = computed(() => Math.max(10, Math.min(100, props.pageSize)))
const pageCount = computed(() => Math.max(1, Math.ceil(totalCount.value / safePageSize.value)))
const hasPrevious = computed(() => page.value > 1 && !loading.value)
const hasNext = computed(() => page.value < pageCount.value && !loading.value)
watch(() => [props.visible, props.entity] as const, async ([visible]) => {
requestSequence += 1
if (!visible) return
query.value = props.initialQuery.slice(0, 120)
page.value = 1
items.value = []
totalCount.value = 0
selectedIndex.value = 0
error.value = ''
await search(1)
})
function valueOf(item: KbxLookupItem<string>, column: KbxLookupColumnDefinition) {
switch (column.source) {
case 'code': return item.code
case 'displayName': return item.displayName
case 'secondaryText': return item.secondaryText ?? ''
case 'status': return item.status ?? ''
default: return item.metadata?.[column.source.slice('metadata.'.length)] ?? ''
}
}
async function search(nextPage = 1) {
const provider = registry[props.entity]
if (!provider) {
items.value = []
totalCount.value = 0
error.value = '조회 공급자가 구성되지 않았습니다. 관리자에게 문의하세요.'
return
}
const sequence = ++requestSequence
loading.value = true
error.value = ''
try {
const result = await provider.search({
query: query.value.trim().slice(0, 120),
page: nextPage,
pageSize: safePageSize.value,
})
if (sequence !== requestSequence) return
items.value = result.items
totalCount.value = Math.max(0, result.totalCount)
page.value = nextPage
selectedIndex.value = 0
lastSearchedQuery.value = query.value
await nextTick()
scrollSelectedIntoView()
} catch {
if (sequence !== requestSequence) return
items.value = []
totalCount.value = 0
error.value = '조회하지 못했습니다. 네트워크 상태를 확인한 후 다시 조회하세요.'
} finally {
if (sequence === requestSequence) loading.value = false
}
}
async function onQueryEnter() {
if (!loading.value && !error.value && lastSearchedQuery.value === query.value && items.value[selectedIndex.value]) {
select(items.value[selectedIndex.value])
return
}
await search(1)
}
function move(delta: number) {
if (!items.value.length || loading.value) return
selectedIndex.value = Math.max(0, Math.min(items.value.length - 1, selectedIndex.value + delta))
nextTick(scrollSelectedIntoView)
}
function scrollSelectedIntoView() {
root.value?.querySelector<HTMLElement>('tr[aria-selected="true"]')?.scrollIntoView({ block:'nearest' })
}
function select(item: KbxLookupItem<string>) {
emit('select', item)
emit('update:visible', false)
}
</script>
<template>
<KbxDialog :open="visible" :title="`${title} 검색`" size="lg" @update:open="emit('update:visible', $event)">
<div
ref="root"
class="kbx-lookup-dialog"
data-kbx-component="lookup-dialog"
:aria-busy="loading || undefined"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.esc.stop="emit('update:visible', false)"
>
<div class="kbx-lookup-dialog__search">
<label class="sr-only" for="kbx-lookup-dialog-query">검색어</label>
<input
id="kbx-lookup-dialog-query"
v-model="query"
class="kbx-lookup-dialog__input"
maxlength="120"
autocomplete="off"
autofocus
placeholder="코드 또는 명칭"
@input="error=''"
@keydown.enter.stop.prevent="onQueryEnter"
>
<KbxButton label="조회" variant="secondary" :loading="loading" @click="search(1)" />
</div>
<div v-if="loading" class="kbx-lookup-dialog__state" role="status">조회 중...</div>
<div v-else-if="error" class="kbx-lookup-dialog__state is-error" role="alert">
<strong>조회할 없습니다.</strong><span>{{ error }}</span><KbxButton label="다시 조회" variant="secondary" @click="search(page)" />
</div>
<div v-else-if="!items.length" class="kbx-lookup-dialog__state">
<strong>조회된 항목이 없습니다.</strong><span>검색어를 변경해 다시 조회하세요.</span>
</div>
<div v-else class="kbx-lookup-dialog__results">
<table class="kbx-lookup-table">
<thead><tr><th v-for="column in columns" :key="column.key" :style="{ width: column.width ? `${column.width}px` : undefined, textAlign: column.align }">{{ column.label }}</th></tr></thead>
<tbody>
<tr
v-for="(item, index) in items"
:key="String(item.id)"
:aria-selected="index === selectedIndex"
:class="{ selected: index === selectedIndex }"
@click="selectedIndex = index"
@dblclick="select(item)"
>
<td v-for="column in columns" :key="column.key" :style="{ textAlign: column.align }">{{ valueOf(item, column) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="kbx-lookup-dialog__footer">
<span aria-live="polite">{{ totalCount.toLocaleString() }} · {{ page }} / {{ pageCount }} 페이지 · 이동 · Enter 선택 · Esc 닫기</span>
<div class="kbx-lookup-dialog__footer-actions">
<KbxButton label="이전" variant="secondary" :disabled="!hasPrevious" @click="search(page - 1)" />
<KbxButton label="다음" variant="secondary" :disabled="!hasNext" @click="search(page + 1)" />
<KbxButton label="선택" variant="primary" :disabled="!items[selectedIndex]" @click="items[selectedIndex] && select(items[selectedIndex])" />
</div>
</div>
</div>
</KbxDialog>
</template>
<style scoped>
.kbx-lookup-dialog{display:grid;gap:var(--kbx-space-3)}
.kbx-lookup-dialog__search{display:flex;gap:var(--kbx-space-2)}
.kbx-lookup-dialog__input{flex:1;height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);font:inherit;color:var(--kbx-color-text);background:var(--kbx-color-surface)}
.kbx-lookup-dialog__results{max-height:60vh;overflow:auto;border:var(--kbx-border-width) solid var(--kbx-color-border)}
.kbx-lookup-dialog__state{min-height:var(--kbx-data-state-min-height);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);text-align:center;color:var(--kbx-color-text-muted)}
.kbx-lookup-dialog__state strong{color:var(--kbx-color-text)}
.kbx-lookup-dialog__state.is-error{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}
.kbx-lookup-table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}
.kbx-lookup-table th,.kbx-lookup-table td{border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);padding:var(--kbx-space-2);text-align:left;white-space:nowrap}
.kbx-lookup-table th{position:sticky;top:0;background:var(--kbx-color-surface-muted);font-weight:600;z-index:1}
.kbx-lookup-table tr.selected{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2);background:var(--kbx-color-info-surface)}
.kbx-lookup-dialog__footer{display:flex;justify-content:space-between;align-items:center;gap:var(--kbx-space-3);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
.kbx-lookup-dialog__footer-actions{display:flex;gap:var(--kbx-space-2)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props=withDefaults(defineProps<{label?:string;maskedValue:string;value?:string;revealed?:boolean;canReveal?:boolean;reason?:string}>(),{revealed:false,canReveal:false})
const emit=defineEmits<{reveal:[];hide:[]}>()
</script>
<template>
<span class="kbx-masked-value">
<span v-if="label" class="kbx-masked-value__label">{{ label }}</span>
<span class="kbx-masked-value__text">{{ revealed ? (value ?? maskedValue) : maskedValue }}</span>
<button v-if="canReveal && !revealed" type="button" class="kbx-masked-value__action" @click="emit('reveal')">전체보기</button>
<button v-else-if="revealed" type="button" class="kbx-masked-value__action" @click="emit('hide')">가리기</button>
<span v-if="!canReveal && reason" class="kbx-masked-value__reason">{{ reason }}</span>
</span>
</template>
<style scoped>
.kbx-masked-value{display:inline-flex;align-items:center;gap:var(--kbx-space-2)}
.kbx-masked-value__label{font-weight:500}.kbx-masked-value__action{border:0;background:none;text-decoration:underline;cursor:pointer}.kbx-masked-value__reason{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSectionHeader from './KbxSectionHeader.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; can?:(permission:string)=>boolean; selectionCount?:number; breadcrumb?:string; masterSize?:'sm'|'md'|'lg'; masterTitle?:string; detailTitle?:string; bottomTitle?:string; contextText?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean; summaryItems?:KbxSummaryItem[] }>(), { masterSize:'md', masterTitle:'목록', detailTitle:'상세', bottomTitle:'이력', contextText:'', context:null, contentState:'ready', refreshing:false, summaryItems:()=>[] })
const emit = defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="master-detail" template-code="T05" :screen="screen" :can="can" :selection-count="selectionCount" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-master-detail__search" data-kbx-surface="search"><slot name="search" /></div>
<div v-if="$slots.context || context || contextText" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context ?? (contextText ? {label:contextText} : null)" /></slot></div>
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')">
<div class="kbx-master-detail__workspace" :data-master-size="masterSize">
<section class="kbx-master-detail__pane kbx-master-detail__master" :aria-label="masterTitle" data-kbx-surface="master"><slot name="master-header"><KbxSectionHeader :title="masterTitle" /></slot><div class="kbx-master-detail__pane-body"><slot name="master" /></div></section>
<section class="kbx-master-detail__pane kbx-master-detail__detail" :aria-label="detailTitle" data-kbx-surface="detail"><slot name="detail-header"><KbxSectionHeader :title="detailTitle" /></slot><div class="kbx-master-detail__pane-body"><slot name="detail" /></div></section>
</div>
</KbxTemplateStateBoundary>
<section v-if="$slots.bottom" class="kbx-master-detail__pane kbx-master-detail__bottom" :aria-label="bottomTitle" data-kbx-surface="bottom/history"><slot name="bottom-header"><KbxSectionHeader :title="bottomTitle" /></slot><div class="kbx-master-detail__pane-body"><slot name="bottom" /></div></section>
<footer v-if="$slots.summary || summaryItems.length" class="kbx-master-detail__footer" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" /></slot></footer>
<div v-if="$slots.drawer" data-kbx-surface="drawer"><slot name="drawer" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-master-detail__workspace{min-height:var(--kbx-master-detail-min-height);flex:1;display:grid;gap:var(--kbx-space-2)}.kbx-master-detail__workspace[data-master-size="sm"]{grid-template-columns:minmax(var(--kbx-master-detail-master-min-sm),34%) minmax(0,1fr)}.kbx-master-detail__workspace[data-master-size="md"]{grid-template-columns:minmax(var(--kbx-master-detail-master-min-md),42%) minmax(0,1fr)}.kbx-master-detail__workspace[data-master-size="lg"]{grid-template-columns:minmax(var(--kbx-master-detail-master-min-lg),50%) minmax(0,1fr)}.kbx-master-detail__pane{min-height:0;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);overflow:hidden;display:flex;flex-direction:column;padding:0 var(--kbx-space-2)}.kbx-master-detail__pane-body{min-height:0;flex:1;padding:var(--kbx-space-2) 0}.kbx-master-detail__bottom{min-height:var(--kbx-master-detail-bottom-min-height)}.kbx-master-detail__footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-master-detail__footer :deep(.kbx-summary-bar){border-top:0}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxAuditEntry, KbxConflictSnapshot, KbxScreenDefinition, KbxTemplateContext, KbxValidationError, KbxWorkflowDefinition } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
import KbxRecordLifecycle from './KbxRecordLifecycle.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; status?:string; dirty?:boolean; version?:number; errors?:KbxValidationError[]; workflow?:KbxWorkflowDefinition; conflict?:KbxConflictSnapshot|null; auditEntries?:KbxAuditEntry[]; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean }>(), { status:'', dirty:false, errors:()=>[], conflict:null, auditEntries:()=>[], context:null, contentState:'ready', refreshing:false })
const emit = defineEmits<{ command:[string]; transition:[string]; reloadConflict:[]; dismissConflict:[] }>()
</script>
<template>
<KbxScreenFrame expected-type="master" template-code="T02" :screen="screen" :status="status" :dirty="dirty" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template>
<template #notice><slot name="notice" /></template>
<div v-if="errors.length" data-kbx-surface="validation"><KbxValidationSummary :errors="errors" /></div>
<KbxRecordLifecycle v-if="workflow || conflict || auditEntries.length || version!=null" data-kbx-surface="record-lifecycle" :status="status" :version="version" :workflow="workflow" :conflict="conflict" :audit-entries="auditEntries" :can="can" @transition="emit('transition',$event)" @reload-conflict="emit('reloadConflict')" @dismiss-conflict="emit('dismissConflict')" />
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" :idle-action-label="$slots.list?'조회 F3':''" @idle-action="emit('command','search')" @retry="emit('command','search')">
<div class="kbx-master-page__body" :class="{ 'without-list': !$slots.list }">
<aside v-if="$slots.list" class="kbx-master-page__list" data-kbx-surface="master-list"><slot name="list" /></aside>
<main class="kbx-master-page__detail" data-kbx-surface="detail">
<slot name="detail" />
<section v-if="$slots.tabs" class="kbx-master-page__tabs" data-kbx-surface="tabs"><slot name="tabs" /></section>
</main>
</div>
</KbxTemplateStateBoundary>
<footer v-if="$slots.footer" class="kbx-master-page__footer" data-kbx-surface="footer"><slot name="footer" /></footer>
<div v-if="$slots.drawer" data-kbx-surface="drawer"><slot name="drawer" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-master-page__body{display:grid;grid-template-columns:minmax(var(--kbx-master-list-min-width),32%) minmax(0,1fr);gap:var(--kbx-space-2);min-height:0;flex:1}.kbx-master-page__body.without-list{grid-template-columns:minmax(0,1fr)}.kbx-master-page__list,.kbx-master-page__detail{min-height:0;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-master-page__list{overflow:hidden}.kbx-master-page__detail{padding:var(--kbx-space-4);overflow:auto;display:flex;flex-direction:column;gap:var(--kbx-space-3)}.kbx-master-page__tabs{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-master-page__footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}@media(max-width:68.75rem){.kbx-master-page__body{grid-template-columns:var(--kbx-master-list-compact-width) minmax(0,1fr)}}
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
import KbxNumberField from './KbxNumberField.vue'
withDefaults(defineProps<{modelValue?:number|null;label:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;precision?:number;currency?:string;allowNegative?:boolean;zeroAllowed?:boolean}>(),{precision:0,currency:'원',allowNegative:false,zeroAllowed:true,state:'default'})
const emit=defineEmits<{ 'update:modelValue':[number|null] }>()
</script>
<template><KbxNumberField :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="error" :warning="warning" :help-text="helpText" :state="state" :precision="precision" :min="allowNegative?undefined:(zeroAllowed?0:Number.MIN_VALUE)" :suffix="currency" @update:model-value="emit('update:modelValue',$event)" /></template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { KbxUserNotification } from '@kbx/contracts'
defineProps<{ notifications: KbxUserNotification[] }>()
const emit = defineEmits<{ open: [KbxUserNotification]; read: [string] }>()
</script>
<template>
<section class="kbx-notification-center" aria-label="알림 센터">
<button
v-for="item in notifications"
:key="item.id"
type="button"
class="notice"
:class="[`is-${item.severity}`, { unread: !item.readAt }]"
@click="emit('open', item); emit('read', item.id)"
>
<span class="marker" aria-hidden="true" />
<span class="copy"><strong>{{ item.title }}</strong><small v-if="item.message">{{ item.message }}</small></span>
<time>{{ new Date(item.createdAt).toLocaleString('ko-KR') }}</time>
</button>
<p v-if="notifications.length === 0" class="empty"> 알림이 없습니다.</p>
</section>
</template>
<style scoped>
.kbx-notification-center { display:grid; gap:4px; }
.notice { display:grid; grid-template-columns:8px 1fr auto; align-items:start; gap:8px; padding:10px; border:1px solid transparent; background:transparent; text-align:left; cursor:pointer; }
.notice:hover { background:var(--kbx-color-surface-hover); }
.marker { width:6px; height:6px; margin-top:6px; border-radius:50%; background:var(--kbx-color-text-muted); }
.unread .marker { background:var(--kbx-color-primary); }
.copy { display:grid; gap:2px; } small,time,.empty { color:var(--kbx-color-text-muted); font-size:var(--kbx-font-xs); }
time { white-space:nowrap; }
.empty { margin:0; padding:12px; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props = withDefaults(defineProps<{
modelValue?: number | null
label: string
required?: boolean
readonly?: boolean
disabled?: boolean
error?: string
warning?: string
helpText?: string
state?: KbxFieldState
min?: number
max?: number
step?: number
precision?: number
suffix?: string
}>(), { step:1, precision:0, state:'default' })
const emit=defineEmits<{ 'update:modelValue':[number|null]; enter:[] }>()
const uid=`kbx-number-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function parse(raw:string){
if(!raw.trim()){emit('update:modelValue',null);return}
const n=Number(raw.replaceAll(',',''))
if(!Number.isFinite(n))return
const bounded=Math.max(props.min??-Infinity,Math.min(props.max??Infinity,n))
emit('update:modelValue',bounded)
}
function display(v:number|null|undefined){if(v==null)return'';return new Intl.NumberFormat('ko-KR',{minimumFractionDigits:props.precision,maximumFractionDigits:props.precision}).format(v)}
</script>
<template>
<div class="kbx-field" :data-state="effectiveState">
<label :for="uid" class="kbx-field__label">{{label}}<span v-if="required" aria-hidden="true"> *</span></label>
<div class="kbx-number-wrap">
<input :id="uid" class="kbx-number" inputmode="decimal" :value="display(modelValue)" :readonly="readonly" :disabled="disabled" :required="required" :aria-invalid="Boolean(error)" :aria-describedby="message?messageId:undefined" @change="parse(($event.target as HTMLInputElement).value)" @keydown.enter.prevent="emit('enter')">
<span v-if="suffix" class="kbx-number__suffix">{{suffix}}</span>
</div>
<span v-if="message" :id="messageId" class="kbx-field__message" :role="error?'alert':undefined">{{message}}</span>
</div>
</template>
<style scoped>
.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-field__label{padding-top:var(--kbx-space-2);font-weight:500}.kbx-number-wrap{display:flex;align-items:center;gap:var(--kbx-space-2)}.kbx-number{height:var(--kbx-control-height);width:100%;min-width:0;text-align:right;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] .kbx-number{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] .kbx-number{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] .kbx-number{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] .kbx-number{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-number[readonly]{background:var(--kbx-color-surface-muted)}.kbx-number:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-number__suffix{white-space:nowrap;color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);margin-top:calc(var(--kbx-space-1) * -1)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { KbxOperationRun } from '@kbx/contracts'
defineProps<{ operations: KbxOperationRun[] }>()
const emit = defineEmits<{ open: [KbxOperationRun] }>()
function progress(operation: KbxOperationRun) {
if (!operation.total) return null
return Math.min(100, Math.round(((operation.processed ?? 0) / operation.total) * 100))
}
</script>
<template>
<section class="kbx-operation-center" aria-label="작업 센터">
<button v-for="operation in operations" :key="operation.id" type="button" class="operation" @click="emit('open', operation)">
<div class="top"><strong>{{ operation.title }}</strong><span>{{ operation.status }}</span></div>
<div v-if="progress(operation) != null" class="progress"><i :style="{ width: `${progress(operation)}%` }" /></div>
<small v-if="operation.total">{{ operation.processed ?? 0 }} / {{ operation.total }}</small>
<small v-else-if="operation.resultMessage">{{ operation.resultMessage }}</small>
</button>
<p v-if="operations.length === 0" class="empty">진행 중이거나 최근 실행한 작업이 없습니다.</p>
</section>
</template>
<style scoped>
.kbx-operation-center { display:grid; gap:8px; }
.operation { display:grid; gap:6px; padding:10px 12px; text-align:left; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface); cursor:pointer; }
.top { display:flex; justify-content:space-between; gap:8px; } .top span,small,.empty { color:var(--kbx-color-text-muted); }
.progress { height:6px; background:var(--kbx-color-surface-subtle); border-radius:999px; overflow:hidden; }
.progress i { display:block; height:100%; background:var(--kbx-color-primary); }
.empty { margin:0; padding:12px; }
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
breadcrumb?: string
description?: string
status?: string
dirty?: boolean
}>(), { breadcrumb:'', description:'', status:'', dirty:false })
</script>
<template>
<header class="kbx-page-header">
<div class="kbx-page-header__identity">
<div v-if="breadcrumb" class="kbx-page-header__breadcrumb">{{ breadcrumb }}</div>
<div class="kbx-page-header__title-row">
<h1>{{ title }}</h1>
<span v-if="status" class="kbx-page-header__status">{{ status }}</span>
<span v-if="dirty" class="kbx-page-header__dirty">변경됨</span>
</div>
<p v-if="description">{{ description }}</p>
</div>
<div class="kbx-page-header__utility"><slot name="utility" /></div>
</header>
</template>
<style scoped>
.kbx-page-header {
min-height:var(--kbx-page-header-height);
display:flex;
align-items:center;
justify-content:space-between;
gap:var(--kbx-space-4);
padding:var(--kbx-space-1) var(--kbx-screen-inline-padding);
border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);
background:var(--kbx-color-surface);
}
.kbx-page-header__identity { min-width:0; }
.kbx-page-header__title-row { display:flex; align-items:center; gap:var(--kbx-space-2); min-width:0; }
h1 { font-size:var(--kbx-font-2xl); font-weight:600; margin:0; line-height:1.3; }
.kbx-page-header__breadcrumb, p { font-size:var(--kbx-font-xs); color:var(--kbx-color-text-muted); margin:0 0 var(--kbx-space-1); }
.kbx-page-header__status, .kbx-page-header__dirty { font-size:var(--kbx-font-xs); padding:var(--kbx-space-1) var(--kbx-space-2); border:var(--kbx-border-width) solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); white-space:nowrap; }
.kbx-page-header__status { background:var(--kbx-color-surface-muted); }
.kbx-page-header__dirty { color:var(--kbx-color-warning-text); border-color:var(--kbx-color-warning-border); background:var(--kbx-color-warning-surface); }
.kbx-page-header__utility { display:flex; align-items:center; gap:var(--kbx-space-1); flex-shrink:0; }
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
export interface KbxProgressStep { key:string; label:string; optional?:boolean }
const props=defineProps<{steps:KbxProgressStep[]; current:string}>()
const currentIndex=()=>Math.max(0,props.steps.findIndex(step=>step.key===props.current))
</script>
<template>
<ol class="kbx-progress-steps" aria-label="업무 진행 단계" :style="{'--kbx-progress-step-count': String(steps.length)}">
<li v-for="(step,index) in props.steps" :key="step.key" :class="{active:step.key===props.current,done:index<currentIndex()}" :aria-current="step.key===props.current?'step':undefined">
<span class="number" aria-hidden="true">{{ index+1 }}</span>
<span>{{ step.label }}</span>
<small v-if="step.optional">선택</small>
</li>
</ol>
</template>
<style scoped>
.kbx-progress-steps{display:grid;grid-template-columns:repeat(var(--kbx-progress-step-count,4),minmax(0,1fr));list-style:none;margin:0;padding:0;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.kbx-progress-steps li{min-height:var(--kbx-progress-step-height);display:flex;align-items:center;justify-content:center;gap:var(--kbx-space-2);border-right:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}.kbx-progress-steps li:last-child{border-right:0}.kbx-progress-steps li.active{background:var(--kbx-color-surface);color:var(--kbx-color-primary);font-weight:600}.kbx-progress-steps li.done{color:var(--kbx-color-text);font-weight:500}.kbx-progress-steps .number{min-width:var(--kbx-progress-step-number-size);height:var(--kbx-progress-step-number-size);display:grid;place-items:center;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:50%;font-size:var(--kbx-font-xs)}.kbx-progress-steps li.active .number{border-color:var(--kbx-color-primary)}.kbx-progress-steps small{font-size:var(--kbx-font-xs);font-weight:400}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { KbxAiProposal } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
const props=defineProps<{proposal:KbxAiProposal;canApply?:boolean;can?:(permission:string)=>boolean}>()
const emit=defineEmits<{cancel:[];detail:[];apply:[]}>()
const permissionHost=inject(KbxPermissionHostKey,null)
const permissionAllowed=computed(()=>!props.proposal.requiredPermission||(props.can?props.can(props.proposal.requiredPermission):(permissionHost?.has(props.proposal.requiredPermission)??false)))
const validationAllowed=computed(()=>!props.proposal.validation||props.proposal.validation.state==='validated')
const applyDisabled=computed(()=>props.canApply===false||!permissionAllowed.value||!validationAllowed.value)
const guardMessage=computed(()=>{
if(!permissionAllowed.value)return '이 제안을 적용할 권한이 없습니다.'
if(props.proposal.validation?.state==='pending')return props.proposal.validation.message??'서버에서 대상·권한·업무규칙을 확인하고 있습니다.'
if(props.proposal.validation?.state==='invalid')return props.proposal.validation.message??'현재 업무 상태에서는 이 제안을 적용할 수 없습니다.'
if(props.proposal.validation?.state==='stale')return props.proposal.validation.message??'대상 데이터가 변경되었습니다. 최신 기준으로 제안을 다시 확인하세요.'
if(props.canApply===false)return '현재 화면 상태에서는 이 제안을 적용할 수 없습니다.'
return ''
})
function apply(){if(!applyDisabled.value)emit('apply')}
</script>
<template>
<section class="kbx-proposal" aria-label="AI 제안" :data-validation="proposal.validation?.state ?? 'not-provided'">
<header><strong>AI 제안</strong><span v-if="proposal.confidence!=null">신뢰도 {{Math.round(proposal.confidence*100)}}%</span></header>
<div class="kbx-proposal__title"><h3>{{proposal.title}}</h3><small v-if="proposal.targets?.length">대상 {{proposal.targets.length.toLocaleString()}}</small></div>
<p>{{proposal.explanation}}</p>
<dl v-if="proposal.proposedChanges?.length"><template v-for="change in proposal.proposedChanges" :key="`${change.field}:${String(change.before)}:${String(change.after)}`"><dt>{{change.label}}</dt><dd>{{change.before ?? '-'}} {{change.after ?? '-'}}</dd></template></dl>
<div v-if="proposal.evidence?.length" class="kbx-proposal__evidence"><strong>근거</strong><span v-for="evidence in proposal.evidence" :key="`${evidence.sourceType}:${evidence.label}`">{{evidence.label}} · {{evidence.sourceType}}</span></div>
<p v-if="guardMessage" class="kbx-proposal__guard" role="status">{{guardMessage}}</p>
<footer><KbxButton label="취소" @click="emit('cancel')"/><KbxButton label="상세보기" @click="emit('detail')"/><KbxButton label="변경안 적용" variant="primary" :disabled="applyDisabled" :title="guardMessage||undefined" @click="apply"/></footer>
</section>
</template>
<style scoped>
.kbx-proposal{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}header,footer,.kbx-proposal__title{display:flex;align-items:center;gap:var(--kbx-space-2)}header span,.kbx-proposal__title small{margin-left:auto;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}h3,p{margin:0}dl{display:grid;grid-template-columns:calc(var(--kbx-label-width) + var(--kbx-space-6)) 1fr;gap:var(--kbx-space-1) var(--kbx-space-2);margin:var(--kbx-space-1) 0}dt{font-weight:500}dd{margin:0}.kbx-proposal__evidence{display:flex;gap:var(--kbx-space-2);flex-wrap:wrap;font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-proposal__guard{padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.kbx-proposal[data-validation="invalid"] .kbx-proposal__guard,.kbx-proposal[data-validation="stale"] .kbx-proposal__guard{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}footer{justify-content:flex-end}
</style>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
import KbxNumberField from './KbxNumberField.vue'
const props=withDefaults(defineProps<{modelValue?:number|null;label:string;unit?:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;availableQuantity?:number|null;allowNegative?:boolean;precision?:number}>(),{unit:'EA',allowNegative:false,precision:0,state:'default'})
const emit=defineEmits<{ 'update:modelValue':[number|null] }>()
const effectiveError=computed(()=>props.error ?? (props.availableQuantity!=null && props.modelValue!=null && props.modelValue>props.availableQuantity ? `출고 가능 수량은 ${props.availableQuantity}${props.unit}입니다.` : undefined))
</script>
<template><KbxNumberField :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="effectiveError" :warning="warning" :help-text="helpText" :state="state" :precision="precision" :min="allowNegative?undefined:0" :suffix="unit" @update:model-value="emit('update:modelValue',$event)" /></template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxWorkQueueCounter } from '@kbx/contracts'
import KbxExceptionSummary from './KbxExceptionSummary.vue'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{
screen:KbxScreenDefinition
selectionCount?:number
can?:(permission:string)=>boolean
breadcrumb?:string
context?:KbxTemplateContext|null
contentState?:KbxAsyncState
refreshing?:boolean
summaryItems?:KbxSummaryItem[]
exceptionCounters?:KbxWorkQueueCounter[]
activeExceptionKey?:string|null
}>(), { context:null, contentState:'ready', refreshing:false, summaryItems:()=>[], exceptionCounters:()=>[], activeExceptionKey:null })
const emit=defineEmits<{ command:[string]; exceptionFilter:[string|null] }>()
</script>
<template>
<KbxScreenFrame expected-type="queue" template-code="T06" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-queue-page__search" data-kbx-surface="search"><slot name="search" /></div>
<section v-if="$slots.summary || summaryItems.length" class="kbx-queue-page__summary" data-kbx-surface="work-summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" /></slot></section>
<section v-if="$slots.exceptions || exceptionCounters.length" class="kbx-queue-page__exceptions" data-kbx-surface="exception-summary"><slot name="exceptions"><KbxExceptionSummary :counters="exceptionCounters" :active-key="activeExceptionKey" @select="emit('exceptionFilter',$event)" /></slot></section>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<section v-if="$slots.contextual" class="kbx-queue-page__contextual" data-kbx-surface="bulk-action"><slot name="contextual" /></section>
<main class="kbx-queue-page__content" data-kbx-surface="queue/content"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')"><slot name="content" /></KbxTemplateStateBoundary></main>
<footer v-if="$slots.footer" class="kbx-queue-page__footer" data-kbx-surface="footer"><slot name="footer" /></footer>
<div v-if="$slots.detail" data-kbx-surface="detail"><slot name="detail" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-queue-page__summary,.kbx-queue-page__exceptions{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:var(--kbx-space-2)}.kbx-queue-page__summary :deep(.kbx-summary-bar){border-top:0}.kbx-queue-page__content{min-height:var(--kbx-content-min-height);flex:1}.kbx-queue-page__contextual{position:sticky;top:var(--kbx-command-bar-height);z-index:6}.kbx-queue-page__footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { KbxQuickFilterItem } from '@kbx/contracts'
defineProps<{items:KbxQuickFilterItem[];ariaLabel?:string}>();const emit=defineEmits<{ select:[string] }>()
</script>
<template><nav class="kbx-quick-filter" :aria-label="ariaLabel??'빠른 필터'"><button v-for="item in items" :key="item.key" type="button" :class="{active:item.active}" :data-tone="item.tone??'default'" :aria-pressed="item.active??false" @click="emit('select',item.key)"><span>{{item.label}}</span><strong>{{item.count.toLocaleString('ko-KR')}}</strong></button></nav></template>
<style scoped>.kbx-quick-filter{display:flex;align-items:stretch;min-height:var(--kbx-home-attention-height);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-quick-filter button{min-width:6.875rem;display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);border:0;border-right:var(--kbx-border-width) solid var(--kbx-color-border);background:transparent;padding:0 var(--kbx-space-3);text-align:left}.kbx-quick-filter button:last-child{border-right:0}.kbx-quick-filter button:hover,.kbx-quick-filter button.active{background:var(--kbx-color-surface-hover)}.kbx-quick-filter span{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-quick-filter strong{font-size:var(--kbx-font-md)}.kbx-quick-filter button[data-tone="warning"] strong{color:var(--kbx-color-warning-text)}.kbx-quick-filter button[data-tone="danger"] strong{color:var(--kbx-color-danger)}</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
defineProps<{modelValue?:string|number|null;value:string|number;name:string;label:string;disabled?:boolean;state?:KbxFieldState}>()
const emit=defineEmits<{ 'update:modelValue':[string|number] }>()
const uid=`kbx-radio-${Math.random().toString(36).slice(2)}`
</script>
<template><label class="kbx-radio" :for="uid" :data-state="state??'default'"><input :id="uid" type="radio" :name="name" :value="value" :checked="modelValue===value" :disabled="disabled" @change="emit('update:modelValue',value)"><span>{{label}}</span></label></template>
<style scoped>.kbx-radio{min-height:var(--kbx-control-height);display:inline-flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-md)}.kbx-radio input{width:var(--kbx-checkbox-size);height:var(--kbx-checkbox-size);margin:0}.kbx-radio[data-state="changed"] span{color:var(--kbx-color-primary)}.kbx-radio[data-state="warning"] span{color:var(--kbx-color-warning-text)}.kbx-radio[data-state="ai-suggested"] span{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxReconcileSummary, KbxTemplateContext } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; summary?:KbxReconcileSummary; selectionCount?:number; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean }>(), { contentState:'ready', refreshing:false })
const emit=defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="reconcile" template-code="T07" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-reconcile-search" data-kbx-surface="criteria/search"><slot name="search" /></div>
<div v-if="summary" class="kbx-reconcile-summary" aria-label="대사 요약" data-kbx-surface="summary"><span>전체 <strong>{{summary.totalCount.toLocaleString('ko-KR')}}</strong></span><span>정상 <strong>{{summary.matchedCount.toLocaleString('ko-KR')}}</strong></span><span class="mismatch">불일치 <strong>{{summary.mismatchCount.toLocaleString('ko-KR')}}</strong></span><span>확인중 <strong>{{summary.pendingCount.toLocaleString('ko-KR')}}</strong></span><span>해결 <strong>{{summary.resolvedCount.toLocaleString('ko-KR')}}</strong></span></div>
<section v-if="$slots.filters" class="kbx-reconcile-filters" data-kbx-surface="filters"><slot name="filters" /></section>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<section v-if="$slots.resolution" class="kbx-reconcile-resolution" data-kbx-surface="resolution-action"><slot name="resolution" /></section>
<main class="kbx-reconcile-content" data-kbx-surface="comparison-grid"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')"><slot name="content" /></KbxTemplateStateBoundary></main>
<section v-if="$slots.audit" class="kbx-reconcile-audit" data-kbx-surface="audit"><slot name="audit" /></section>
<footer v-if="$slots.footer" class="kbx-reconcile-footer"><slot name="footer" /></footer>
<div v-if="$slots.detail" data-kbx-surface="detail"><slot name="detail" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-reconcile-summary{display:flex;flex-wrap:wrap;gap:var(--kbx-space-4);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-sm)}.kbx-reconcile-summary strong{margin-left:var(--kbx-space-1);font-size:var(--kbx-font-md)}.kbx-reconcile-summary .mismatch strong{color:var(--kbx-color-danger)}.kbx-reconcile-resolution{position:sticky;top:var(--kbx-command-bar-height);z-index:6}.kbx-reconcile-content{min-height:var(--kbx-content-min-height);flex:1}.kbx-reconcile-audit{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-reconcile-footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { KbxAuditEntry, KbxConflictSnapshot, KbxWorkflowDefinition } from '@kbx/contracts'
import KbxAuditTrail from './KbxAuditTrail.vue'
import KbxConflictResolver from './KbxConflictResolver.vue'
import KbxWorkflowBar from './KbxWorkflowBar.vue'
withDefaults(defineProps<{ status:string; version?:number; workflow?:KbxWorkflowDefinition; conflict?:KbxConflictSnapshot|null; auditEntries?:KbxAuditEntry[]; can?:(permission:string)=>boolean }>(), { version:undefined, conflict:null, auditEntries:()=>[] })
const emit=defineEmits<{ transition:[string]; reloadConflict:[]; dismissConflict:[] }>()
</script>
<template>
<section class="kbx-record-lifecycle" aria-label="업무 상태 이력">
<div v-if="version!=null" class="kbx-record-lifecycle__version">Version {{ version }}</div>
<KbxConflictResolver v-if="conflict" :conflict="conflict" @reload="emit('reloadConflict')" @cancel="emit('dismissConflict')" />
<KbxWorkflowBar v-if="workflow" :workflow="workflow" :current="status" :can="can" @transition="emit('transition',$event)" />
<details v-if="auditEntries.length" class="kbx-record-lifecycle__audit">
<summary>변경이력 {{ auditEntries.length }}</summary>
<KbxAuditTrail :entries="auditEntries" />
</details>
</section>
</template>
<style scoped>
.kbx-record-lifecycle{display:grid;gap:var(--kbx-space-2)}
.kbx-record-lifecycle__version{justify-self:end;font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}
.kbx-record-lifecycle__audit{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}
.kbx-record-lifecycle__audit summary{cursor:pointer;font-weight:600;margin-bottom:var(--kbx-space-2)}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { KbxRuntimeNotice } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
defineProps<{ notice: KbxRuntimeNotice | null }>()
const emit = defineEmits<{ retry: [] }>()
</script>
<template>
<aside
v-if="notice && notice.mode !== 'normal'"
class="kbx-runtime-banner"
:class="`is-${notice.mode}`"
role="status"
aria-live="polite"
>
<div class="copy">
<strong>{{ notice.title }}</strong>
<span v-if="notice.message">{{ notice.message }}</span>
<small v-if="notice.correlationId">참조번호 {{ notice.correlationId }}</small>
</div>
<KbxButton v-if="notice.retryAllowed" label="다시 시도" variant="secondary" @click="emit('retry')" />
</aside>
</template>
<style scoped>
.kbx-runtime-banner { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:44px; padding:8px 12px; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface-muted); }
.copy { display:flex; flex-wrap:wrap; align-items:baseline; gap:8px; }
.copy span,.copy small { color:var(--kbx-color-text-muted); }
.is-degraded,.is-offline { border-color:var(--kbx-color-warning); }
.is-read-only { border-color:var(--kbx-color-danger); }
</style>
@@ -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>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { KbxSearchField } from '@kbx/contracts'
import KbxLookup from './KbxLookup.vue'
const props=defineProps<{field:KbxSearchField;model:Record<string,unknown>}>()
const emit=defineEmits<{change:[key:string,value:unknown]}>()
function value(key:string){return props.model[key] as any}
</script>
<template>
<label v-if="field.type==='text'" class="kbx-search-field" :data-width="field.width??'md'"><span>{{field.label}}</span><input :value="value(field.key)??''" :disabled="field.disabled" :placeholder="field.placeholder" @input="emit('change',field.key,($event.target as HTMLInputElement).value)"></label>
<label v-else-if="field.type==='date'" class="kbx-search-field"><span>{{field.label}}</span><input type="date" :value="value(field.key)??''" :disabled="field.disabled" @input="emit('change',field.key,($event.target as HTMLInputElement).value)"></label>
<div v-else-if="field.type==='date-range'&&field.range" class="kbx-search-field kbx-search-field--range"><span>{{field.label}}</span><input type="date" :value="value(field.range.from)??''" :disabled="field.disabled" :aria-label="`${field.label} 시작일`" @input="emit('change',field.range!.from,($event.target as HTMLInputElement).value)"><b aria-hidden="true">~</b><input type="date" :value="value(field.range.to)??''" :disabled="field.disabled" :aria-label="`${field.label} 종료일`" @input="emit('change',field.range!.to,($event.target as HTMLInputElement).value)"></div>
<label v-else-if="field.type==='select'" class="kbx-search-field"><span>{{field.label}}</span><select :value="value(field.key)??''" :disabled="field.disabled" @change="emit('change',field.key,($event.target as HTMLSelectElement).value||null)"><option value="">전체</option><option v-for="option in field.options??[]" :key="option.value" :value="option.value">{{option.label}}</option></select></label>
<label v-else-if="field.type==='checkbox'" class="kbx-search-check"><input type="checkbox" :checked="Boolean(value(field.key))" :disabled="field.disabled" @change="emit('change',field.key,($event.target as HTMLInputElement).checked)"><span>{{field.label}}</span></label>
<KbxLookup v-else-if="field.type==='lookup'&&field.lookup" :model-value="(value(field.key) as string|null)??null" :entity="field.lookup.entity" :label="field.label" :disabled="field.disabled" @update:model-value="emit('change',field.key,$event)" />
</template>
<style scoped>
.kbx-search-field{display:flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-sm);white-space:nowrap}.kbx-search-field>span{font-weight:500}.kbx-search-field input,.kbx-search-field select{height:var(--kbx-control-height);min-width:var(--kbx-search-control-min-width);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);background:var(--kbx-color-surface);font:inherit}.kbx-search-field[data-width="sm"] input{width:var(--kbx-search-control-sm-width)}.kbx-search-field[data-width="lg"] input{width:var(--kbx-search-control-lg-width)}.kbx-search-field--range b{font-weight:400;color:var(--kbx-color-text-muted)}.kbx-search-check{display:inline-flex;align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);font-size:var(--kbx-font-sm)}.kbx-search-check input{width:var(--kbx-checkbox-size);height:var(--kbx-checkbox-size);margin:0}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxSearchField } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxSearchFieldControl from './KbxSearchFieldControl.vue'
const props = withDefaults(defineProps<{
modelValue: Record<string, unknown>
fields: KbxSearchField[]
remember?: boolean
rememberChecked?: boolean
savedSearch?: boolean
searchLabel?: string
}>(), { remember:false, rememberChecked:false, savedSearch:false, searchLabel:'조회' })
const emit = defineEmits<{
'update:modelValue':[Record<string, unknown>]
'update:rememberChecked':[boolean]
search:[]
reset:[]
savedSearchRequested:[]
}>()
const primary = computed(() => props.fields.filter(x => x.primary !== false))
const secondary = computed(() => props.fields.filter(x => x.primary === false))
const showMore = computed(() => secondary.value.length > 0)
const expanded = ref(false)
const activeSecondaryCount = computed(() => secondary.value.filter(isActive).length)
function isActive(field:KbxSearchField){
if(field.type==='date-range'&&field.range)return Boolean(props.modelValue[field.range.from]||props.modelValue[field.range.to])
const current=props.modelValue[field.key]
return current!==null&&current!==undefined&&current!==''&&current!==false
}
function setValue(key:string,value:unknown){emit('update:modelValue',{...props.modelValue,[key]:value})}
function reset(){
const next={...props.modelValue}
for(const field of props.fields){
if(field.type==='date-range'&&field.range){next[field.range.from]=null;next[field.range.to]=null;continue}
next[field.key]=field.defaultValue??(field.type==='checkbox'?false:null)
}
emit('update:modelValue',next);emit('reset')
}
</script>
<template>
<form class="kbx-search-panel" role="search" @submit.prevent="emit('search')">
<div class="kbx-search-panel__fields">
<KbxSearchFieldControl v-for="field in primary" :key="field.key" :field="field" :model="modelValue" @change="setValue" />
</div>
<div class="kbx-search-panel__actions">
<KbxButton type="submit" :label="searchLabel" variant="primary" />
<KbxButton v-if="showMore" :label="activeSecondaryCount?`상세조건 ${activeSecondaryCount}`:'상세조건'" :aria-expanded="expanded" @click="expanded=!expanded" />
<KbxButton label="초기화" variant="ghost" @click="reset" />
<KbxButton v-if="savedSearch" label="조건저장" variant="ghost" @click="emit('savedSearchRequested')" />
</div>
<div v-if="expanded&&secondary.length" class="kbx-search-panel__secondary">
<KbxSearchFieldControl v-for="field in secondary" :key="field.key" :field="field" :model="modelValue" @change="setValue" />
<label v-if="remember" class="kbx-search-remember"><input type="checkbox" :checked="rememberChecked" @change="emit('update:rememberChecked',($event.target as HTMLInputElement).checked)"><span>마지막 조회조건 기억</span></label>
</div>
</form>
</template>
<style scoped>
.kbx-search-panel{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--kbx-space-2) var(--kbx-space-3);padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.kbx-search-panel__fields,.kbx-search-panel__secondary{display:flex;align-items:center;gap:var(--kbx-space-2) var(--kbx-space-4);flex-wrap:wrap}.kbx-search-panel__secondary{grid-column:1/-1;padding-top:var(--kbx-space-2);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-search-panel__actions{display:flex;align-items:flex-start;gap:var(--kbx-space-1);white-space:nowrap}.kbx-search-remember{display:inline-flex;align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);font-size:var(--kbx-font-sm);margin-left:auto;color:var(--kbx-color-text-muted)}.kbx-search-remember input{width:var(--kbx-checkbox-size);height:var(--kbx-checkbox-size);margin:0}@media(max-width:64rem){.kbx-search-panel{grid-template-columns:1fr}.kbx-search-panel__actions{justify-content:flex-end}.kbx-search-panel__secondary{grid-column:1}}
</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
defineProps<{title:string;count?:number;description?:string}>()
</script>
<template><header class="kbx-section-header"><div><h2>{{title}}<span v-if="count!=null"> {{count.toLocaleString('ko-KR')}}</span></h2><p v-if="description">{{description}}</p></div><div class="kbx-section-header__actions"><slot name="actions"/></div></header></template>
<style scoped>.kbx-section-header{min-height:var(--kbx-control-md);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-section-header h2{margin:0;font-size:var(--kbx-font-lg);font-weight:600}.kbx-section-header h2 span{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);font-weight:500}.kbx-section-header p{margin:var(--kbx-space-1) 0 0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-section-header__actions{display:flex;gap:var(--kbx-space-1)}</style>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
export interface KbxSelectOption { value:string; label:string; disabled?:boolean }
const props=withDefaults(defineProps<{modelValue?:string|null;label:string;options:KbxSelectOption[];required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;emptyLabel?:string}>(),{state:'default',emptyLabel:'선택'})
const emit=defineEmits<{ 'update:modelValue':[string|null] }>()
const uid=`kbx-select-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function update(event:Event){if(props.readonly)return;emit('update:modelValue',($eventTarget(event)).value||null)}
function $eventTarget(event:Event){return event.target as HTMLSelectElement}
</script>
<template><div class="kbx-field" :data-state="effectiveState"><label :for="uid">{{label}}<span v-if="required" aria-hidden="true"> *</span></label><select :id="uid" :value="modelValue??''" :disabled="disabled" :aria-disabled="disabled||undefined" :aria-readonly="readonly||undefined" :required="required" :aria-invalid="Boolean(error)" :aria-describedby="message?messageId:undefined" @change="update"><option value="">{{emptyLabel}}</option><option v-for="option in options" :key="option.value" :value="option.value" :disabled="option.disabled">{{option.label}}</option></select><span v-if="message" :id="messageId" class="kbx-field__message" :role="error?'alert':undefined">{{message}}</span></div></template>
<style scoped>.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:center;font-size:var(--kbx-font-md)}.kbx-field label{font-weight:500}.kbx-field select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);background:var(--kbx-color-surface);font:inherit;color:var(--kbx-color-text)}.kbx-field[data-state="changed"] select{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] select{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] select{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] select{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-field select[aria-readonly="true"]{pointer-events:none;background:var(--kbx-color-surface-muted)}.kbx-field select:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { KbxStatusSemantic } from '@kbx/contracts'
defineProps<{
label: string
semantic: KbxStatusSemantic
}>()
</script>
<template>
<span class="kbx-status" :data-semantic="semantic">
<span class="kbx-status__dot" aria-hidden="true" />
{{ label }}
</span>
</template>
<style scoped>
.kbx-status { display:inline-flex; align-items:center; gap:6px; min-height:24px; padding:0 8px; border:1px solid var(--kbx-color-border); border-radius:999px; background:var(--kbx-color-surface); font-size:12px; font-weight:600; white-space:nowrap; }
.kbx-status__dot { width:6px; height:6px; border-radius:50%; background:var(--kbx-status-color, var(--kbx-color-text-muted)); }
.kbx-status[data-semantic="completed"] { --kbx-status-color:var(--kbx-color-success); }
.kbx-status[data-semantic="processing"], .kbx-status[data-semantic="pending"] { --kbx-status-color:var(--kbx-color-primary); }
.kbx-status[data-semantic="warning"], .kbx-status[data-semantic="hold"] { --kbx-status-color:var(--kbx-color-warning); }
.kbx-status[data-semantic="error"] { --kbx-status-color:var(--kbx-color-danger); }
.kbx-status[data-semantic="cancelled"], .kbx-status[data-semantic="disabled"] { --kbx-status-color:var(--kbx-color-text-muted); color:var(--kbx-color-text-muted); }
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxSummaryItem } from '@kbx/contracts'
defineProps<{items:KbxSummaryItem[];align?:'start'|'end'}>()
function display(v:string|number){return typeof v==='number'?v.toLocaleString('ko-KR'):v}
</script>
<template><footer class="kbx-summary-bar" :data-align="align??'start'"><span v-for="item in items" :key="item.key"><small>{{item.label}}</small><strong :class="{emphasis:item.emphasis}">{{display(item.value)}}</strong></span></footer></template>
<style scoped>.kbx-summary-bar{min-height:var(--kbx-control-height);display:flex;align-items:center;gap:var(--kbx-space-5);padding:0 var(--kbx-space-2);border-top:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm)}.kbx-summary-bar[data-align="end"]{justify-content:flex-end}.kbx-summary-bar span{display:flex;align-items:center;gap:var(--kbx-space-1)}.kbx-summary-bar small{color:var(--kbx-color-text-muted)}.kbx-summary-bar strong{font-weight:500}.kbx-summary-bar strong.emphasis{font-weight:700;color:var(--kbx-color-primary)}</style>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
export interface KbxTabItem{key:string;label:string;disabled?:boolean;badge?:string|number}
const props=defineProps<{modelValue:string;items:KbxTabItem[];ariaLabel?:string}>()
const emit=defineEmits<{ 'update:modelValue':[string] }>()
const tablist=ref<HTMLElement|null>(null)
function enabledItems(){return props.items.filter(x=>!x.disabled)}
function activate(key:string){emit('update:modelValue',key);nextTick(()=>focusKey(key))}
function focusKey(key:string){tablist.value?.querySelector<HTMLElement>(`[data-tab-key="${CSS.escape(key)}"]`)?.focus()}
function move(event:KeyboardEvent,key:string){
const items=enabledItems();const index=items.findIndex(x=>x.key===key);if(index<0)return
let target=index
if(event.key==='ArrowRight')target=(index+1)%items.length
else if(event.key==='ArrowLeft')target=(index-1+items.length)%items.length
else if(event.key==='Home')target=0
else if(event.key==='End')target=items.length-1
else return
event.preventDefault();activate(items[target].key)
}
</script>
<template><div class="kbx-tabs"><nav ref="tablist" role="tablist" :aria-label="ariaLabel??'탭'"><button v-for="item in items" :id="`kbx-tab-${item.key}`" :key="item.key" type="button" role="tab" :data-tab-key="item.key" :aria-selected="modelValue===item.key" :aria-controls="`kbx-panel-${item.key}`" :tabindex="modelValue===item.key?0:-1" :disabled="item.disabled" @click="activate(item.key)" @keydown="move($event,item.key)"><span>{{item.label}}</span><b v-if="item.badge!=null">{{item.badge}}</b></button></nav><div :id="`kbx-panel-${modelValue}`" class="kbx-tabs__panel" role="tabpanel" :aria-labelledby="`kbx-tab-${modelValue}`"><slot :active-key="modelValue"/></div></div></template>
<style scoped>.kbx-tabs>nav{display:flex;align-items:end;gap:var(--kbx-border-width);min-height:var(--kbx-control-lg);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-tabs>nav button{height:var(--kbx-control-md);border:var(--kbx-border-width) solid transparent;border-bottom:0;background:transparent;padding:0 var(--kbx-space-3);color:var(--kbx-color-text-muted)}.kbx-tabs>nav button[aria-selected="true"]{border-color:var(--kbx-color-border);background:var(--kbx-color-surface);color:var(--kbx-color-text);font-weight:600}.kbx-tabs>nav b{margin-left:var(--kbx-space-1);font-size:var(--kbx-font-xs);color:var(--kbx-color-primary)}.kbx-tabs__panel{padding-top:var(--kbx-space-2)}</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { KbxTemplateContext } from '@kbx/contracts'
const props = withDefaults(defineProps<{
context?: KbxTemplateContext | null
ariaLabel?: string
}>(), { context:null, ariaLabel:'화면 작업 문맥' })
function display(value:string|number){ return typeof value === 'number' ? value.toLocaleString('ko-KR') : value }
function time(value?:string){
if(!value) return ''
const date=new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'})
}
</script>
<template>
<section v-if="props.context" class="kbx-template-context" :aria-label="props.ariaLabel">
<div v-if="context?.label || context?.hint" class="kbx-template-context__identity">
<strong v-if="context?.label">{{ context.label }}</strong>
<span v-if="context?.hint">{{ context.hint }}</span>
</div>
<div v-if="context?.metrics?.length" class="kbx-template-context__metrics">
<span v-for="metric in context.metrics" :key="metric.key" :data-tone="metric.tone ?? 'default'">
<small>{{ metric.label }}</small>
<strong :class="{ emphasis:metric.emphasis }">{{ display(metric.value) }}</strong>
</span>
</div>
<time v-if="context?.updatedAt" :datetime="context.updatedAt">기준 {{ time(context.updatedAt) }}</time>
<slot />
</section>
</template>
<style scoped>
.kbx-template-context{min-height:var(--kbx-template-context-min-height);display:flex;align-items:center;gap:var(--kbx-space-4);padding:var(--kbx-space-1) var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-sm);overflow-x:auto}.kbx-template-context__identity{display:flex;align-items:center;gap:var(--kbx-space-2);min-width:0}.kbx-template-context__identity strong{white-space:nowrap}.kbx-template-context__identity span{color:var(--kbx-color-text-muted);white-space:nowrap}.kbx-template-context__metrics{display:flex;align-items:center;gap:var(--kbx-space-4);margin-left:auto}.kbx-template-context__metrics>span{display:flex;align-items:center;gap:var(--kbx-space-1);white-space:nowrap}.kbx-template-context__metrics small{color:var(--kbx-color-text-muted)}.kbx-template-context__metrics strong{font-weight:500}.kbx-template-context__metrics strong.emphasis{font-weight:700}.kbx-template-context__metrics span[data-tone="info"] strong{color:var(--kbx-color-primary)}.kbx-template-context__metrics span[data-tone="success"] strong{color:var(--kbx-color-success)}.kbx-template-context__metrics span[data-tone="warning"] strong{color:var(--kbx-color-warning-text)}.kbx-template-context__metrics span[data-tone="danger"] strong{color:var(--kbx-color-danger)}.kbx-template-context time{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);white-space:nowrap}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { KbxAsyncState } from '@kbx/contracts'
import KbxDataState from './KbxDataState.vue'
withDefaults(defineProps<{
state?: KbxAsyncState
refreshing?: boolean
idleTitle?: string
idleDetail?: string
idleActionLabel?: string
emptyTitle?: string
emptyDetail?: string
errorTitle?: string
errorDetail?: string
retryLabel?: string
}>(), {
state:'ready', refreshing:false,
idleTitle:'조회 전입니다.', idleDetail:'조회조건을 확인한 후 조회하세요.', idleActionLabel:'',
emptyTitle:'', emptyDetail:'', errorTitle:'', errorDetail:'', retryLabel:'다시 조회',
})
const emit=defineEmits<{ retry:[]; idleAction:[] }>()
</script>
<template>
<div class="kbx-template-state" :data-template-state="state" :aria-busy="state==='loading' || refreshing">
<KbxDataState v-if="state==='idle'" state="idle" :title="idleTitle" :detail="idleDetail" :action-label="idleActionLabel" @action="emit('idleAction')" />
<KbxDataState v-else-if="state==='loading'" state="loading" />
<KbxDataState v-else-if="state==='empty'" state="empty" :title="emptyTitle" :detail="emptyDetail" />
<KbxDataState v-else-if="state==='error'" state="error" :title="errorTitle" :detail="errorDetail" :action-label="retryLabel" @action="emit('retry')" />
<template v-else>
<div v-if="refreshing" class="kbx-template-state__refresh" role="status" aria-live="polite">최신 내용을 조회 중입니다. 현재 화면은 유지됩니다.</div>
<slot />
</template>
</div>
</template>
<style scoped>
.kbx-template-state{min-height:0;height:100%;display:flex;flex-direction:column}.kbx-template-state__refresh{min-height:var(--kbx-template-refresh-height);display:flex;align-items:center;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-info-border);background:var(--kbx-color-info-surface);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
import KbxInput from './KbxInput.vue'
withDefaults(defineProps<{modelValue?:string|null;label:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;placeholder?:string;maxlength?:number}>(),{modelValue:'',state:'default'})
const emit=defineEmits<{ 'update:modelValue':[string]; enter:[] }>()
</script>
<template><KbxInput :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="error" :warning="warning" :help-text="helpText" :state="state" :placeholder="placeholder" :maxlength="maxlength" @update:model-value="emit('update:modelValue',$event)" @enter="emit('enter')"/></template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{modelValue?:string|null;label:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;placeholder?:string;rows?:number;maxlength?:number}>(),{modelValue:'',rows:3,state:'default'})
const emit=defineEmits<{ 'update:modelValue':[string] }>()
const uid=`kbx-textarea-${Math.random().toString(36).slice(2)}`;const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
</script>
<template><div class="kbx-field" :data-state="effectiveState"><label :for="uid">{{label}}<span v-if="required" aria-hidden="true"> *</span></label><textarea :id="uid" :value="modelValue??''" :required="required" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" :rows="rows" :maxlength="maxlength" :aria-invalid="Boolean(error)" :aria-describedby="message?messageId:undefined" @input="emit('update:modelValue',($event.target as HTMLTextAreaElement).value)"/><span v-if="message" :id="messageId" class="kbx-field__message" :role="error?'alert':undefined">{{message}}</span></div></template>
<style scoped>.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-field label{padding-top:var(--kbx-space-2);font-weight:500}.kbx-field textarea{min-height:var(--kbx-textarea-min-height);resize:vertical;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:var(--kbx-space-2) var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] textarea{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] textarea{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] textarea{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] textarea{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-field textarea[readonly]{background:var(--kbx-color-surface-muted)}.kbx-field textarea:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
withDefaults(defineProps<{message:string;kind?:'success'|'info'|'warning';visible?:boolean}>(),{kind:'success',visible:true})
</script>
<template><div v-if="visible" class="kbx-toast" :data-kind="kind" role="status" aria-live="polite">{{message}}</div></template>
<style scoped>.kbx-toast{display:inline-flex;max-width:420px;padding:8px 12px;border:1px solid var(--kbx-color-border);border-left:3px solid var(--kbx-color-success);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);box-shadow:0 4px 12px rgba(16,24,40,.10);font-size:var(--kbx-font-sm)}.kbx-toast[data-kind="warning"]{border-left-color:var(--kbx-color-warning)}.kbx-toast[data-kind="info"]{border-left-color:var(--kbx-color-primary)}</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
defineProps<{text:string}>();const uid=`kbx-tooltip-${Math.random().toString(36).slice(2)}`
</script>
<template><span class="kbx-tooltip" tabindex="0" :aria-describedby="uid"><slot/><span :id="uid" class="kbx-tooltip__bubble" role="tooltip">{{text}}</span></span></template>
<style scoped>.kbx-tooltip{position:relative;display:inline-flex}.kbx-tooltip__bubble{position:absolute;z-index:40;left:50%;bottom:calc(100% + var(--kbx-space-2));transform:translateX(-50%);width:max-content;max-width:var(--kbx-tooltip-max-width);padding:var(--kbx-space-1) var(--kbx-space-2);border-radius:var(--kbx-radius-sm);background:var(--kbx-gray-900);color:white;font-size:var(--kbx-font-xs);line-height:1.4;opacity:0;pointer-events:none}.kbx-tooltip:hover .kbx-tooltip__bubble,.kbx-tooltip:focus-visible .kbx-tooltip__bubble,.kbx-tooltip:focus-within .kbx-tooltip__bubble{opacity:1}</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxAuditEntry, KbxConflictSnapshot, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxValidationError, KbxWorkflowDefinition } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
import KbxRecordLifecycle from './KbxRecordLifecycle.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; status?:string; dirty?:boolean; version?:number; errors?:KbxValidationError[]; workflow?:KbxWorkflowDefinition; conflict?:KbxConflictSnapshot|null; auditEntries?:KbxAuditEntry[]; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean; summaryItems?:KbxSummaryItem[] }>(), { errors:()=>[], status:'', dirty:false, conflict:null, auditEntries:()=>[], context:null, contentState:'ready', refreshing:false, summaryItems:()=>[] })
const emit = defineEmits<{ command:[string]; transition:[string]; reloadConflict:[]; dismissConflict:[] }>()
</script>
<template>
<KbxScreenFrame expected-type="transaction" template-code="T03" :screen="screen" :status="status" :dirty="dirty" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template>
<template #notice><slot name="notice" /></template>
<div v-if="errors.length" data-kbx-surface="validation"><KbxValidationSummary :errors="errors" /></div>
<KbxRecordLifecycle v-if="workflow || conflict || auditEntries.length || version!=null" data-kbx-surface="record-lifecycle" :status="status" :version="version" :workflow="workflow" :conflict="conflict" :audit-entries="auditEntries" :can="can" @transition="emit('transition',$event)" @reload-conflict="emit('reloadConflict')" @dismiss-conflict="emit('dismissConflict')" />
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" @retry="emit('command','reload')">
<section class="kbx-transaction-page__header" aria-label="거래 기본정보" data-kbx-surface="header-form"><slot name="header" /></section>
<section class="kbx-transaction-page__detail" aria-label="거래 상세" data-kbx-surface="detail-grid"><slot name="detail" /></section>
<section v-if="$slots.workflow" class="kbx-transaction-page__workflow" data-kbx-surface="workflow"><slot name="workflow" /></section>
<section v-if="$slots.summary || summaryItems.length" class="kbx-transaction-page__summary" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" align="end" /></slot></section>
<section v-if="$slots.audit" class="kbx-transaction-page__audit" data-kbx-surface="audit"><slot name="audit" /></section>
</KbxTemplateStateBoundary>
<div v-if="$slots.drawer" data-kbx-surface="drawer"><slot name="drawer" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-transaction-page__header{display:flex;flex-direction:column;gap:var(--kbx-space-3)}.kbx-transaction-page__detail{min-height:var(--kbx-master-list-compact-width)}.kbx-transaction-page__workflow{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-transaction-page__summary{position:sticky;bottom:0;z-index:5;background:var(--kbx-color-surface);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-transaction-page__summary :deep(.kbx-summary-bar){border-top:0}.kbx-transaction-page__audit{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxSuggestionCategory, KbxSuggestionContext, KbxSuggestionRequest } from '@kbx/contracts'
const props = defineProps<{
context: KbxSuggestionContext
submitting?: boolean
}>()
const emit = defineEmits<{
submit: [request: KbxSuggestionRequest]
}>()
const category = ref<KbxSuggestionCategory>('inconvenience')
const message = ref('')
const includeScreenContext = ref(true)
const canSubmit = computed(() => message.value.trim().length >= 3 && !props.submitting)
function submit() {
if (!canSubmit.value) return
emit('submit', {
category: category.value,
message: message.value.trim(),
includeScreenContext: includeScreenContext.value,
context: props.context,
})
}
</script>
<template>
<section class="kbx-suggestion" aria-labelledby="kbx-suggestion-title">
<h2 id="kbx-suggestion-title"> 화면에 의견 보내기</h2>
<fieldset>
<legend>의견 유형</legend>
<label><input v-model="category" type="radio" value="inconvenience"> 불편해요</label>
<label><input v-model="category" type="radio" value="bug"> 오류 같아요</label>
<label><input v-model="category" type="radio" value="improvement"> 개선 제안</label>
</fieldset>
<label class="kbx-suggestion__message">
<span>내용</span>
<textarea v-model="message" rows="6" maxlength="2000" placeholder="어떤 작업에서 무엇이 불편했는지 적어주세요." />
</label>
<label class="kbx-suggestion__context">
<input v-model="includeScreenContext" type="checkbox">
현재 화면 정보 포함
</label>
<p class="kbx-suggestion__note">화면 ID·버전·역할·필터 같은 진단 정보만 포함하고 업무 원문 데이터는 기본 첨부하지 않습니다.</p>
<button type="button" :disabled="!canSubmit" @click="submit">
{{ submitting ? '보내는 중...' : '보내기' }}
</button>
</section>
</template>
<style scoped>
.kbx-suggestion { display:grid; gap:14px; padding:16px; font-size:14px; }
h2 { margin:0; font-size:18px; }
fieldset { display:flex; gap:16px; border:0; padding:0; margin:0; }
legend { margin-bottom:8px; font-weight:600; }
.kbx-suggestion__message { display:grid; gap:6px; }
textarea { resize:vertical; min-height:120px; padding:10px; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); font:inherit; }
.kbx-suggestion__context { display:flex; gap:8px; align-items:center; }
.kbx-suggestion__note { margin:0; color:var(--kbx-color-text-muted); font-size:12px; line-height:1.45; }
button { justify-self:end; min-height:34px; padding:0 16px; border:1px solid var(--kbx-color-primary); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-primary); color:white; font-weight:600; }
button:disabled { opacity:.45; cursor:not-allowed; }
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { KbxAiAnswer, KbxAiScreenContext, KbxHelpContent, KbxSuggestionContext, KbxSuggestionRequest } from '@kbx/contracts'
import KbxHelpPanel from './KbxHelpPanel.vue'
import KbxAiAssistant from './KbxAiAssistant.vue'
import KbxUserSuggestionPanel from './KbxUserSuggestionPanel.vue'
type UtilityTab = 'help' | 'ai' | 'suggestion'
const props = defineProps<{
help?: KbxHelpContent
aiContext?: KbxAiScreenContext
aiAnswer?: KbxAiAnswer | null
aiLoading?: boolean
aiError?: string
aiCurrentScreenLabel?: string
aiQuickQuestions?: string[]
suggestionContext?: KbxSuggestionContext
suggestionSubmitting?: boolean
openTab?: UtilityTab | null
triggerless?: boolean
}>()
const emit = defineEmits<{
aiAsk: [question: string]
aiAction: [actionId: string]
aiOpenProposal: []
suggestionSubmit: [request: KbxSuggestionRequest]
close: []
}>()
const open = ref(false)
const active = ref<UtilityTab>('help')
const availableTabs = computed(() => [
props.help && 'help',
props.aiContext && 'ai',
props.suggestionContext && 'suggestion',
].filter(Boolean) as UtilityTab[])
function show(tab: UtilityTab) { active.value = tab; open.value = true }
function close(){ open.value=false; emit('close') }
watch(() => props.openTab, tab => { if(tab)show(tab); else if(props.triggerless)open.value=false }, { immediate:true })
</script>
<template>
<div class="kbx-utility-rail">
<nav v-if="!triggerless" class="kbx-utility-rail__buttons" aria-label="화면 보조기능">
<button v-if="help" type="button" @click="show('help')">도움말</button>
<button v-if="aiContext" type="button" @click="show('ai')">AI</button>
<button v-if="suggestionContext" type="button" @click="show('suggestion')">제안</button>
</nav>
<div v-if="open" class="kbx-utility-rail__backdrop" @click.self="close">
<aside class="kbx-utility-rail__panel" role="dialog" aria-modal="false" aria-label="화면 보조기능">
<header class="kbx-utility-rail__header">
<nav>
<button v-for="tab in availableTabs" :key="tab" type="button" :aria-current="active === tab ? 'page' : undefined" @click="active = tab">
{{ tab === 'help' ? '도움말' : tab === 'ai' ? 'AI' : '제안' }}
</button>
</nav>
<button type="button" aria-label="닫기" @click="close">×</button>
</header>
<KbxHelpPanel v-if="active === 'help' && help" :content="help" />
<KbxAiAssistant
v-else-if="active === 'ai' && aiContext"
:context="aiContext"
:answer="aiAnswer"
:loading="aiLoading"
:error="aiError"
:current-screen-label="aiCurrentScreenLabel"
:quick-questions="aiQuickQuestions"
@ask="emit('aiAsk', $event)"
@action="emit('aiAction', $event)"
@open-proposal="emit('aiOpenProposal')"
/>
<KbxUserSuggestionPanel
v-else-if="active === 'suggestion' && suggestionContext"
:context="suggestionContext"
:submitting="suggestionSubmitting"
@submit="emit('suggestionSubmit', $event)"
/>
</aside>
</div>
</div>
</template>
<style scoped>
.kbx-utility-rail__buttons{display:flex;align-items:center;gap:var(--kbx-space-1)}
.kbx-utility-rail__buttons button,.kbx-utility-rail__header button{min-height:var(--kbx-control-sm);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)}
.kbx-utility-rail__backdrop{position:fixed;inset:0;z-index:1000;background:var(--kbx-color-overlay);display:flex;justify-content:flex-end}
.kbx-utility-rail__panel{width:min(var(--kbx-utility-panel-width),94vw);height:100%;overflow:auto;background:var(--kbx-color-surface);border-left:var(--kbx-border-width) solid var(--kbx-color-border);box-shadow:var(--kbx-shadow-overlay)}
.kbx-utility-rail__header{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}
.kbx-utility-rail__header nav{display:flex;gap:var(--kbx-space-1)}
.kbx-utility-rail__header [aria-current="page"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}
</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { KbxValidationError } from '@kbx/contracts'
defineProps<{ errors: KbxValidationError[] }>()
</script>
<template>
<div v-if="errors.length" class="kbx-validation-summary" role="alert">
<strong>저장할 없습니다. {{ errors.length }} 항목을 확인하세요.</strong>
<ul><li v-for="(error, i) in errors.slice(0,5)" :key="`${error.field}-${error.rowKey}-${i}`">{{ error.message }}</li></ul>
</div>
</template>
<style scoped>
.kbx-validation-summary{border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface);padding:var(--kbx-space-2) var(--kbx-space-3);border-radius:var(--kbx-radius-sm);font-size:var(--kbx-font-sm);color:var(--kbx-color-text)}
.kbx-validation-summary strong{color:var(--kbx-color-danger)}.kbx-validation-summary ul{margin:var(--kbx-space-1) 0 0 var(--kbx-space-5);padding:0}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxWorkQueueCounter } from '@kbx/contracts'
import KbxQueuePage from './KbxQueuePage.vue'
withDefaults(defineProps<{
screen:KbxScreenDefinition
selectionCount?:number
can?:(permission:string)=>boolean
breadcrumb?:string
context?:KbxTemplateContext|null
contentState?:KbxAsyncState
refreshing?:boolean
summaryItems?:KbxSummaryItem[]
exceptionCounters?:KbxWorkQueueCounter[]
activeExceptionKey?:string|null
}>(), { context:null, contentState:'ready', refreshing:false, summaryItems:()=>[], exceptionCounters:()=>[], activeExceptionKey:null })
const emit=defineEmits<{ command:[string]; exceptionFilter:[string|null] }>()
</script>
<template>
<KbxQueuePage :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :context="context" :content-state="contentState" :refreshing="refreshing" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" @command="emit('command',$event)" @exception-filter="emit('exceptionFilter',$event)">
<template v-if="$slots.utility" #utility><slot name="utility" /></template>
<template v-if="$slots.notice" #notice><slot name="notice" /></template>
<template v-if="$slots.search" #search><slot name="search" /></template>
<template v-if="$slots['queue-summary'] || $slots.summary" #summary><slot name="queue-summary"><slot name="summary" /></slot></template>
<template v-if="$slots.exceptions" #exceptions><slot name="exceptions" /></template>
<template v-if="$slots.contextual" #contextual><slot name="contextual" /></template>
<template #content><slot name="content" /></template>
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
<template v-if="$slots.detail" #detail><slot name="detail" /></template>
</KbxQueuePage>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { KbxWorkflowDefinition, KbxWorkflowTransitionDefinition } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxConfirm from './KbxConfirm.vue'
import KbxStatus from './KbxStatus.vue'
import { KbxPermissionHostKey } from '../permission/host'
const props=defineProps<{workflow:KbxWorkflowDefinition;current:string;can?:(permission:string)=>boolean}>();const emit=defineEmits<{transition:[string]}>();const pending=ref<KbxWorkflowTransitionDefinition|null>(null);const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
const currentState=computed(()=>props.workflow.states.find(x=>x.value===props.current));const actions=computed(()=>props.workflow.transitions.filter(x=>x.from.includes(props.current)&&(!x.permission||canPermission(x.permission))))
function invoke(action:KbxWorkflowTransitionDefinition){if(action.confirm){pending.value=action;return}emit('transition',action.id)}function confirm(){if(!pending.value)return;const id=pending.value.id;pending.value=null;emit('transition',id)}
</script>
<template><section class="kbx-workflow-bar" aria-label="업무 상태"><div class="kbx-workflow-bar__state"><span class="label">현재 상태</span><KbxStatus v-if="currentState" :label="currentState.label" :semantic="currentState.semantic"/><strong v-else>{{current}}</strong></div><div class="kbx-workflow-bar__flow" aria-label="상태 흐름"><template v-for="(state,i) in workflow.states" :key="state.value"><span :class="['step',{current:state.value===current}]">{{state.label}}</span><span v-if="i<workflow.states.length-1" class="arrow">→</span></template></div><div class="kbx-workflow-bar__actions"><KbxButton v-for="action in actions" :key="action.id" :label="action.label" @click="invoke(action)"/></div><KbxConfirm v-if="pending" :open="Boolean(pending)" :title="`${pending.label}하시겠습니까?`" :detail="`${currentState?.label??current} 상태에서 ${workflow.states.find(x=>x.value===pending?.to)?.label??pending.to} 상태로 변경합니다.`" level="high" :confirm-label="pending.label" @update:open="value=>{if(!value)pending=null}" @confirm="confirm"/></section></template>
<style scoped>.kbx-workflow-bar{display:grid;grid-template-columns:auto 1fr auto;gap:var(--kbx-space-3);align-items:center;min-height:var(--kbx-command-bar-height);padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);border-radius:var(--kbx-radius-sm);font-size:var(--kbx-font-sm)}.kbx-workflow-bar__state{display:flex;align-items:center;gap:var(--kbx-space-2);white-space:nowrap}.label,.kbx-workflow-bar__flow{color:var(--kbx-color-text-muted)}.kbx-workflow-bar__flow{min-width:0;overflow:auto;display:flex;align-items:center;gap:var(--kbx-space-1);white-space:nowrap}.step.current{color:var(--kbx-color-text);font-weight:600}.arrow{color:var(--kbx-color-border-strong)}.kbx-workflow-bar__actions{display:flex;gap:var(--kbx-space-2)}@media(max-width:68.75rem){.kbx-workflow-bar{grid-template-columns:1fr auto}.kbx-workflow-bar__flow{grid-column:1/-1;grid-row:2}}</style>
@@ -0,0 +1,13 @@
import { computed, ref } from 'vue'
export function useKbxDirtyState() {
const revision = ref(0)
const savedRevision = ref(0)
const dirty = computed(() => revision.value !== savedRevision.value)
function touch() { revision.value++ }
function markSaved() { savedRevision.value = revision.value }
function reset() { revision.value = 0; savedRevision.value = 0 }
return { dirty, touch, markSaved, reset }
}
@@ -0,0 +1,55 @@
import { onBeforeUnmount, ref } from 'vue'
import type { KbxImportProgressEvent } from '@kbx/contracts'
export interface KbxImportProgressConnection {
start(): Promise<void>
stop(): Promise<void>
subscribe(sessionId: string): Promise<void>
unsubscribe(sessionId: string): Promise<void>
onProgress(callback: (event: KbxImportProgressEvent) => void): void
}
/**
* SignalR is intentionally hidden behind this adapter.
* The app shell can provide a @microsoft/signalr implementation without
* coupling KbxExcelImport or a vertical slice to HubConnection directly.
*/
export function useKbxImportProgress(connection: KbxImportProgressConnection | null) {
const connected = ref(false)
const lastEvent = ref<KbxImportProgressEvent | null>(null)
let activeSessionId: string | null = null
if (connection) {
connection.onProgress(event => {
if (!activeSessionId || event.sessionId === activeSessionId)
lastEvent.value = event
})
}
async function connect() {
if (!connection || connected.value) return
await connection.start()
connected.value = true
}
async function watch(sessionId: string) {
if (!connection) return
await connect()
if (activeSessionId && activeSessionId !== sessionId)
await connection.unsubscribe(activeSessionId)
activeSessionId = sessionId
await connection.subscribe(sessionId)
}
async function dispose() {
if (!connection) return
if (activeSessionId) await connection.unsubscribe(activeSessionId)
if (connected.value) await connection.stop()
connected.value = false
activeSessionId = null
}
onBeforeUnmount(() => { void dispose() })
return { connected, lastEvent, watch, dispose }
}
@@ -0,0 +1,19 @@
import { onBeforeUnmount, onMounted } from 'vue'
import type { KbxShortcutDefinition, KbxShortcutScope } from '@kbx/contracts'
import { registerKbxShortcuts } from '../keyboard/manager'
export interface KbxPageShortcut {
key:string
enabled?:()=>boolean
execute:()=>void|Promise<void>
}
export function useKbxShortcuts(shortcuts:KbxShortcutDefinition[]){
let unregister:(()=>void)|undefined
onMounted(()=>{unregister=registerKbxShortcuts(shortcuts)})
onBeforeUnmount(()=>unregister?.())
}
export function useKbxPageShortcuts(shortcuts:KbxPageShortcut[]){
useKbxShortcuts(shortcuts.map(x=>({...x,scope:'page' as KbxShortcutScope})))
}
@@ -0,0 +1,37 @@
import { computed, ref } from 'vue'
import type { KbxProblem, KbxValidationError } from '@kbx/contracts'
export function useKbxValidation() {
const errors = ref<KbxValidationError[]>([])
const fieldErrors = computed(() => {
const result = new Map<string, string>()
for (const error of errors.value) {
if (error.field && !error.rowKey && !result.has(error.field)) result.set(error.field, error.message)
}
return result
})
function fieldError(field: string) {
return fieldErrors.value.get(field)
}
function setErrors(next: KbxValidationError[]) {
errors.value = next
}
function setRowFieldError(rowKey: string, field: string, error?: Omit<KbxValidationError, 'rowKey' | 'field'>) {
errors.value = errors.value.filter(x => !(x.rowKey === rowKey && x.field === field))
if (error) errors.value.push({ rowKey, field, ...error })
}
function applyProblem(problem: KbxProblem) {
errors.value = problem.type === 'validation' ? problem.errors : []
}
function clear() {
errors.value = []
}
return { errors, fieldError, setErrors, setRowFieldError, applyProblem, clear }
}
@@ -0,0 +1,29 @@
import type { KbxImportDefinition, KbxImportMapping } from '@kbx/contracts'
export type KbxImportMappingIssueCode = 'required-missing' | 'duplicate-target'
export interface KbxImportMappingIssue {
code: KbxImportMappingIssueCode
fieldKey: string
message: string
}
export function validateKbxImportMappings(definition:KbxImportDefinition,mappings:readonly KbxImportMapping[]):KbxImportMappingIssue[]{
const fields=definition.fields.filter(field=>field.importable!==false)
const targets=mappings.map(mapping=>mapping.targetField).filter((value):value is string=>Boolean(value))
const issues:KbxImportMappingIssue[]=[]
for(const target of [...new Set(targets.filter((value,index)=>targets.indexOf(value)!==index))]){
const field=fields.find(item=>item.key===target)
issues.push({code:'duplicate-target',fieldKey:target,message:`${field?.label ?? target} 필드에 Excel 열이 두 개 이상 연결되어 있습니다.`})
}
for(const field of fields.filter(item=>item.required)){
if(!targets.includes(field.key))issues.push({code:'required-missing',fieldKey:field.key,message:`${field.label} 필수 필드를 매핑하세요.`})
}
return issues
}
export function validateKbxImportFileCandidate(definition:KbxImportDefinition,file:{name:string;size:number}):string|null{
if(!file.name.toLocaleLowerCase().endsWith('.xlsx'))return 'Excel .xlsx 파일만 업로드할 수 있습니다.'
const maxBytes=definition.maxFileSizeBytes??10*1024*1024
if(file.size>maxBytes)return `파일 크기는 ${Math.round(maxBytes/1048576)}MB 이하여야 합니다.`
return null
}
@@ -0,0 +1,62 @@
import type { KbxBulkSelectionRequest, KbxGridColumn, KbxGridPasteIssue, KbxGridPasteResult, KbxGridType, KbxSelectionState } from '@kbx/contracts'
function normalizeNumber(raw: unknown, integer = false) {
const text = String(raw ?? '').replace(/,/g, '').trim()
if (!text) return { value: null, changed: raw !== null && raw !== '' }
const value = Number(text)
if (!Number.isFinite(value) || (integer && !Number.isInteger(value))) return { invalid: true as const, value: raw, changed: false }
return { value, changed: String(value) !== String(raw) }
}
function normalizeDate(raw: unknown) {
const text = String(raw ?? '').trim()
if (!text) return { value: '', changed: false }
const compact = text.replace(/[./-]/g, '')
if (!/^\d{8}$/.test(compact)) return { invalid: true as const, value: raw, changed: false }
const y = Number(compact.slice(0, 4)), m = Number(compact.slice(4, 6)), d = Number(compact.slice(6, 8))
const date = new Date(Date.UTC(y, m - 1, d))
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return { invalid: true as const, value: raw, changed: false }
const value = `${compact.slice(0,4)}-${compact.slice(4,6)}-${compact.slice(6,8)}`
return { value, changed: value !== text }
}
function normalizeBoolean(raw: unknown) {
const text = String(raw ?? '').trim().toLowerCase()
if (['true','1','y','yes','예','사용','o'].includes(text)) return { value: true, changed: raw !== true }
if (['false','0','n','no','아니오','미사용','x'].includes(text)) return { value: false, changed: raw !== false }
return { invalid: true as const, value: raw, changed: false }
}
export function normalizeKbxGridClipboardValue(type: KbxGridType | undefined, raw: unknown) {
if (type === 'integer') return { ...normalizeNumber(raw, true), code: 'INVALID_NUMBER' as const }
if (['decimal','quantity','money','percent'].includes(type ?? '')) return { ...normalizeNumber(raw), code: 'INVALID_NUMBER' as const }
if (type === 'date') return { ...normalizeDate(raw), code: 'INVALID_DATE' as const }
if (type === 'boolean') return { ...normalizeBoolean(raw), code: 'INVALID_BOOLEAN' as const }
const value = typeof raw === 'string' ? raw.trim() : raw
return { value, changed: value !== raw }
}
export function normalizeKbxGridClipboardData<T extends object>(
data: unknown[][],
columns: KbxGridColumn<T>[],
startColumnIndex: number,
): { data: unknown[][]; result: KbxGridPasteResult } {
const issues: KbxGridPasteIssue[] = []
let normalizedCount = 0
const normalized = data.map((row, rowOffset) => row.map((raw, columnOffset) => {
const column = columns[startColumnIndex + columnOffset]
if (!column) return raw
const parsed = normalizeKbxGridClipboardValue(column.type, raw)
if (parsed.invalid) issues.push({ rowOffset, columnOffset, field: column.field, code: parsed.code!, value: raw })
if (parsed.changed) normalizedCount++
return parsed.value
}))
const cellCount = normalized.reduce((sum, row) => sum + row.length, 0)
return { data: normalized, result: { cellCount, normalizedCount, rejectedCount: issues.length, issues } }
}
export function toKbxBulkSelectionRequest<TFilter, TId = string>(selection: KbxSelectionState<TId>, filter: TFilter): KbxBulkSelectionRequest<TFilter, TId> {
return selection.mode === 'all-filtered'
? { mode: 'filter', filter, excludedIds: selection.excludedIds ?? [] }
: { mode: 'ids', ids: selection.selectedIds }
}
@@ -0,0 +1,121 @@
export { default as KbxButton } from './components/KbxButton.vue'
export { default as KbxCommandBar } from './components/KbxCommandBar.vue'
export { default as KbxListPage } from './components/KbxListPage.vue'
export { default as KbxDataGrid } from './components/KbxDataGrid.vue'
export * from './composables/useKbxShortcuts'
export * from './keyboard/manager'
export { defineKbxScreen } from '@kbx/contracts'
export { default as KbxTextField } from './components/KbxTextField.vue'
export { default as KbxDateField } from './components/KbxDateField.vue'
export { default as KbxLookup } from './components/KbxLookup.vue'
export { default as KbxFormSection } from './components/KbxFormSection.vue'
export { default as KbxFormGrid } from './components/KbxFormGrid.vue'
export { default as KbxFormSpan } from './components/KbxFormSpan.vue'
export { default as KbxValidationSummary } from './components/KbxValidationSummary.vue'
export { default as KbxTransactionPage } from './components/KbxTransactionPage.vue'
export * from './lookup/registry'
export * from './composables/useKbxValidation'
export * from './composables/useKbxDirtyState'
export type * from '@kbx/contracts'
export { default as KbxLookupDialog } from './components/KbxLookupDialog.vue'
export { default as KbxWmsMobilePage } from './wms/KbxWmsMobilePage.vue'
export { default as KbxNetworkIndicator } from './wms/KbxNetworkIndicator.vue'
export { default as KbxWmsActionButton } from './wms/KbxWmsActionButton.vue'
export { default as KbxBarcodeCapture } from './wms/KbxBarcodeCapture.vue'
export * from './wms/feedback'
export * from './wms/barcodeGuard'
export * from './wms/useKbxNetworkState'
export { default as KbxStatus } from './components/KbxStatus.vue'
export { default as KbxMasterPage } from './components/KbxMasterPage.vue'
export { default as KbxMasterDetailPage } from './components/KbxMasterDetailPage.vue'
export { default as KbxHelpPanel } from './components/KbxHelpPanel.vue'
export * from './registry/screenRegistry'
export * from './registry/componentManifest'
export * from './theme/kbxGridTheme'
export { default as KbxSearchPanel } from './components/KbxSearchPanel.vue'
export { default as KbxExcelMenu } from './components/KbxExcelMenu.vue'
export { default as KbxExcelImport } from './components/KbxExcelImport.vue'
export { default as KbxJobProgress } from './components/KbxJobProgress.vue'
export * from './composables/useKbxImportProgress'
export * from './excel/importGuard'
export { default as KbxImportPage } from './components/KbxImportPage.vue'
export { default as KbxExceptionSummary } from './components/KbxExceptionSummary.vue'
export { default as KbxWorkQueuePage } from './components/KbxWorkQueuePage.vue'
export { default as KbxExceptionDetailDrawer } from './components/KbxExceptionDetailDrawer.vue'
export { default as KbxReconcilePage } from './components/KbxReconcilePage.vue'
export { default as KbxExceptionCenter } from './components/KbxExceptionCenter.vue'
export { default as KbxUtilityRail } from './components/KbxUtilityRail.vue'
export { default as KbxAiAssistant } from './components/KbxAiAssistant.vue'
export { default as KbxUserSuggestionPanel } from './components/KbxUserSuggestionPanel.vue'
export { default as KbxWorkflowBar } from './components/KbxWorkflowBar.vue'
export { default as KbxRuntimeBanner } from './components/KbxRuntimeBanner.vue'
export { default as KbxFreshnessIndicator } from './components/KbxFreshnessIndicator.vue'
export { default as KbxConflictResolver } from './components/KbxConflictResolver.vue'
export { default as KbxOperationCenter } from './components/KbxOperationCenter.vue'
export { default as KbxNotificationCenter } from './components/KbxNotificationCenter.vue'
export { default as KbxGlobalHeader } from './shell/KbxGlobalHeader.vue'
export { default as KbxSideNavigation } from './shell/KbxSideNavigation.vue'
export { default as KbxWorkspaceTabs } from './shell/KbxWorkspaceTabs.vue'
export { default as KbxMenuSearch } from './shell/KbxMenuSearch.vue'
export { default as KbxApplicationShell } from './shell/KbxApplicationShell.vue'
export { default as KbxUnsavedChangesDialog } from './shell/KbxUnsavedChangesDialog.vue'
export * from './shell/navigationSearch'
export { default as KbxInput } from './components/KbxInput.vue'
export { default as KbxNumberField } from './components/KbxNumberField.vue'
export { default as KbxMoneyField } from './components/KbxMoneyField.vue'
export { default as KbxQuantityField } from './components/KbxQuantityField.vue'
export { default as KbxDateRange } from './components/KbxDateRange.vue'
export { default as KbxSelect } from './components/KbxSelect.vue'
export { default as KbxPageHeader } from './components/KbxPageHeader.vue'
export { default as KbxBulkActionBar } from './components/KbxBulkActionBar.vue'
export { default as KbxDialog } from './components/KbxDialog.vue'
export { default as KbxDrawer } from './components/KbxDrawer.vue'
export { default as KbxToast } from './components/KbxToast.vue'
export { default as KbxConfirm } from './components/KbxConfirm.vue'
export { default as KbxAuditTrail } from './components/KbxAuditTrail.vue'
export { default as KbxProposalPanel } from './components/KbxProposalPanel.vue'
export { default as KbxFastEntryPage } from './components/KbxFastEntryPage.vue'
export { default as KbxQueuePage } from './components/KbxQueuePage.vue'
export { default as KbxAiPanel } from './components/KbxAiPanel.vue'
export * from './catalog/componentCatalog'
export { default as KbxMaskedValue } from './components/KbxMaskedValue.vue'
export { default as KbxIntegrationState } from './components/KbxIntegrationState.vue'
export { default as KbxDataProvenance } from './components/KbxDataProvenance.vue'
export { default as KbxCheckbox } from './components/KbxCheckbox.vue'
export { default as KbxRadio } from './components/KbxRadio.vue'
export { default as KbxTextarea } from './components/KbxTextarea.vue'
export { default as KbxTabs } from './components/KbxTabs.vue'
export { default as KbxBadge } from './components/KbxBadge.vue'
export { default as KbxTooltip } from './components/KbxTooltip.vue'
export { default as KbxBarcodeField } from './components/KbxBarcodeField.vue'
export { default as KbxSectionHeader } from './components/KbxSectionHeader.vue'
export { default as KbxQuickFilterBar } from './components/KbxQuickFilterBar.vue'
export { default as KbxSummaryBar } from './components/KbxSummaryBar.vue'
export { default as KbxHomePage } from './shell/KbxHomePage.vue'
export { default as KbxAccessDenied } from './shell/KbxAccessDenied.vue'
export { default as KbxRouteNotFound } from './shell/KbxRouteNotFound.vue'
export { default as KbxDataState } from './components/KbxDataState.vue'
export * from './grid/editing'
export { default as KbxRecordLifecycle } from './components/KbxRecordLifecycle.vue'
export { default as KbxTemplateContextBar } from './components/KbxTemplateContextBar.vue'
export { default as KbxTemplateStateBoundary } from './components/KbxTemplateStateBoundary.vue'
export { default as KbxProgressSteps } from './components/KbxProgressSteps.vue'
export * from './registry/templateManifest'
export { buildKbxHomeAttention, buildKbxHomeAttentionQueue, buildKbxHomeQuickStart, buildKbxHomeWorkbench } from './shell/homeNavigation'
export { matchKbxRoutePattern, resolveKbxSafeRecentPath, resolveKbxSafeWorkspacePath } from './shell/navigationSecurity'
export { KbxScreenUtilityHostKey } from './utility/host'
export type { KbxScreenUtilityHost, KbxScreenUtilityTab } from './utility/host'
export { KbxPermissionHostKey } from './permission/host'
export type { KbxPermissionHost } from './permission/host'
@@ -0,0 +1,52 @@
import type { KbxShortcutDefinition, KbxShortcutScope } from '@kbx/contracts'
const scopeRank:Record<KbxShortcutScope,number>={application:1,page:2,grid:3,dialog:4,editor:5}
const protectedBrowserShortcuts=new Set(['F5','CTRL+L','CTRL+T','CTRL+W','CTRL+R'])
let nextId=1
const registrations=new Map<number,KbxShortcutDefinition[]>()
let listening=false
function normalizeConfiguredKey(value:string){return value.trim().replace(/\s+/g,'').toUpperCase()}
function eventKey(event:KeyboardEvent){
const parts:string[]=[]
if(event.ctrlKey||event.metaKey)parts.push('CTRL')
if(event.altKey)parts.push('ALT')
if(event.shiftKey)parts.push('SHIFT')
const key=event.key===' '?'SPACE':event.key.toUpperCase()
parts.push(key==='ESC'?'ESCAPE':key)
return parts.join('+')
}
function isTextEditingTarget(target:EventTarget|null){
const el=target instanceof HTMLElement?target:null
if(!el)return false
const tag=el.tagName.toLowerCase()
return tag==='input'||tag==='textarea'||tag==='select'||el.isContentEditable
}
function candidates(event:KeyboardEvent){
const key=eventKey(event)
return [...registrations.values()].flat().filter(x=>normalizeConfiguredKey(x.key)===key&&x.enabled?.()!==false)
.sort((a,b)=>(scopeRank[b.scope]-scopeRank[a.scope])||((b.priority??0)-(a.priority??0)))
}
function handle(event:KeyboardEvent){
const key=eventKey(event)
if(protectedBrowserShortcuts.has(key))return
const matches=candidates(event)
if(!matches.length)return
const editing=isTextEditingTarget(event.target)
const match=matches.find(x=>!editing||x.scope==='editor'||!['ENTER','SPACE','ARROWUP','ARROWDOWN','ARROWLEFT','ARROWRIGHT'].includes(key))
if(!match)return
event.preventDefault();void match.execute()
}
function ensureListener(){if(listening||typeof window==='undefined')return;window.addEventListener('keydown',handle);listening=true}
function releaseListener(){if(!listening||registrations.size||typeof window==='undefined')return;window.removeEventListener('keydown',handle);listening=false}
export function registerKbxShortcuts(shortcuts:KbxShortcutDefinition[]){
for(const shortcut of shortcuts){
const key=normalizeConfiguredKey(shortcut.key)
if(protectedBrowserShortcuts.has(key))throw new Error(`KBX browser shortcut is protected: ${shortcut.key}`)
}
const id=nextId++;registrations.set(id,shortcuts);ensureListener()
return ()=>{registrations.delete(id);releaseListener()}
}
export function getKbxShortcutDebugSnapshot(){return [...registrations.values()].flat().map(x=>({key:x.key,scope:x.scope,priority:x.priority??0}))}
@@ -0,0 +1,6 @@
import type { KbxLookupProvider } from '@kbx/contracts'
import type { InjectionKey } from 'vue'
export type KbxLookupRegistry = Record<string, KbxLookupProvider<any>>
export const kbxLookupRegistryKey: InjectionKey<KbxLookupRegistry> = Symbol('kbxLookupRegistry')
@@ -0,0 +1,7 @@
import type { InjectionKey } from 'vue'
export interface KbxPermissionHost {
has(permission:string): boolean
}
export const KbxPermissionHostKey:InjectionKey<KbxPermissionHost>=Symbol('KbxPermissionHost')
@@ -0,0 +1,89 @@
import type { KbxComponentManifestEntry } from '@kbx/contracts'
export const kbxComponentManifest: KbxComponentManifestEntry[] = [
{ name: 'KbxButton', category: 'primitive', purpose: '표준 업무 명령 버튼', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxTextField', category: 'primitive', purpose: '표준 문자열 입력', owner: 'KBX', version:'1.1.0' },
{ name: 'KbxDateField', category: 'primitive', purpose: '키보드 직접입력을 포함한 날짜 입력', owner: 'KBX', version: '1.1.0' },
{ name: 'KbxLookup', category: 'business', purpose: '비동기 race/error 복구와 코드/명칭 기반 업무 엔터티 선택', keyboard: ['F2', 'Enter', 'Esc'], owner: 'KBX', version: '1.3.0' },
{ name: 'KbxLookupDialog', category: 'business', purpose: 'Provider 오류·Empty·Pagination·Keyboard 선택을 표준화한 Lookup Dialog', keyboard: ['ArrowUp', 'ArrowDown', 'Enter', 'Esc'], owner: 'KBX', version: '1.1.0' },
{ name: 'KbxSearchPanel', category: 'business', purpose: '조회조건의 고정 배치와 상세조건 확장', keyboard: ['Enter'], owner: 'KBX', version: '1.3.0' },
{ name: 'KbxCommandBar', category: 'business', purpose: 'Permission Host·선택·업무상태·Dirty 조건을 반영해 명령을 고정 배치', keyboard: ['F3', 'F8'], owner: 'KBX', version: '1.3.0' },
{ name: 'KbxDataGrid', category: 'business', purpose: '조회·편집·대량처리용 업무 Grid', keyboard: ['Enter', 'Tab', 'Shift+Tab', 'Ctrl+C', 'Ctrl+V', 'F2'], owner: 'KBX', version: '1.4.0' },
{ name: 'KbxFormSection', category: 'business', purpose: 'Token 기반 Section heading·description·action 영역을 제공하는 Master/Transaction 입력 Section', owner: 'KBX', version: '1.1.0' },
{ name: 'KbxFormGrid', category: 'business', purpose: 'Desktop 2-column·좁은 화면 1-column 전환을 표준화하는 Form layout', owner:'KBX', version:'1.0.0' },
{ name: 'KbxFormSpan', category: 'business', purpose: '주소·비고 등 관계상 넓게 보여야 하는 Field의 명시적 full-span layout', owner:'KBX', version:'1.0.0' },
{ name: 'KbxValidationSummary', category: 'business', purpose: '저장 실패 입력 오류의 요약 및 이동', owner: 'KBX', version: '1.0.1' },
{ name: 'KbxStatus', category: 'business', purpose: 'Domain 상태를 일관된 semantic 상태로 표시', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxHelpPanel', category: 'business', purpose: '화면 목적·사용순서·단축키·주의사항 도움말', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxListPage', category: 'template', purpose: '조회 전·조회·재조회·Empty/Error 복구와 Search·Quick Filter·Grid·Summary를 고정한 조회형 표준 화면', owner: 'KBX', version: '1.6.0' },
{ name: 'KbxTransactionPage', category: 'template', purpose: 'Header/Detail·Context·Lifecycle·Summary를 결합한 거래형 표준 화면', owner: 'KBX', version: '1.6.0' },
{ name: 'KbxMasterPage', category: 'template', purpose: '조회 전 상태·목록/상세·Tabs·Context·Dirty/Conflict/Audit Lifecycle을 결합한 기준정보 표준 화면', owner: 'KBX', version: '1.6.0' },
{ name: 'KbxMasterDetailPage', category: 'template', purpose: '조회 전·재조회 Context를 보존하며 Master·Detail·이력·Drawer를 함께 탐색하는 표준 화면', owner: 'KBX', version: '1.6.0' },
{ name: 'KbxWmsMobilePage', category: 'wms', purpose: 'Template/Permission/Network 경계를 포함한 PDA·Scanner 현장 작업 Shell', owner: 'KBX', version: '1.5.0' },
{ name: 'KbxBarcodeCapture', category: 'wms', purpose: 'Keyboard-wedge/Camera/수동 입력과 bounded buffer·짧은 중복 debounce를 표준화', keyboard: ['Enter','Esc'], owner: 'KBX', version: '1.1.0' },
{ name: 'KbxNetworkIndicator', category: 'wms', purpose: '현장 네트워크 및 대기 Command 상태 표시', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxWmsActionButton', category: 'wms', purpose: '장갑/터치 환경용 주요 업무 CTA', owner: 'KBX', version: '1.1.0' },
{ name: 'KbxExcelMenu', category: 'business', purpose: '업무 화면의 Excel 다운로드·업로드 진입점', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxExcelImport', category: 'business', purpose: '파일 preflight·1:1 매핑·검증·반영 확인·부분실패 복구를 포함한 Excel Import 흐름', owner: 'KBX', version: '1.1.0' },
{ name: 'KbxJobProgress', category: 'business', purpose: 'Hangfire 장시간 작업 진행상태 표시', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxImportPage', category: 'template', purpose: 'Progress·Context·Import Content·Result를 고정한 Excel Import 표준 화면', owner: 'KBX', version: '1.4.0' },
{ name: 'KbxExceptionCenter', category: 'business', purpose: '예외 요약·목록·상세·안전한 후속 Action을 결합한 운영 컴포넌트', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxExceptionSummary', category: 'business', purpose: '업무 예외 유형별 건수와 빠른 필터', owner: 'KBX', version: '1.0.1' },
{ name: 'KbxExceptionDetailDrawer', category: 'business', purpose: '표준 Drawer·Permission Host로 예외 원인·대상·후속 Action을 안전하게 처리', keyboard: ['Esc'], owner: 'KBX', version: '1.1.0' },
{ name: 'KbxWorkQueuePage', category: 'template', purpose: '사용자가 지금 처리해야 할 업무와 예외를 우선하는 Queue 호환 화면', owner: 'KBX', version: '1.4.0' },
{ name: 'KbxReconcilePage', category: 'template', purpose: '조회 전·재조회·Expected/Actual/Difference·Resolution·Audit 기반 정합성 대사 화면', owner: 'KBX', version: '1.5.0' },
{ name: 'KbxUtilityRail', category: 'business', purpose: '도움말·AI 오류복구·사용자 제안을 동일한 우측 보조영역에 통합', keyboard: ['Esc'], owner: 'KBX', version: '1.2.0' },
{ name: 'KbxAiAssistant', category: 'business', purpose: 'Capability/Permission guard와 오류 재질문을 포함한 현재 화면 기반 AI 보조', owner: 'KBX', version: '1.1.0' },
{ name: 'KbxUserSuggestionPanel', category: 'business', purpose: '현재 화면 맥락을 포함한 저마찰 사용자 개선 제안', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxRuntimeBanner', category: 'business', purpose: '장애·읽기전용·오프라인 등 현재 운영모드를 화면 상단에 명확히 표시', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxFreshnessIndicator', category: 'business', purpose: '조회 데이터의 관측시각과 stale 여부를 표시하고 재조회를 제공', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxConflictResolver', category: 'business', purpose: '낙관적 동시성 충돌 시 내 화면과 최신 값을 비교하고 안전하게 다시 읽기', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxOperationCenter', category: 'business', purpose: 'Excel·대량처리·연계 등 장시간 작업의 진행률과 결과를 지속적으로 표시', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxNotificationCenter', category: 'business', purpose: '사용자가 확인해야 할 완료·부분실패·운영 알림을 비차단 방식으로 제공', owner: 'KBX', version: '1.0.0' },
{ name: 'KbxRecordLifecycle', category: 'business', purpose: 'Version·Workflow·Conflict·Audit를 하나의 재사용 가능한 레코드 생명주기 영역으로 통합', owner:'KBX', version:'1.0.0' },
{ name: 'KbxWorkflowBar', category: 'business', purpose: 'Permission Host를 반영해 현재 업무상태·표준 상태흐름·허용 전이를 한 위치에서 표현', owner: 'KBX', version: '1.1.0' },
{ name: 'KbxGlobalHeader', category: 'shell', purpose: '제품·실제 Module Selector·메뉴검색·운영알림·사용자 Action을 고정 배치하는 전역 Header', keyboard: ['Ctrl+K'], owner: 'KBX', version: '1.3.0' },
{ name: 'KbxSideNavigation', category: 'shell', purpose: 'Header와 동기화된 모듈 전환·검색·즐겨찾기·최근메뉴·2단계 업무메뉴 탐색', owner: 'KBX', version: '1.3.0' },
{ name: 'KbxWorkspaceTabs', category: 'shell', purpose: '키보드 이동·Dirty·Pin·Overflow를 가진 다중 업무 Workspace Tab', keyboard:['ArrowLeft','ArrowRight','Home','End'], owner: 'KBX', version: '1.2.0' },
{ name: 'KbxMenuSearch', category: 'shell', purpose: '즐겨찾기·최근업무 우선순위와 메뉴명·화면코드·업무 키워드를 결합하고 Focus를 복원하는 메뉴검색', keyboard: ['ArrowUp', 'ArrowDown', 'Enter', 'Esc'], owner: 'KBX', version: '1.3.0' },
{ name: 'KbxApplicationShell', category: 'shell', purpose: 'Global Header·Module Navigation·Workspace Tabs·Skip Navigation·업무영역과 사용자 Action 전달을 통합하는 Application Shell', owner: 'KBX', version: '1.5.0' },
{ name: 'KbxUnsavedChangesDialog', category: 'shell', purpose: 'Workspace 이동·종료 시 미저장 변경의 유지·폐기·저장후이동을 표준화', keyboard: ['Esc'], owner: 'KBX', version: '1.0.0' },
{ name: 'KbxInput', category: 'primitive', purpose: '표준 문자열 입력의 canonical API', allowedUse:['업무 문자열 입력'], forbiddenUse:['숫자·금액·수량 입력'], accessibility:['명시적 label 연결','error aria-describedby','focus-visible'], owner:'KBX', version:'1.2.0' },
{ name: 'KbxNumberField', category: 'primitive', purpose: '숫자 입력·표시 형식을 분리한 공통 숫자 입력', allowedUse:['integer','decimal'], forbiddenUse:['금액 의미를 직접 표현'], accessibility:['label','aria-invalid'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxMoneyField', category: 'business', purpose: '통화·정밀도·음수정책을 포함한 금액 입력', owner:'KBX', version:'1.1.0' },
{ name: 'KbxQuantityField', category: 'business', purpose: '단위·가용수량을 포함한 업무 수량 입력', owner:'KBX', version:'1.1.0' },
{ name: 'KbxDateRange', category: 'business', purpose: 'ERP 조회용 시작일·종료일 범위 입력', keyboard:['Tab','Shift+Tab'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxSelect', category: 'primitive', purpose: '짧고 고정된 Enum 선택', forbiddenUse:['대량 품목·거래처 선택'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxPageHeader', category: 'business', purpose: '화면명·경로·Utility 진입점의 고정 배치', owner:'KBX', version:'1.1.0' },
{ name: 'KbxBulkActionBar', category: 'business', purpose: 'Permission Host와 선택 건수를 반영한 대량 업무 Action의 공통 표현', owner:'KBX', version:'1.1.0' },
{ name: 'KbxDialog', category: 'primitive', purpose: '위험확정·짧은 Lookup·사유입력용 제한적 Modal', keyboard:['Esc'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxDrawer', category: 'primitive', purpose: '조회맥락을 유지하는 상세·Audit·Help 보조영역', keyboard:['Esc'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxToast', category: 'business', purpose: '시간이 지나도 해결이 필요하지 않은 성공·정보 Feedback', accessibility:['aria-live'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxConfirm', category: 'business', purpose: '위험 수준과 업무 결과를 설명하는 확인 UX', keyboard:['Esc'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxAuditTrail', category: 'business', purpose: '사용자 관점의 변경 전·후·행위자·사유 이력 표시', owner:'KBX', version:'1.0.0' },
{ name: 'KbxProposalPanel', category: 'business', purpose: 'Permission·Server validation 상태를 확인한 뒤 AI 변경안을 승인하는 공통 패턴', owner:'KBX', version:'1.1.0' },
{ name: 'KbxAiPanel', category: 'business', purpose: 'KbxAiAssistant 오류·화면명 계약을 포함하는 표준 명칭 호환 API', owner:'KBX', version:'1.1.0' },
{ name: 'KbxFastEntryPage', category: 'template', purpose: 'Enter·Paste·Fill Down·Context 중심 대량 행 입력 화면', keyboard:['Enter','Tab','Shift+Tab','F2','Ctrl+V','Ctrl+D'], owner:'KBX', version:'1.7.0' },
{ name: 'KbxQueuePage', category: 'template', purpose: '조회 전·예외 우선 Work Summary·Context·Queue·Quick Action 표준 Template', owner:'KBX', version:'1.6.0' },
{ name: 'KbxMaskedValue', category: 'business', purpose: '민감정보의 기본 마스킹·권한 기반 전체보기 진입점을 표준화', allowedUse:['개인정보·민감 업무값 표시'], forbiddenUse:['권한검증 없이 원문 노출'], accessibility:['전체보기/가리기 명시적 텍스트 Action'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxIntegrationState', category: 'business', purpose: 'Business State와 분리된 외부연계 대기·재시도·완료·실패 상태를 사용자에게 설명', allowedUse:['Outbox/외부 API 연계 상태'], forbiddenUse:['Domain 업무상태 대체'], accessibility:['색상 외 상태 텍스트','상세/재처리 명시적 Action'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxDataProvenance', category: 'business', purpose: '외부 데이터의 출처·기준시각·수신시각·정규화 버전·stale 상태를 재현 가능하게 표시', allowedUse:['KRX/OPENDART/KIS 등 외부 Read Model'], forbiddenUse:['외부 데이터를 Domain Truth로 표현'], accessibility:['색상 외 freshness 상태 텍스트','재조회 명시적 Action'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxCheckbox', category: 'primitive', purpose: 'Boolean 업무값의 키보드·마우스 선택', accessibility:['명시적 label','focus-visible'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxRadio', category: 'primitive', purpose: '소수의 상호배타 선택지 입력', accessibility:['radio label','keyboard native'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxTextarea', category: 'primitive', purpose: '메모·사유 등 다중행 문자열 입력', accessibility:['label','aria-invalid'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxTabs', category: 'primitive', purpose: '상세정보를 동일 화면 맥락에서 분리하는 표준 Tabs', keyboard:['Tab','ArrowLeft','ArrowRight','Home','End'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxBadge', category: 'primitive', purpose: '보조 상태·건수의 짧은 텍스트 표시', accessibility:['색상 외 텍스트'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxTooltip', category: 'primitive', purpose: '짧은 Field·Action 도움말을 Hover와 Focus 모두에서 표시', keyboard:['Tab'], owner:'KBX', version:'1.0.0' },
{ name: 'KbxBarcodeField', category: 'business', purpose: 'Desktop 업무 Form의 바코드 문자열 입력과 정규화', owner:'KBX', version:'1.1.0' },
{ name: 'KbxSectionHeader', category: 'business', purpose: 'Form·Detail Grid Section의 제목·건수·보조 Action 위치 표준화', owner:'KBX', version:'1.0.0' },
{ name: 'KbxQuickFilterBar', category: 'business', purpose: '현재 조회결과 KPI를 즉시 필터로 전환하는 Actionable Summary', owner:'KBX', version:'1.0.0' },
{ name: 'KbxDataState', category: 'business', purpose: 'Idle·Loading·Empty·Error 상태와 복구 Action을 공통 표현', allowedUse:['조회·Grid·비동기 업무 상태'], forbiddenUse:['업무 오류를 Toast로 대체'], accessibility:['aria-busy','error role alert','명시적 복구 Action'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxTemplateStateBoundary', category: 'business', purpose: '표준 Template의 Idle·Loading·Empty·Error·Refreshing 상태와 조회 복구 Action을 Context 손실 없이 통합', allowedUse:['T01~T09 주요 업무 Surface'], forbiddenUse:['Domain 업무 오류를 단순 Empty/Error로 축약'], accessibility:['aria-busy','live refresh status','explicit retry'], owner:'KBX', version:'1.1.0' },
{ name: 'KbxSummaryBar', category: 'business', purpose: '조회건수·선택건수·수량·금액 등 화면 하단 요약의 고정 표현', owner:'KBX', version:'1.0.0' },
{ name: 'KbxHomePage', category: 'shell', purpose: '미저장·실패 작업·중요 알림을 우선하고 같은 우선순위는 최신 발생 순으로 정렬하며 overflow 전체 건수까지 정확히 제공하는 안전한 Home Workbench', keyboard:['Ctrl+K'], owner:'KBX', version:'1.7.0' },
{ name: 'KbxAccessDenied', category: 'shell', purpose: '직접 URL 접근을 포함한 Frontend 권한 거부 상태와 화면 정보 비노출 secure-default 복구', owner:'KBX', version:'1.2.0' },
{ name: 'KbxRouteNotFound', category: 'shell', purpose: '등록되지 않은 딥링크를 안전한 메뉴검색/Home 흐름으로 복구', owner:'KBX', version:'1.0.0' },
{ name: 'KbxTemplateContextBar', category: 'template', purpose: 'T01~T08의 조회건수·선택·오류·기준시각 등 업무 문맥을 동일 위치에 표현', owner:'KBX', version:'1.0.0' },
{ name: 'KbxProgressSteps', category: 'business', purpose: 'Import 등 단계형 업무의 현재/완료 단계를 접근성 있게 표현', owner:'KBX', version:'1.0.0' },
]
@@ -0,0 +1,34 @@
import type { KbxScreenDefinition, KbxScreenManifestEntry } from '@kbx/contracts'
const screens = new Map<string, KbxScreenDefinition>()
export function registerKbxScreens(definitions: readonly KbxScreenDefinition[]) {
for (const definition of definitions) {
if (screens.has(definition.id)) {
throw new Error(`Duplicate KBX screen id: ${definition.id}`)
}
screens.set(definition.id, definition)
}
}
export function getKbxScreen(id: string) {
return screens.get(id)
}
export function listKbxScreens(): KbxScreenDefinition[] {
return [...screens.values()].sort((a, b) => a.id.localeCompare(b.id))
}
export function toKbxScreenManifest(routeById: Record<string, string> = {}): KbxScreenManifestEntry[] {
return listKbxScreens().map(screen => ({
id: screen.id,
version: screen.version,
module: screen.module,
type: screen.type,
templateCode: screen.templateCode,
title: screen.title,
route: routeById[screen.id],
helpKey: screen.helpKey,
requiredPermissions: screen.permissions ?? [],
}))
}
@@ -0,0 +1,19 @@
import type { KbxTemplateManifestEntry } from '@kbx/contracts'
const commonA11y=['keyboard','focus-visible','color-independent-state','labelled-regions']
const commonStates=['idle','loading','empty','error','ready'] as const
const desktopUtilities=['help','ai','suggestion'] as const
export const kbxTemplateManifest:KbxTemplateManifestEntry[]=[
{code:'T01',type:'list',component:'KbxListPage',purpose:'조회·선택·대량처리',requiredSurfaces:['page-header','command-bar','search','content/grid','summary'],optionalSurfaces:['quick-filter','context','bulk-action','detail-drawer'],keyboard:['F3','Ctrl+F'],referenceScreens:['OMS-ORD-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['idle','loading','refreshing','empty','error','permission'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxSearchPanel','KbxDataGrid','KbxSummaryBar','KbxBulkActionBar'],completionChecks:['조회조건 4~6개 우선 노출','재조회 Context 유지','대량선택 의미 명시','Empty/Error 복구 Action','Drawer 상세로 목록 Context 유지']},
{code:'T02',type:'master',component:'KbxMasterPage',purpose:'기준정보 목록·상세 CRUD',requiredSurfaces:['page-header','command-bar','detail','record-lifecycle'],optionalSurfaces:['master-list','context','tabs','audit','drawer'],keyboard:['F2','F8','Ctrl+S'],referenceScreens:['ERP-MST-ITEM-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['idle','loading','refreshing','empty','error','permission','dirty','conflict'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxFormSection','KbxFormGrid','KbxRecordLifecycle','KbxAuditTrail'],completionChecks:['Desktop 기본 2-column Form','신규/수정 권한 분리','Readonly/Disabled 구분','Version 충돌 복구','변경이력 연결']},
{code:'T03',type:'transaction',component:'KbxTransactionPage',purpose:'Header+Detail 거래 등록·수정',requiredSurfaces:['page-header','command-bar','header-form','detail-grid','summary','record-lifecycle'],optionalSurfaces:['context','workflow','audit','drawer'],keyboard:['F2','F8','Ctrl+S','Enter'],referenceScreens:['OMS-ORD-002','ERP-PUR-001','ERP-INV-MOVE-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['loading','refreshing','error','permission','dirty','conflict','validation'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxFormSection','KbxFormGrid','KbxDataGrid','KbxSummaryBar','KbxRecordLifecycle'],completionChecks:['Header 2-column Form','Header→Detail Focus 흐름','Dirty 상태에서 위험 Workflow 차단','서버 재검증','Optimistic Concurrency 복구']},
{code:'T04',type:'fast-entry',component:'KbxFastEntryPage',purpose:'대량 행 입력·붙여넣기',requiredSurfaces:['page-header','command-bar','keyboard-guide','editable-grid','validation','summary'],optionalSurfaces:['context','bulk-action'],keyboard:['Enter','Tab','F2','Ctrl+V','Ctrl+D'],referenceScreens:['ERP-PRICE-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['loading','refreshing','error','permission','dirty','validation'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxDataGrid','KbxValidationSummary','KbxSummaryBar','KbxLookupDialog'],completionChecks:['다중 Cell Paste 정규화','Fill Down/행 복제','오류 셀 이동','Idempotent Bulk Save','서버 대량 Validation']},
{code:'T05',type:'master-detail',component:'KbxMasterDetailPage',purpose:'Master 선택 Context 유지 Detail 탐색',requiredSurfaces:['page-header','search','master','detail'],optionalSurfaces:['context','bottom/history','summary','drawer'],keyboard:['F3','Enter'],referenceScreens:['ERP-INV-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['idle','loading','refreshing','empty','error','permission'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxSearchPanel','KbxDataGrid','KbxTemplateContextBar','KbxDrawer'],completionChecks:['Master 선택 Context 유지','재조회 후 선택 복원','Detail N+1 방지','Drill-down 근거 제공','Drawer로 조회 Context 유지']},
{code:'T06',type:'queue',component:'KbxQueuePage',purpose:'지금 처리할 업무·예외 우선 Queue',requiredSurfaces:['page-header','command-bar','work-summary','queue/content'],optionalSurfaces:['search','exceptions','context','bulk-action','detail'],keyboard:['F3','Enter'],referenceScreens:['WMS-WORK-001','COMMON-OPS-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['idle','loading','refreshing','empty','error','permission'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxExceptionSummary','KbxDataGrid','KbxBulkActionBar','KbxExceptionDetailDrawer'],completionChecks:['정상건보다 예외 우선','SLA/지연 식별','부분성공 결과','재처리/해결 Action','예외 처리 Audit']},
{code:'T07',type:'reconcile',component:'KbxReconcilePage',purpose:'Expected/Actual/Difference 정합성 비교·해결',requiredSurfaces:['page-header','criteria/search','summary','comparison-grid'],optionalSurfaces:['filters','context','resolution-action','audit','detail'],keyboard:['F3','Enter'],referenceScreens:['COMMON-REC-001'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['idle','loading','refreshing','empty','error','permission'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxSearchPanel','KbxDataGrid','KbxSummaryBar','KbxAuditTrail'],completionChecks:['Expected/Actual/Difference 동시 제공','불일치만 필터','원인/해결상태 제공','해결 Action 권한 검증','Audit 연결']},
{code:'T08',type:'import',component:'KbxImportPage',purpose:'파일→매핑→검증→반영 Import',requiredSurfaces:['page-header','progress-steps','import-content','result'],optionalSurfaces:['context','job-progress','footer'],keyboard:['Enter','Esc'],referenceScreens:['OMS-ORD-003'],defaultDensity:'compact',runtimeStates:[...commonStates],stateCapabilities:['loading','refreshing','error','permission','validation','job'],utilitySurfaces:[...desktopUtilities],accessibility:[...commonA11y],coreComponents:['KbxProgressSteps','KbxExcelImport','KbxValidationSummary','KbxJobProgress'],completionChecks:['파일→매핑→검증→반영 단계 고정','Staging 후 Commit','오류행 다운로드','부분성공 정책 명시','대량 작업 Job 복구']},
{code:'T09',type:'wms-mobile',component:'KbxWmsMobilePage',purpose:'Scanner/PDA 한 손 현장 작업',requiredSurfaces:['mobile-header','network-state','instruction/content','sticky-actions'],optionalSurfaces:['progress','notice','task-context'],keyboard:['Scanner Enter','Esc'],referenceScreens:['WMS-PICK-001','WMS-REC-001','WMS-PUT-001','WMS-COUNT-001'],defaultDensity:'touch',runtimeStates:[...commonStates],stateCapabilities:['loading','refreshing','error','permission','network'],utilitySurfaces:['help'],accessibility:[...commonA11y],coreComponents:['KbxBarcodeCapture','KbxNetworkIndicator','KbxWmsActionButton'],completionChecks:['Touch Density 최소 Target 준수','서버 응답 전 성공 Feedback 금지','중복 Scan Idempotency','오프라인 정책 명시','오류 시 다음 행동 우선']},
]
export function getKbxTemplate(type:KbxTemplateManifestEntry['type']){return kbxTemplateManifest.find(entry=>entry.type===type)}
@@ -0,0 +1,21 @@
<script setup lang="ts">
import KbxButton from '../components/KbxButton.vue'
withDefaults(defineProps<{ screenTitle?:string; screenId?:string; revealDetails?:boolean }>(),{revealDetails:false})
const emit=defineEmits<{ home:[]; menuSearch:[] }>()
</script>
<template>
<section class="kbx-access-denied" role="alert" aria-labelledby="kbx-access-denied-title">
<div class="kbx-access-denied__box">
<strong id="kbx-access-denied-title">접근 권한이 없습니다.</strong>
<p><template v-if="revealDetails"><b v-if="screenTitle">{{screenTitle}}</b><span v-if="screenId"> ({{screenId}})</span> </template>현재 사용자에게 업무 화면을 사용할 권한이 없습니다.</p>
<p>메뉴 노출 여부와 실제 업무 권한은 분리되며, 서버에서도 동일 권한을 최종 검증해야 합니다.</p>
<div class="kbx-access-denied__actions"><KbxButton label="홈으로 이동" variant="primary" @click="emit('home')" /><KbxButton label="다른 업무 찾기" @click="emit('menuSearch')" /></div>
</div>
</section>
</template>
<style scoped>
.kbx-access-denied{height:100%;min-height:var(--kbx-content-min-height);display:grid;place-items:center;padding:var(--kbx-space-6)}.kbx-access-denied__box{max-width:var(--kbx-access-max-width);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-danger);padding:var(--kbx-space-5);background:var(--kbx-color-surface)}.kbx-access-denied strong{font-size:var(--kbx-font-lg)}.kbx-access-denied p{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);line-height:1.6}.kbx-access-denied__actions{display:flex;align-items:center;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { KbxNavigationResolvedEntry, KbxNavigationSection, KbxRecentNavigation, KbxWorkspaceTab } from '@kbx/contracts'
import KbxGlobalHeader from './KbxGlobalHeader.vue'
import KbxMenuSearch from './KbxMenuSearch.vue'
import KbxSideNavigation from './KbxSideNavigation.vue'
import KbxWorkspaceTabs from './KbxWorkspaceTabs.vue'
const props=withDefaults(defineProps<{productName?:string;activeModule?:string;activeScreenId?:string|null;sections:KbxNavigationSection[];allEntries:KbxNavigationResolvedEntry[];favorites?:KbxNavigationResolvedEntry[];recents?:KbxRecentNavigation[];tabs?:KbxWorkspaceTab[];activeTabKey?:string|null;sideNavCollapsed?:boolean;menuSearchOpen?:boolean;operationCount?:number;notificationCount?:number;homeActive?:boolean;workspaceMaxTabs?:number;shellNotice?:string|null}>(),{productName:'KBX',activeModule:'',activeScreenId:null,favorites:()=>[],recents:()=>[],tabs:()=>[],activeTabKey:null,sideNavCollapsed:false,menuSearchOpen:false,operationCount:0,notificationCount:0,homeActive:false,workspaceMaxTabs:10,shellNotice:null})
const emit=defineEmits<{home:[];open:[entry:KbxNavigationResolvedEntry];openRecent:[entry:KbxRecentNavigation];selectTab:[tab:KbxWorkspaceTab];closeTab:[tab:KbxWorkspaceTab];toggleTabPin:[tab:KbxWorkspaceTab];toggleFavorite:[screenId:string];toggleSideNav:[];menuSearchOpen:[];menuSearchClose:[];operations:[];notifications:[];profile:[];dismissShellNotice:[]}>()
const moduleOptions=computed(()=>[...new Set(props.sections.map(section=>section.module).filter((module):module is NonNullable<KbxNavigationSection['module']>=>Boolean(module)))])
const focusedModule=ref(props.activeModule||moduleOptions.value[0]||'')
watch([()=>props.activeModule,moduleOptions],()=>{if(props.activeModule)focusedModule.value=props.activeModule;else if(!moduleOptions.value.includes(focusedModule.value as any))focusedModule.value=moduleOptions.value[0]??''},{immediate:true})
function selectModule(module:string){if(moduleOptions.value.includes(module as any))focusedModule.value=module}
const priorityScreenIds=computed(()=>[...props.favorites.map(x=>x.screenId),...props.recents.map(x=>x.screenId)].filter((id,index,list)=>list.indexOf(id)===index))
</script>
<template>
<div class="app-shell">
<a class="skip-link" href="#kbx-main-workspace">본문 바로가기</a>
<KbxGlobalHeader :product-name="props.productName" :active-module="focusedModule" :modules="moduleOptions" :operation-count="props.operationCount" :notification-count="props.notificationCount" :home-active="props.homeActive" @home="emit('home')" @module-select="selectModule" @menu-search="emit('menuSearchOpen')" @operations="emit('operations')" @notifications="emit('notifications')" @profile="emit('profile')" />
<div class="below-header">
<KbxSideNavigation :sections="props.sections" :favorites="props.favorites" :recents="props.recents" :active-screen-id="props.activeScreenId" :collapsed="props.sideNavCollapsed" :home-active="props.homeActive" :preferred-module="focusedModule" @home="emit('home')" @open="emit('open',$event)" @open-recent="emit('openRecent',$event)" @toggle-favorite="emit('toggleFavorite',$event)" @toggle-collapsed="emit('toggleSideNav')" @module-selected="selectModule" @menu-search="emit('menuSearchOpen')" />
<main class="workspace">
<KbxWorkspaceTabs :tabs="props.tabs" :active-key="props.activeTabKey" :max-tabs="props.workspaceMaxTabs" @select="emit('selectTab',$event)" @close-request="emit('closeTab',$event)" @toggle-pin="emit('toggleTabPin',$event)" />
<aside v-if="props.shellNotice" class="shell-notice" role="status" aria-live="polite"><span>{{props.shellNotice}}</span><button type="button" aria-label="안내 닫기" @click="emit('dismissShellNotice')">×</button></aside>
<section class="runtime"><slot name="runtime" /></section>
<section id="kbx-main-workspace" class="content" tabindex="-1"><slot /></section>
</main>
</div>
<KbxMenuSearch :open="props.menuSearchOpen" :entries="props.allEntries" :priority-screen-ids="priorityScreenIds" @close="emit('menuSearchClose')" @select="emit('open',$event)" />
</div>
</template>
<style scoped>.skip-link{position:fixed;left:var(--kbx-space-2);top:calc(var(--kbx-control-lg) * -2);z-index:300;padding:var(--kbx-space-2) var(--kbx-space-3);background:var(--kbx-color-text);color:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm)}.skip-link:focus{top:var(--kbx-space-2)}.app-shell{height:100vh;min-height:var(--kbx-app-shell-min-height);display:flex;flex-direction:column;background:var(--kbx-color-surface-muted);color:var(--kbx-color-text)}.below-header{flex:1;min-height:0;display:flex}.workspace{flex:1;min-width:0;display:flex;flex-direction:column}.runtime{background:var(--kbx-color-surface)}.content{flex:1;min-height:0;overflow:auto;background:var(--kbx-color-surface)}.shell-notice{min-height:var(--kbx-shell-notice-height);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.shell-notice button{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;background:transparent;color:var(--kbx-color-text-muted)}
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
productName?: string
activeModule?: string
modules?: string[]
menuSearchLabel?: string
operationCount?: number
notificationCount?: number
homeActive?: boolean
}>(), { productName:'KBX', activeModule:'', modules:()=>[], menuSearchLabel:'메뉴 검색', operationCount:0, notificationCount:0, homeActive:false })
const emit = defineEmits<{ home:[]; moduleSelect:[module:string]; menuSearch:[]; operations:[]; notifications:[]; profile:[] }>()
const label=(module:string)=>module==='COMMON'?'공통':module
</script>
<template>
<header class="kbx-global-header">
<div class="brand" aria-label="제품 모듈">
<button type="button" class="brand__home" :aria-current="homeActive?'page':undefined" @click="emit('home')"><strong>{{ props.productName }}</strong><span class="brand__home-label"></span></button>
<label v-if="props.modules.length" class="module-select"><span class="sr-only">업무 모듈</span><select :value="props.activeModule" @change="emit('moduleSelect',($event.target as HTMLSelectElement).value)"><option value="" disabled>모듈</option><option v-for="module in props.modules" :key="module" :value="module">{{label(module)}}</option></select></label>
</div>
<button class="menu-search" type="button" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>{{ props.menuSearchLabel }}</span><kbd>Ctrl K</kbd></button>
<div class="actions"><button type="button" @click="emit('operations')">작업 <b v-if="props.operationCount">{{ props.operationCount }}</b></button><button type="button" @click="emit('notifications')">알림 <b v-if="props.notificationCount">{{ props.notificationCount }}</b></button><button type="button" @click="emit('profile')">사용자</button></div>
</header>
</template>
<style scoped>
.kbx-global-header{height:var(--kbx-side-nav-collapsed-width);display:grid;grid-template-columns:var(--kbx-side-nav-width) minmax(var(--kbx-master-list-min-width),var(--kbx-access-max-width)) 1fr;align-items:center;gap:var(--kbx-space-4);padding:0 var(--kbx-space-3);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);z-index:20}.brand{display:flex;align-items:center;gap:var(--kbx-space-2);min-width:0}.brand__home{height:var(--kbx-control-height);display:flex;align-items:center;gap:var(--kbx-space-2);border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:0 var(--kbx-space-2);color:var(--kbx-color-text)}.brand__home:hover,.brand__home[aria-current="page"]{background:var(--kbx-color-surface-hover)}.brand__home strong{font-size:var(--kbx-font-lg)}.brand__home-label{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.module-select{padding-left:var(--kbx-space-2);border-left:var(--kbx-border-width) solid var(--kbx-color-border)}.module-select select{width:var(--kbx-global-header-module-selector-width);height:var(--kbx-control-sm);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-sm);padding:0 var(--kbx-space-2)}.menu-search{height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;padding:0 var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface-muted);color:var(--kbx-color-text-muted);text-align:left}.menu-search:hover{border-color:var(--kbx-color-border-strong)}.menu-search kbd{font-size:var(--kbx-font-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);padding:var(--kbx-space-1);background:var(--kbx-color-surface)}.actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-1)}.actions button{height:var(--kbx-control-sm);border:0;background:transparent;border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.actions button:hover{background:var(--kbx-color-surface-hover)}.actions b{min-width:var(--kbx-checkbox-track);display:inline-block;margin-left:var(--kbx-space-1);color:var(--kbx-color-primary)}.sr-only{position:absolute;width:var(--kbx-visually-hidden-size);height:var(--kbx-visually-hidden-size);padding:0;margin:calc(var(--kbx-visually-hidden-size) * -1);overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:56rem){.kbx-global-header{grid-template-columns:var(--kbx-side-nav-width) 1fr auto}.brand__home-label,.menu-search kbd{display:none}.module-select select{width:var(--kbx-control-lg);padding:0}}
</style>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
import { searchNavigationEntries } from './navigationSearch'
const props=withDefaults(defineProps<{open:boolean;entries:KbxNavigationResolvedEntry[];priorityScreenIds?:string[]}>(),{priorityScreenIds:()=>[]})
const emit=defineEmits<{close:[];select:[entry:KbxNavigationResolvedEntry]}>()
const query=ref('');const selected=ref(0);const input=ref<HTMLInputElement|null>(null);const dialog=ref<HTMLElement|null>(null);let restoreFocus:HTMLElement|null=null
const results=computed(()=>searchNavigationEntries(props.entries,query.value,12,props.priorityScreenIds));const byId=computed(()=>new Map(props.entries.map(x=>[x.screenId,x])))
const activeOptionId=computed(()=>results.value[selected.value]?`kbx-menu-result-${results.value[selected.value].screenId}`:undefined)
function choose(index=selected.value){const result=results.value[index];if(!result)return;const entry=byId.value.get(result.screenId);if(entry){emit('select',entry);emit('close')}}
function trapTab(e:KeyboardEvent){const root=dialog.value;if(!root)return;const nodes=[...root.querySelectorAll<HTMLElement>('input,button,[tabindex]:not([tabindex="-1"])')].filter(node=>!node.hasAttribute('disabled'));if(!nodes.length)return;const first=nodes[0],last=nodes.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}
function onKeydown(e:KeyboardEvent){if(e.key==='Escape'){e.preventDefault();emit('close')}else if(e.key==='ArrowDown'){e.preventDefault();selected.value=Math.min(selected.value+1,results.value.length-1)}else if(e.key==='ArrowUp'){e.preventDefault();selected.value=Math.max(0,selected.value-1)}else if(e.key==='Enter'){e.preventDefault();choose()}else if(e.key==='Tab'){trapTab(e)}}
watch(()=>props.open,async (value,previous)=>{if(value){restoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;query.value='';selected.value=0;await nextTick();input.value?.focus()}else if(previous){await nextTick();restoreFocus?.focus();restoreFocus=null}});watch(results,()=>{selected.value=0})
</script>
<template><div v-if="props.open" class="overlay" @mousedown.self="emit('close')"><section ref="dialog" class="dialog" role="dialog" aria-modal="true" aria-labelledby="kbx-menu-search-title" @keydown="onKeydown"><h2 id="kbx-menu-search-title" class="sr-only">메뉴·화면 검색</h2><input ref="input" v-model="query" type="search" placeholder="메뉴명, 화면코드, 업무명 검색" autocomplete="off" role="combobox" aria-autocomplete="list" aria-controls="kbx-menu-results" :aria-expanded="results.length>0" :aria-activedescendant="activeOptionId"/><div class="hint"><span v-if="!query">즐겨찾기·최근업무를 우선 표시합니다.</span><span v-else role="status" aria-live="polite">검색 결과 {{results.length}}건</span><span>↑↓ 이동 · Enter 열기 · Esc 닫기</span></div><div id="kbx-menu-results" role="listbox" aria-label="검색 결과"><button v-for="(result,index) in results" :id="`kbx-menu-result-${result.screenId}`" :key="result.screenId" type="button" role="option" :aria-selected="index===selected" class="result" :class="{selected:index===selected}" @mouseenter="selected=index" @click="choose(index)"><span class="main"><b>{{result.title}}</b><small>{{result.module}} · {{result.section}}</small></span><code>{{result.screenId}}</code></button></div><p v-if="!results.length" class="empty" role="status">일치하는 메뉴가 없습니다.</p></section></div></template>
<style scoped>.overlay{position:fixed;inset:0;z-index:200;background:var(--kbx-color-overlay);display:flex;align-items:flex-start;justify-content:center;padding-top:var(--kbx-menu-search-top)}.dialog{width:min(var(--kbx-menu-search-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;background:var(--kbx-color-surface);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);box-shadow:var(--kbx-shadow-overlay);padding:var(--kbx-space-2)}.dialog input{width:100%;height:var(--kbx-control-lg);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font-size:var(--kbx-font-md)}.hint{display:flex;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-1);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.result{width:100%;min-height:var(--kbx-menu-search-result-height);display:flex;align-items:center;justify-content:space-between;text-align:left;border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:var(--kbx-space-1) var(--kbx-space-2)}.result.selected{background:var(--kbx-color-surface-hover)}.main{display:flex;flex-direction:column;gap:var(--kbx-space-1)}.main small,.result code{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.empty{text-align:center;color:var(--kbx-color-text-muted);padding:var(--kbx-space-6)}.sr-only{position:absolute;width:var(--kbx-visually-hidden-size);height:var(--kbx-visually-hidden-size);padding:0;margin:calc(var(--kbx-visually-hidden-size) * -1);overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import KbxButton from '../components/KbxButton.vue'
const emit=defineEmits<{home:[];menuSearch:[]}>()
</script>
<template>
<section class="kbx-route-not-found" aria-labelledby="kbx-route-not-found-title">
<div class="kbx-route-not-found__box">
<strong id="kbx-route-not-found-title">화면을 찾을 없습니다.</strong>
<p>주소가 변경되었거나 이상 제공되지 않는 업무 화면입니다. 저장된 링크를 그대로 신뢰하지 않고 현재 메뉴에서 다시 찾아주세요.</p>
<div class="kbx-route-not-found__actions"><KbxButton label="메뉴 검색" variant="primary" @click="emit('menuSearch')"/><KbxButton label="홈으로 이동" @click="emit('home')"/></div>
</div>
</section>
</template>
<style scoped>.kbx-route-not-found{height:100%;min-height:var(--kbx-content-min-height);display:grid;place-items:center;padding:var(--kbx-space-6)}.kbx-route-not-found__box{max-width:var(--kbx-access-max-width);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-warning);padding:var(--kbx-space-5);background:var(--kbx-color-surface)}.kbx-route-not-found strong{font-size:var(--kbx-font-lg)}.kbx-route-not-found p{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted);line-height:1.6}.kbx-route-not-found__actions{display:flex;align-items:center;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { KbxNavigationResolvedEntry, KbxNavigationSection, KbxRecentNavigation } from '@kbx/contracts'
const props=withDefaults(defineProps<{sections:KbxNavigationSection[];favorites?:KbxNavigationResolvedEntry[];recents?:KbxRecentNavigation[];activeScreenId?:string|null;collapsed?:boolean;homeActive?:boolean;preferredModule?:string}>(),{favorites:()=>[],recents:()=>[],activeScreenId:null,collapsed:false,homeActive:false,preferredModule:''})
const emit=defineEmits<{home:[];open:[entry:KbxNavigationResolvedEntry];openRecent:[entry:KbxRecentNavigation];toggleFavorite:[screenId:string];toggleCollapsed:[];menuSearch:[];moduleSelected:[module:string]}>()
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통'}
const moduleOrder=['OMS','ERP','WMS','COMMON'] as const
const grouped=computed(()=>moduleOrder.map(module=>({module,label:moduleLabel[module],sections:props.sections.filter(section=>section.module===module)})).filter(group=>group.sections.length))
const activeModule=computed(()=>props.sections.find(section=>section.entries.some(entry=>entry.screenId===props.activeScreenId))?.module)
const selectedModule=ref<string>('')
watch([()=>props.preferredModule,activeModule,grouped],()=>{if(props.preferredModule&&grouped.value.some(x=>x.module===props.preferredModule))selectedModule.value=props.preferredModule;else if(activeModule.value)selectedModule.value=activeModule.value;else if(!grouped.value.some(x=>x.module===selectedModule.value))selectedModule.value=grouped.value[0]?.module??''},{immediate:true})
const selectedGroup=computed(()=>grouped.value.find(x=>x.module===selectedModule.value)??grouped.value[0])
const isFavorite=(id:string)=>props.favorites.some(x=>x.screenId===id)
function chooseModule(module:string){selectedModule.value=module;emit('moduleSelected',module);if(props.collapsed)emit('toggleCollapsed')}
</script>
<template>
<aside class="side-nav" :class="{collapsed:props.collapsed}" aria-label="업무 메뉴">
<div class="side-nav__top">
<button class="home" type="button" :class="{active:homeActive}" :aria-current="homeActive?'page':undefined" @click="emit('home')"><span aria-hidden="true"></span><span v-if="!collapsed"></span></button>
<button class="collapse" type="button" @click="emit('toggleCollapsed')" :aria-label="props.collapsed?'메뉴 펼치기':'메뉴 접기'">{{props.collapsed?'':''}}</button>
</div>
<template v-if="!props.collapsed">
<button class="side-nav__search" type="button" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"><span>메뉴·화면 검색</span><kbd>Ctrl K</kbd></button>
<nav class="module-switcher" aria-label="업무 모듈"><button v-for="group in grouped" :key="group.module" type="button" :aria-pressed="selectedModule===group.module" @click="chooseModule(group.module)">{{group.label}}</button></nav>
<section v-if="props.favorites.length" class="personal"><h2>즐겨찾기</h2><button v-for="entry in props.favorites.slice(0,6)" :key="entry.screenId" class="nav-item favorite" :class="{active:entry.screenId===props.activeScreenId}" @click="emit('open',entry)"><span aria-hidden="true"></span><span>{{entry.title}}</span></button></section>
<section v-if="props.recents.length" class="personal"><h2>최근메뉴</h2><button v-for="entry in props.recents.slice(0,4)" :key="entry.screenId+entry.path" class="nav-item" @click="emit('openRecent',entry)"><span aria-hidden="true"></span><span>{{entry.title}}</span></button></section>
<section v-if="selectedGroup" class="module-block active">
<h2 class="module-title">{{selectedGroup.label}} 업무</h2>
<div v-for="section in selectedGroup.sections" :key="section.key" class="section-block">
<h3>{{section.label.replace(`${selectedGroup.label} · `,'')}}</h3>
<div v-for="entry in section.entries" :key="entry.screenId" class="row">
<button class="nav-item main" :class="{active:entry.screenId===props.activeScreenId}" :aria-current="entry.screenId===props.activeScreenId?'page':undefined" @click="emit('open',entry)"><span>{{entry.title}}</span></button>
<button v-if="entry.favoriteAllowed!==false" class="star" type="button" :aria-label="isFavorite(entry.screenId)?'즐겨찾기 해제':'즐겨찾기 추가'" :aria-pressed="isFavorite(entry.screenId)" @click="emit('toggleFavorite',entry.screenId)">{{isFavorite(entry.screenId)?'':''}}</button>
</div>
</div>
</section>
</template>
<div v-else class="collapsed-actions"><button v-for="group in grouped" :key="group.module" type="button" :class="{active:activeModule===group.module}" :title="`${group.label} 메뉴 열기`" @click="chooseModule(group.module)">{{group.module==='COMMON'?'공':group.module.charAt(0)}}</button><button type="button" title="메뉴 검색" aria-label="메뉴 검색" aria-keyshortcuts="Control+K" @click="emit('menuSearch')"></button></div>
</aside>
</template>
<style scoped>
.side-nav{position:relative;width:var(--kbx-side-nav-width);min-width:var(--kbx-side-nav-width);border-right:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);overflow:auto;padding:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-5)}.side-nav.collapsed{width:var(--kbx-side-nav-collapsed-width);min-width:var(--kbx-side-nav-collapsed-width);overflow:hidden}.side-nav__top{position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:var(--kbx-space-1);padding-bottom:var(--kbx-space-2);background:var(--kbx-color-surface)}.home{height:var(--kbx-control-sm);flex:1;display:flex;align-items:center;gap:var(--kbx-space-2);border:0;border-radius:var(--kbx-radius-sm);background:transparent;padding:0 var(--kbx-space-2);text-align:left}.home:hover,.home.active{background:var(--kbx-color-surface-hover);color:var(--kbx-color-primary)}.collapse{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm)}.side-nav__search{width:100%;height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;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-muted);color:var(--kbx-color-text-muted)}.side-nav__search kbd{font-size:var(--kbx-font-xs)}.module-switcher{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--kbx-space-1);margin-top:var(--kbx-space-2)}.module-switcher 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);font-size:var(--kbx-font-xs)}.module-switcher button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600;background:var(--kbx-color-surface-hover)}.side-nav h2{margin:var(--kbx-space-3) var(--kbx-space-2) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.module-block{margin-top:var(--kbx-space-2);padding-top:var(--kbx-space-1);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.module-block.active>.module-title{color:var(--kbx-color-primary)}.section-block h3{margin:var(--kbx-space-2) var(--kbx-space-2) var(--kbx-space-1);font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.row{display:flex;align-items:center}.nav-item{min-height:var(--kbx-control-sm);border:0;background:transparent;border-radius:var(--kbx-radius-sm);display:flex;align-items:center;gap:var(--kbx-space-2);width:100%;padding:0 var(--kbx-space-2);text-align:left;color:var(--kbx-color-text)}.nav-item:hover,.nav-item.active{background:var(--kbx-color-surface-hover)}.nav-item.active{color:var(--kbx-color-primary);font-weight:600}.nav-item.main{min-width:0;flex:1}.nav-item.main span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.star{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;background:transparent;color:var(--kbx-color-text-muted);border-radius:var(--kbx-radius-sm)}.star:hover{background:var(--kbx-color-surface-hover);color:var(--kbx-color-primary)}.collapsed .side-nav__top{flex-direction:column}.collapsed .home{width:var(--kbx-control-md);flex:none;justify-content:center;padding:0}.collapsed .collapse{width:var(--kbx-control-md)}.collapsed-actions{display:flex;flex-direction:column;align-items:center;gap:var(--kbx-space-2);padding-top:var(--kbx-space-2);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.collapsed-actions button{width:var(--kbx-control-md);height:var(--kbx-control-md);border:0;border-radius:var(--kbx-radius-sm);background:transparent;font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.collapsed-actions button:hover,.collapsed-actions button.active{background:var(--kbx-color-surface-hover);color:var(--kbx-color-primary)}
</style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
const props=withDefaults(defineProps<{ open:boolean; title?:string; canSave?:boolean; saving?:boolean }>(),{title:'저장하지 않은 변경사항이 있습니다.',canSave:true,saving:false})
const emit=defineEmits<{ stay:[]; discard:[]; save:[] }>()
</script>
<template>
<div v-if="props.open" class="overlay" @mousedown.self="emit('stay')">
<section class="dialog" role="dialog" aria-modal="true" aria-labelledby="unsaved-title">
<h2 id="unsaved-title">{{props.title}}</h2>
<p>변경사항을 어떻게 처리할지 선택하세요.</p>
<div class="actions">
<button type="button" @click="emit('stay')">계속 편집</button>
<button type="button" class="danger" @click="emit('discard')">변경 버리기</button>
<button v-if="props.canSave" type="button" class="primary" :disabled="props.saving" @click="emit('save')">{{props.saving?'저장 중...':'저장 이동'}}</button>
</div>
</section>
</div>
</template>
<style scoped>.overlay{position:fixed;inset:0;z-index:220;display:flex;align-items:center;justify-content:center;background:var(--kbx-color-overlay)}.dialog{width:min(440px,calc(100vw - 24px));padding:18px;border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:0 20px 50px rgba(16,24,40,.25)}h2{font-size:var(--kbx-font-lg);margin:0 0 8px}p{color:var(--kbx-color-text-muted)}.actions{display:flex;justify-content:flex-end;gap:6px;margin-top:18px}.actions button{height:34px;padding:0 12px;border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}.actions .primary{background:var(--kbx-color-primary);border-color:var(--kbx-color-primary);color:white}.actions .danger{color:var(--kbx-color-danger)}</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import type { KbxWorkspaceTab } from '@kbx/contracts'
const props=withDefaults(defineProps<{tabs:KbxWorkspaceTab[];activeKey?:string|null;maxTabs?:number}>(),{activeKey:null,maxTabs:10})
const emit=defineEmits<{select:[tab:KbxWorkspaceTab];closeRequest:[tab:KbxWorkspaceTab];togglePin:[tab:KbxWorkspaceTab]}>()
const tabButtons=ref<Record<string,HTMLButtonElement|null>>({})
const visibleTabs=computed(()=>{if(props.tabs.length<=props.maxTabs)return props.tabs;const first=props.tabs.slice(0,props.maxTabs);const active=props.tabs.find(x=>x.key===props.activeKey);if(active&&!first.some(x=>x.key===active.key))first[first.length-1]=active;return first})
const overflowTabs=computed(()=>props.tabs.filter(tab=>!visibleTabs.value.some(x=>x.key===tab.key)))
async function selectAndFocus(tab:KbxWorkspaceTab){emit('select',tab);await nextTick();tabButtons.value[tab.key]?.focus()}
function move(tab:KbxWorkspaceTab,direction:'prev'|'next'|'first'|'last'){const list=visibleTabs.value;const index=list.findIndex(x=>x.key===tab.key);if(index<0)return;const target=direction==='first'?list[0]:direction==='last'?list.at(-1):list[(index+(direction==='next'?1:-1)+list.length)%list.length];if(target)void selectAndFocus(target)}
function keydown(event:KeyboardEvent,tab:KbxWorkspaceTab){if(event.key==='ArrowLeft'){event.preventDefault();move(tab,'prev')}else if(event.key==='ArrowRight'){event.preventDefault();move(tab,'next')}else if(event.key==='Home'){event.preventDefault();move(tab,'first')}else if(event.key==='End'){event.preventDefault();move(tab,'last')}}
function setTabButton(key:string,el:unknown){tabButtons.value[key]=el instanceof HTMLButtonElement?el:null}
</script>
<template>
<nav class="workspace-tabs" role="tablist" aria-label="열린 업무">
<div v-if="props.tabs.length===0" class="empty">열린 업무가 없습니다.</div>
<div v-for="tab in visibleTabs" :key="tab.key" class="tab-shell" :class="{active:tab.key===props.activeKey}">
<button :ref="el=>setTabButton(tab.key,el)" type="button" class="tab" role="tab" :aria-selected="tab.key===props.activeKey" :tabindex="tab.key===props.activeKey?0:-1" @click="emit('select',tab)" @keydown="keydown($event,tab)" @auxclick.middle.prevent="emit('closeRequest',tab)"><span v-if="tab.pinned" class="pin-mark" aria-label="고정됨">◆</span><span class="title">{{tab.title}}</span><span v-if="tab.dirty" class="dirty" title="저장하지 않은 변경" aria-label="저장하지 않은 변경"></span></button>
<button type="button" class="pin" :aria-pressed="Boolean(tab.pinned)" :aria-label="tab.pinned?`${tab.title} 고정 해제`:`${tab.title} 고정`" @click="emit('togglePin',tab)">{{tab.pinned?'':''}}</button>
<button type="button" class="close" :aria-label="`${tab.title} 닫기`" @click="emit('closeRequest',tab)">×</button>
</div>
<details v-if="overflowTabs.length" class="overflow"><summary>더보기 {{ overflowTabs.length }}</summary><div class="overflow-menu"><button v-for="tab in overflowTabs" :key="tab.key" type="button" @click="emit('select',tab)"><span>{{tab.title}}</span><small v-if="tab.pinned">고정</small><small v-if="tab.dirty">미저장</small></button></div></details>
</nav>
</template>
<style scoped>
.workspace-tabs{height:var(--kbx-workspace-tabs-height);display:flex;align-items:flex-end;gap:var(--kbx-border-width);overflow-x:auto;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:0 var(--kbx-space-2)}.tab-shell{height:var(--kbx-workspace-tab-height);max-width:var(--kbx-workspace-tab-max-width);display:flex;align-items:center;border:var(--kbx-border-width) solid transparent;border-bottom:0;background:transparent}.tab-shell.active{background:var(--kbx-color-surface);border-color:var(--kbx-color-border)}.tab{height:100%;min-width:0;display:flex;align-items:center;gap:var(--kbx-space-2);border:0;background:transparent;padding:0 var(--kbx-space-2);color:var(--kbx-color-text-muted);white-space:nowrap}.tab-shell.active .tab{color:var(--kbx-color-text);font-weight:500}.title{overflow:hidden;text-overflow:ellipsis}.dirty{font-size:var(--kbx-font-xs);color:var(--kbx-color-warning)}.pin-mark{font-size:var(--kbx-font-xs);color:var(--kbx-color-primary)}.pin,.close{width:var(--kbx-control-xs);height:100%;border:0;background:transparent;color:var(--kbx-color-text-muted)}.pin{font-size:var(--kbx-font-xs)}.close{font-size:var(--kbx-font-xl)}.pin:hover,.close:hover,.pin:focus-visible,.close:focus-visible{background:var(--kbx-color-surface-hover)}.pin[aria-pressed="true"]{color:var(--kbx-color-primary)}.empty{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);padding:0 var(--kbx-space-2) var(--kbx-space-2);margin:auto}.overflow{position:relative;margin-left:auto;padding-bottom:var(--kbx-space-1)}.overflow summary{list-style:none;cursor:pointer;font-size:var(--kbx-font-xs);padding:var(--kbx-space-2)}.overflow-menu{position:absolute;right:0;top:var(--kbx-control-height);z-index:80;min-width:var(--kbx-workspace-overflow-width);padding:var(--kbx-space-1);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}.overflow-menu button{width:100%;min-height:var(--kbx-control-xs);display:flex;align-items:center;gap:var(--kbx-space-2);border:0;background:transparent;text-align:left;padding:0 var(--kbx-space-2)}.overflow-menu button span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.overflow-menu button small{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.overflow-menu button:hover{background:var(--kbx-color-surface-hover)}
</style>
@@ -0,0 +1,128 @@
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: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
}
@@ -0,0 +1,12 @@
import type { KbxMenuSearchResult, KbxNavigationResolvedEntry } from '@kbx/contracts'
function normalize(value:string){return value.toLocaleLowerCase().replace(/\s+/g,'')}
export function searchNavigationEntries(entries:KbxNavigationResolvedEntry[], query:string, limit=12, priorityScreenIds:string[]=[]):KbxMenuSearchResult[]{
const q=normalize(query);const priority=new Map(priorityScreenIds.map((id,index)=>[id,index]))
return entries.map(entry=>{
const values=[entry.title,entry.screenId,entry.module,entry.section,...(entry.keywords??[])].map(normalize)
let score=q?0:(priority.has(entry.screenId)?1000-(priority.get(entry.screenId)??0):1)
for(const value of values){if(!q)continue;if(value===q)score=Math.max(score,100);else if(value.startsWith(q))score=Math.max(score,70);else if(value.includes(q))score=Math.max(score,40)}
if(q&&priority.has(entry.screenId)&&score>0)score+=5
return {screenId:entry.screenId,title:entry.title,module:entry.module,section:entry.section,path:entry.path,keywords:entry.keywords??[],score}
}).filter(x=>x.score>0).sort((a,b)=>b.score-a.score||a.title.localeCompare(b.title)).slice(0,limit)
}
@@ -0,0 +1,47 @@
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
const MAX_WORKSPACE_URL_LENGTH=2048
function normalizePathname(value:string){const clean=value.replace(/\/+$/,'')||'/';return clean.startsWith('/')?clean:`/${clean}`}
function hasUnsafeEncodedPath(value:string){return value.length>MAX_WORKSPACE_URL_LENGTH || /[\u0000-\u001f\u007f]|\\|%2f|%5c|%00|%0d|%0a|%09|%2e%2e/i.test(value)}
/** Match a Vue-Router-style path pattern without trusting persisted route strings. */
export function matchKbxRoutePattern(pattern:string,candidatePath:string){
if(hasUnsafeEncodedPath(candidatePath))return false
const p=normalizePathname(pattern).split('/').filter(Boolean)
const c=normalizePathname(candidatePath).split('/').filter(Boolean)
if(p.length!==c.length)return false
return p.every((segment,index)=>segment.startsWith(':') ? Boolean(c[index]) : segment===c[index])
}
/**
* Re-resolve an in-memory workspace path against the screen's declared route capabilities.
* Query/hash are retained only for the current in-memory workspace after the pathname passes the capability check.
*/
export function resolveKbxSafeWorkspacePath(patterns:readonly string[],candidatePath:string,fallbackPath:string){
try{
if(hasUnsafeEncodedPath(candidatePath))return fallbackPath
const candidate=new URL(candidatePath,'https://kbx.local')
if(candidate.origin!=='https://kbx.local'||candidate.username||candidate.password)return fallbackPath
const allowed=patterns.some(pattern=>matchKbxRoutePattern(pattern,candidate.pathname))
return allowed?`${candidate.pathname}${candidate.search}${candidate.hash}`:fallbackPath
}catch{return fallbackPath}
}
/**
* Re-resolve untrusted persisted recent navigation against the current authorized catalog entry.
* Query/hash are deliberately stripped before persistence/replay so transient filters, tokens, and sensitive
* values do not leak into local preference storage. Dynamic route path persistence remains explicit opt-in.
*/
export function resolveKbxSafeRecentPath(entry:KbxNavigationResolvedEntry, storedPath:string){
if(entry.recentPolicy!=='route')return entry.path
try{
if(hasUnsafeEncodedPath(storedPath))return entry.path
const base=new URL(entry.path,'https://kbx.local')
const candidate=new URL(storedPath,'https://kbx.local')
if(candidate.origin!==base.origin||candidate.username||candidate.password)return entry.path
const basePath=base.pathname.replace(/\/$/,'')||'/'
const candidatePath=candidate.pathname.replace(/\/$/,'')||'/'
const within=candidatePath===basePath || (basePath!=='/' && candidatePath.startsWith(`${basePath}/`))
return within?candidate.pathname:entry.path
}catch{return entry.path}
}
@@ -0,0 +1,10 @@
import { themeQuartz } from 'ag-grid-community'
// AG Grid v33+ Theming API adapter. Business modules never import a grid theme directly.
export const kbxGridTheme = themeQuartz.withParams({
spacing: 4,
foregroundColor: 'var(--kbx-color-text)',
backgroundColor: 'var(--kbx-color-surface)',
headerBackgroundColor: 'var(--kbx-color-surface-subtle)',
rowHoverColor: 'var(--kbx-color-surface-hover)',
})

Some files were not shown because too many files have changed in this diff Show More