chore: remove kbx-foundation-v36 reference (superseded by v4 implementation)

Removed entire kbx-foundation-v36 directory as it's been replaced by
the new KBX Foundation v4 patterns implemented in this session:
- Registry-driven screen definitions
- Density-aware UI adapter components
- Feature module templates (ShadowRun, Models)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:39:58 +09:00
parent 831c4b467d
commit c41e5063b7
1079 changed files with 13094 additions and 1134 deletions
-50
View File
@@ -1,50 +0,0 @@
# KBX Foundation v36 — Recipe Verification · Generated Test Plan · Deterministic Home Workbench
KBX v36는 v35의 Screen Recipe / Secure Scaffolder 기반을 실제 검증계약까지 닫는 Foundation iteration이다. 목표는 화면·컴포넌트 수를 더 늘리는 것이 아니라, T01~T09를 선택한 순간부터 표준 UX, 보안, 복구, 테스트 증적이 함께 따라오도록 만드는 것이다.
## v36 핵심
- T01~T09 `testProfile` 추가
- 필수 Scenario Kind
- 필수 Behavioral Tag
- 필수 Evidence
- 필수 Recovery/Interaction Check
- `generated/screen-recipe-verification-manifest.json` 추가
- T01~T09 Recipe Verification **9/9 자동 폐쇄성**
- 각 Recipe는 실제 해당 Template을 사용하는 Screen의 Canonical E2E Scenario를 최소 1개 요구
- T02 Master / T06 Queue / T07 Reconcile Canonical E2E Recovery Scenario 추가
- Scaffolder가 신규 Vertical Slice에 `*.test-plan.ts` 자동 생성
- 생성 Test Plan은 Canonical Scenario / Required Check / Required Evidence를 타입 계약으로 보존
- 기존 Master / Transaction / Fast Entry의 명시적 `--write-permission` fail-closed 정책 유지
- Home Attention Queue를 동일 우선순위 내 최신 발생 순으로 정렬
- Home은 표시 상한과 별개로 권한 필터 후 실제 전체 건수 및 overflow를 정확히 표시
- `buildKbxHomeAttentionQueue()` public API 추가, 기존 `buildKbxHomeAttention()` 호환 유지
- Screen Recipe `testProfile` / `canonicalScenarioIds` 변경을 Release Impact에서 추적
- Design Debt ratchet **131 유지**
## 검증
```bash
node scripts/validate-kbx.mjs
```
최종 검증 기준:
- 299 TS/Vue script units
- 20 Screen Definitions
- 84 Component Definitions
- 66 Component Catalog entries
- 153 Design Tokens
- 9 Screen Recipes
- 30 Canonical Test Scenarios
- Recipe Verification 9/9
- 16 Navigation Entries
- Design Debt 131 <= 131
- Release Impact major / major
## 문서
- `docs/screen-recipe-verification-home-attention-v36.md`
- `docs/kbx-v36-standard-traceability.md`
- `docs/validation-report-v36.md`
- `docs/release/migration-guide.md`
@@ -1,9 +0,0 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
import { inventoryMoveColumns, inventoryMoveScreen, inventoryMoveWorkflow, type InventoryMoveLine } from './inventory-move.definition'
const status=ref('DRAFT'); const header=reactive({moveDate:new Date().toISOString().slice(0,10),fromWarehouseId:null as string|null,toWarehouseId:null as string|null}); const lines=ref<InventoryMoveLine[]>([])
function command(id:string){ const t=inventoryMoveWorkflow.transitions.find(x=>x.id===id && x.from.includes(status.value)); if(t) status.value=t.to }
</script>
<template><KbxTransactionPage :screen="inventoryMoveScreen" :status="status" :summary-items="[{key:'items',label:'품목',value:`${lines.length}종`}]" @command="command"><template #header><KbxWorkflowBar :workflow="inventoryMoveWorkflow" :current="status" @transition="command"/><KbxFormSection title="이동정보"><KbxFormGrid><KbxDateField v-model="header.moveDate" label="이동일" required/><KbxLookup v-model="header.fromWarehouseId" entity="warehouse" label="출발창고" required/><KbxLookup v-model="header.toWarehouseId" entity="warehouse" label="도착창고" required/></KbxFormGrid></KbxFormSection></template><template #detail><KbxDataGrid :rows="lines" :columns="inventoryMoveColumns" row-key="clientId" editable clipboard/></template></KbxTransactionPage></template>
@@ -1,34 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { isKbxProblem, kbxFieldReadonly, resolveKbxRecordStatePolicy, type KbxAuditEntry, type KbxConflictSnapshot, type KbxSearchField } from '@kbx/contracts'
import { KbxBarcodeField, KbxCheckbox, KbxDataGrid, KbxFormGrid, KbxFormSection, KbxLookup, KbxMasterPage, KbxInput, KbxSearchPanel, useKbxDirtyState, useKbxPageShortcuts } from '@kbx/ui'
import { itemApi } from './itemApi'
import { itemMasterColumns, itemMasterScreen, itemStatePolicies, itemWorkflow, type ItemMasterRow } from './item.definition'
const searchModel=reactive({keyword:''});const searchFields:KbxSearchField[]=[{key:'keyword',label:'검색',type:'text',width:'lg',placeholder:'품목코드/품목명'}]
const selected=ref<ItemMasterRow[]>([]);const form=reactive<ItemMasterRow>(emptyItem());const status=computed(()=>!form.id?'신규':form.active?'사용':'사용중지');const policy=computed(()=>resolveKbxRecordStatePolicy(itemStatePolicies,status.value))
const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null);const errors=ref<any[]>([]);const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState()
const query=useQuery({queryKey:['erp','items'],queryFn:()=>itemApi.search(searchModel.keyword),enabled:false});const rows=computed(()=>query.data.value?.items??[])
function emptyItem():ItemMasterRow{return{id:'',code:'',name:'',categoryName:'',specification:'',unit:'EA',barcode:'',defaultWarehouseId:null,defaultWarehouseName:'',lotManaged:false,expiryManaged:false,active:true,version:0}}
async function search(){selected.value=[];await query.refetch()}
async function choose(rows:ItemMasterRow[]){selected.value=rows;if(!rows[0])return;const latest=await itemApi.get(rows[0].id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(latest.id);errors.value=[];conflict.value=null;resetDirty()}
function state(field:string){return kbxFieldReadonly(policy.value,field)}
function createNew(){Object.assign(form,emptyItem());selected.value=[];auditEntries.value=[];conflict.value=null;errors.value=[];resetDirty()}
function copy(){const source={...form};Object.assign(form,{...source,id:'',code:'',barcode:'',version:0,active:true});auditEntries.value=[];conflict.value=null;resetDirty();touch()}
function payload(){return{id:form.id||undefined,version:form.version||undefined,code:form.code.trim(),name:form.name.trim(),categoryName:form.categoryName,specification:form.specification,unit:form.unit,barcode:form.barcode,defaultWarehouseId:form.defaultWarehouseId,lotManaged:form.lotManaged,expiryManaged:form.expiryManaged}}
async function save(){errors.value=[];if(!form.code.trim()||!form.name.trim()){errors.value=[...(!form.code.trim()?[{field:'code',code:'REQUIRED',message:'품목코드를 입력하세요.'}]:[]),...(!form.name.trim()?[{field:'name',code:'REQUIRED',message:'품목명을 입력하세요.'}]:[])];return}try{const r=form.id?await itemApi.update(form.id,payload()):await itemApi.create(payload());form.id=r.id;form.version=r.version;form.active=true;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='validation'){errors.value=e.errors;return}if(isKbxProblem(e)&&e.type==='conflict'&&form.id){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',entityId:form.id,requestedVersion:form.version,currentVersion:e.currentVersion??latest.version,changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label])=>({field,label,mine:(form as any)[field],latest:(latest as any)[field]}))};return}throw e}}
async function deactivate(){if(!form.id)return;try{const r=await itemApi.deactivate(form.id,form.version);form.version=r.version;form.active=false;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,currentVersion:e.currentVersion??latest.version,requestedVersion:form.version,entityId:form.id};return}throw e}}
async function reloadConflict(){if(!form.id)return;const latest=await itemApi.get(form.id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(form.id);conflict.value=null;resetDirty()}
async function command(id:string){if(id==='search')return search();if(id==='new')return createNew();if(id==='copy')return copy();if(id==='save')return save();if(id==='deactivate')return deactivate()}
useKbxPageShortcuts([{key:'F3',execute:search},{key:'F8',execute:save}])
</script>
<template>
<KbxMasterPage :screen="itemMasterScreen" :status="status" :dirty="dirty" :version="form.version||undefined" :errors="errors" :workflow="itemWorkflow" :conflict="conflict" :audit-entries="auditEntries" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.items.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" breadcrumb="ERP > 기준정보" @command="command" @transition="command" @reload-conflict="reloadConflict" @dismiss-conflict="conflict=null">
<template #list><KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search"/><KbxDataGrid :rows="rows" :columns="itemMasterColumns" row-key="id" selection="single" :active-row-key="form.id||undefined" :loading="query.isFetching.value" @selection-changed="choose"/></template>
<template #detail>
<KbxFormSection title="기본정보"><KbxFormGrid><KbxInput v-model="form.code" label="품목코드" required :readonly="Boolean(form.id)||state('code')" :error="errors.find(x=>x.field==='code')?.message" @update:model-value="touch"/><KbxInput v-model="form.name" label="품목명" required :readonly="state('name')" :error="errors.find(x=>x.field==='name')?.message" @update:model-value="touch"/><KbxInput v-model="form.categoryName" label="품목그룹" :readonly="state('categoryName')" @update:model-value="touch"/><KbxInput v-model="form.specification" label="규격" :readonly="state('specification')" @update:model-value="touch"/><KbxInput v-model="form.unit" label="단위" :readonly="state('unit')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
<KbxFormSection title="물류정보"><KbxFormGrid><KbxLookup v-model="form.defaultWarehouseId" entity="warehouse" label="기본창고" :readonly="state('defaultWarehouseId')" @selected="touch"/><KbxBarcodeField v-model="form.barcode" label="바코드" :readonly="state('barcode')" @update:model-value="touch"/><KbxCheckbox v-model="form.lotManaged" label="LOT 관리" :disabled="state('lotManaged')" @update:model-value="touch"/><KbxCheckbox v-model="form.expiryManaged" label="유통기한 관리" :disabled="state('expiryManaged')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
</template>
</KbxMasterPage>
</template>
@@ -1,30 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { KbxLookupItem } from '@kbx/contracts'
import { kbxFieldReadonly, resolveKbxRecordStatePolicy } from '@kbx/contracts'
import { KbxDataGrid,KbxDateField,KbxFormGrid,KbxFormSection,KbxFormSpan,KbxLookup,KbxLookupDialog,KbxInput,KbxSectionHeader,KbxTransactionPage,useKbxPageShortcuts } from '@kbx/ui'
import { orderLineColumns, orderRegisterScreen, orderStatePolicies, orderWorkflow, type OrderLineForm } from './order-register.definition'
import { useKbxWorkspaceBinding } from '../../../../shell/useKbxWorkspaceBinding'
import { useOrderRegistration } from './useOrderRegistration'
const route=useRoute();const vm=useOrderRegistration();useKbxWorkspaceBinding(vm.dirty,async()=>{await vm.save();return !vm.dirty.value})
const itemLookupOpen=ref(false);const activeLine=ref<OrderLineForm|null>(null);const statePolicy=computed(()=>resolveKbxRecordStatePolicy(orderStatePolicies,vm.status.value));const readonly=computed(()=>statePolicy.value.editability==='readonly')
function fieldReadonly(field:string){return kbxFieldReadonly(statePolicy.value,field)}
function openGridLookup(event:{row:OrderLineForm;entity:string}){if(event.entity!=='item'||readonly.value)return;activeLine.value=event.row;itemLookupOpen.value=true}
function selectGridItem(item:KbxLookupItem<string>){if(activeLine.value)vm.applyItemLookup(activeLine.value,item)}
const summaryItems=computed(()=>[{key:'items',label:'품목',value:`${vm.lines.value.length.toLocaleString('ko-KR')}`},{key:'qty',label:'총수량',value:vm.totalQty.value},{key:'amount',label:'주문금액',value:`${vm.totalAmount.value.toLocaleString('ko-KR')}`,emphasis:true}])
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
async function executeCommand(id:string){if(id==='save')await vm.save();if(id==='new')vm.createNew();if(id==='confirm')await vm.confirm()}
onMounted(async()=>{const id=String(route.params.orderId??'');if(id)await vm.load(id)})
</script>
<template>
<KbxTransactionPage :screen="orderRegisterScreen" :status="vm.status.value" :dirty="vm.dirty.value" :version="vm.version.value" :errors="vm.errors.value" :workflow="orderWorkflow" :conflict="vm.conflict.value" :audit-entries="vm.auditEntries.value" :summary-items="summaryItems" breadcrumb="OMS > 주문" @command="executeCommand" @transition="executeCommand" @reload-conflict="vm.reloadConflict" @dismiss-conflict="vm.dismissConflict">
<template #header>
<KbxFormSection title="기본정보"><KbxFormGrid><KbxDateField v-model="vm.header.orderDate" label="주문일" required :readonly="fieldReadonly('orderDate')" :error="vm.fieldError('orderDate')" @update:model-value="vm.touch"/><KbxLookup v-model="vm.header.customerId" entity="customer" label="거래처" required :readonly="fieldReadonly('customerId')" :error="vm.fieldError('customerId')" @selected="vm.touch"/><KbxLookup v-model="vm.header.warehouseId" entity="warehouse" label="출고창고" required :readonly="fieldReadonly('warehouseId')" :error="vm.fieldError('warehouseId')" @selected="vm.touch"/><KbxInput v-model="vm.header.receiverName" label="수취인" required :readonly="fieldReadonly('receiverName')" :error="vm.fieldError('receiverName')" @update:model-value="vm.touch"/><KbxInput v-model="vm.header.phone" label="연락처" required :readonly="fieldReadonly('phone')" :error="vm.fieldError('phone')" @update:model-value="vm.touch"/></KbxFormGrid></KbxFormSection>
<KbxFormSection title="배송정보"><KbxFormGrid><KbxInput v-model="vm.header.postalCode" label="우편번호" :readonly="fieldReadonly('postalCode')" @update:model-value="vm.touch"/><KbxFormSpan span="full"><KbxInput v-model="vm.header.address1" label="주소" required :readonly="fieldReadonly('address1')" :error="vm.fieldError('address1')" @update:model-value="vm.touch"/></KbxFormSpan><KbxFormSpan span="full"><KbxInput v-model="vm.header.address2" label="상세주소" :readonly="fieldReadonly('address2')" @update:model-value="vm.touch"/></KbxFormSpan></KbxFormGrid></KbxFormSection>
</template>
<template #detail><section class="order-lines"><KbxSectionHeader title="상품" :count="vm.lines.value.length"/><KbxDataGrid :rows="vm.lines.value" :columns="orderLineColumns" row-key="clientId" selection="multiple" :errors="vm.errors.value" :editable="!readonly" :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="vm.addLine" @row-duplicate-requested="vm.duplicateLines" @cell-changed="vm.onCellChanged" @lookup-requested="openGridLookup"/></section></template>
</KbxTransactionPage>
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem"/>
</template>
<style scoped>.order-lines{min-height:var(--kbx-grid-min-height);display:flex;flex-direction:column;gap:var(--kbx-space-2)}</style>
@@ -1,51 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { KbxDataGrid, KbxListPage, KbxQuickFilterBar, KbxSearchPanel, KbxSummaryBar, useKbxPageShortcuts } from '@kbx/ui'
import { orderColumns, orderListScreen, orderSearchFields } from './order-list.definition'
import { useOrderSearch } from './useOrderSearch'
import { useKbxExperiment } from '../../../../experiments/kbxExperimentClient'
const vm=useOrderSearch();const router=useRouter()
const exceptionSummaryExperiment=useKbxExperiment(orderListScreen.id,orderListScreen.version,'exp.oms.order-list.exception-summary-v2','information-emphasis')
useKbxPageShortcuts([{key:'F3',execute:vm.executeSearch}])
async function selectQuickFilter(key:string){
vm.search.exceptionOnly=false
vm.search.status=null
if(key==='new')vm.search.status='NEW'
if(key==='ready')vm.search.status='READY'
if(key==='exceptions')vm.search.exceptionOnly=true
await vm.executeSearch()
}
const quickFilters=computed(()=>{
const c=vm.result.value?.counters
if(!c)return []
return [
{key:'all',label:'전체',count:c.all,active:!vm.search.status&&!vm.search.exceptionOnly},
{key:'new',label:'신규',count:c.new,active:vm.search.status==='NEW'},
{key:'ready',label:'출고대기',count:c.readyToShip,active:vm.search.status==='READY'},
{key:'exceptions',label:'오류',count:c.exceptions,active:vm.search.exceptionOnly,tone:'danger' as const},
]
})
const pageContext=computed(()=>vm.result.value?{label:`${vm.appliedSearch.value.from} ~ ${vm.appliedSearch.value.to}`,hint:vm.appliedSearch.value.exceptionOnly?'예외 주문만 조회 중':'현재 적용된 조회조건 기준',metrics:[{key:'result',label:'조회',value:vm.result.value.totalCount},{key:'selected',label:'선택',value:vm.selectionCount.value},{key:'exceptions',label:'오류',value:vm.result.value.counters.exceptions,tone:'danger' as const,emphasis:vm.result.value.counters.exceptions>0}]}:null)
const summaryItems=computed(()=>vm.result.value?[
{key:'rows',label:'조회',value:`${vm.result.value.totalCount.toLocaleString('ko-KR')}`},
{key:'selected',label:'선택',value:`${vm.selectionCount.value.toLocaleString('ko-KR')}`},
{key:'qty',label:'수량',value:vm.result.value.totalQty},
{key:'amount',label:'금액',value:`${vm.result.value.totalAmount.toLocaleString('ko-KR')}`,emphasis:true},
]:[])
</script>
<template>
<KbxListPage :screen="orderListScreen" :selection-count="vm.selectionCount.value" :context="pageContext" :content-state="vm.error.value?'error':vm.loading.value&&!vm.result.value?'loading':!vm.result.value?'idle':vm.result.value.totalCount===0?'empty':'ready'" :refreshing="vm.loading.value&&Boolean(vm.result.value)" breadcrumb="OMS > 주문" @command="vm.executeCommand">
<template #search><KbxSearchPanel :model-value="vm.search" :fields="orderSearchFields" @update:model-value="value=>Object.assign(vm.search,value)" @search="vm.executeSearch"/></template>
<template #quick-filter>
<div v-if="vm.result.value&&exceptionSummaryExperiment.is('exception-summary')" class="experiment-summary"><strong>확인 필요</strong> · 오류 {{vm.result.value.counters.exceptions.toLocaleString()}} 기존 조회·처리 문법은 그대로 유지됩니다.</div>
<KbxQuickFilterBar v-if="quickFilters.length" :items="quickFilters" @select="selectQuickFilter"/>
</template>
<template #content><KbxDataGrid :rows="vm.rows.value" :columns="orderColumns" row-key="id" selection="multiple" :loading="vm.loading.value" :total-count="vm.result.value?.totalCount??0" :selection-state="vm.selectionState.value" allow-all-filtered-selection @selection-state-changed="value=>vm.selectionState.value=value" @row-double-clicked="row=>router.push({name:'oms-order-edit',params:{orderId:row.id}})"/></template>
<template #summary><KbxSummaryBar v-if="summaryItems.length" :items="summaryItems"/></template>
</KbxListPage>
</template>
<style scoped>.experiment-summary{margin-bottom:var(--kbx-space-1);padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
@@ -1,88 +0,0 @@
import { computed, reactive, ref } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useRouter } from 'vue-router'
import type { KbxSelectionState } from '@kbx/contracts'
import { toKbxBulkSelectionRequest } from '@kbx/ui'
import { orderApi, type OrderBulkFilter, type OrderSearchFilter } from './orderApi'
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
import { orderListScreen } from './order-list.definition'
function todayIso() { return new Date().toISOString().slice(0, 10) }
export function useOrderSearch() {
const router = useRouter()
const queryClient = useQueryClient()
const selectionState = ref<KbxSelectionState<string>>({ mode:'explicit', selectedIds:[] })
const search = reactive<OrderSearchFilter>({
from: todayIso(), to: todayIso(), channelId: null, status: null,
exceptionOnly: false, keyword: '', page: 1, pageSize: 100,
})
const appliedSearch = ref<OrderSearchFilter>({ ...search })
const query = useQuery({
queryKey: computed(() => ['oms', 'orders', appliedSearch.value]),
queryFn: () => orderApi.search({ ...appliedSearch.value }),
enabled: false,
})
const shipMutation = useMutation({
mutationFn: (selection: ReturnType<typeof currentBulkSelection>) => orderApi.ship(selection),
onSuccess: async () => {
selectionState.value = { mode:'explicit', selectedIds:[] }
await queryClient.invalidateQueries({ queryKey: ['oms', 'orders'] })
await query.refetch()
},
})
const selectionCount = computed(() => selectionState.value.mode === 'all-filtered'
? Math.max((query.data.value?.totalCount ?? 0) - (selectionState.value.excludedIds?.length ?? 0), 0)
: selectionState.value.selectedIds.length)
function currentBulkFilter(): OrderBulkFilter {
return {
from: appliedSearch.value.from, to: appliedSearch.value.to, channelId: appliedSearch.value.channelId,
status: appliedSearch.value.status, exceptionOnly: appliedSearch.value.exceptionOnly, keyword: appliedSearch.value.keyword,
}
}
function currentBulkSelection() {
return toKbxBulkSelectionRequest(selectionState.value, currentBulkFilter())
}
async function executeSearch() {
const started=performance.now();selectionState.value={mode:'explicit',selectedIds:[]};appliedSearch.value={...search}
kbxTelemetry.track('command.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{commandId:'search',operationKind:'query'}})
kbxTelemetry.track('search.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{resultBucket:'requested'}})
await query.refetch()
kbxTelemetry.track('command.succeeded',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,durationMs:Math.round(performance.now()-started),attributes:{commandId:'search',operationKind:'query'}})
}
async function executeCommand(commandId: string) {
const handlers: Record<string, () => unknown | Promise<unknown>> = {
search: executeSearch,
new: () => router.push({ name: 'oms-order-new' }),
ship: async () => {
const task=startKbxTask(orderListScreen.id,orderListScreen.version,'ship-orders');task.interaction('bulk-command','ship')
kbxTelemetry.track('grid.bulk_action',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,taskSessionId:task.id,attributes:{commandId:'ship',countBucket:selectionCount.value<10?'1-9':selectionCount.value<100?'10-99':'100+',selectionMode:selectionState.value.mode}})
try{await shipMutation.mutateAsync(currentBulkSelection());task.complete('success')}catch(error){task.abandon('failed');throw error}
},
hold: () => Promise.resolve(),
excel: () => Promise.resolve(),
}
return handlers[commandId]?.()
}
return {
search,
appliedSearch,
rows: computed(() => query.data.value?.items ?? []),
result: computed(() => query.data.value),
loading: computed(() => query.isFetching.value),
error: computed(() => query.error.value),
selectionState,
selectionCount,
executeSearch,
executeCommand,
}
}
@@ -1,11 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
import { countingScreen } from './counting.definition'
const online=ref(true);const stage=ref<'location'|'item'|'counted'>('location');const location=ref('');const item=ref('');const counted=ref(0);const bookQty=ref(12);const msg=ref('실사 위치를 스캔하세요.')
function scan(v:string){if(stage.value==='location'){location.value=v;stage.value='item';msg.value='상품을 스캔하세요.';return}if(stage.value==='item'){item.value=v;counted.value++;msg.value='계속 스캔하거나 수량 확정하세요.'}}
function finish(){stage.value='counted';msg.value=counted.value===bookQty.value?'장부수량과 일치합니다.':'차이가 있어 관리자 확인 대상으로 등록합니다.'}
</script>
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :online="online"><p class="msg">{{msg}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>{{item}}</strong><p>실사 {{counted}} · 장부 {{bookQty}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'위치 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" @click="finish"/><KbxWmsActionButton v-if="stage==='counted'" label="다음 위치" @click="stage='location';location='';item='';counted=0;msg='실사 위치를 스캔하세요.'"/></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.box{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.box small{display:block;color:var(--kbx-color-text-muted)}.box strong{font-size:28px}.box p{font-size:20px;font-weight:650}</style>
@@ -1,10 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
import { putawayScreen } from './putaway.definition'
const online=ref(true);const stage=ref<'item'|'location'|'done'>('item');const item=ref('');const suggested=ref('A-01-03');const location=ref('');const msg=ref('적치할 상품을 스캔하세요.')
function scan(v:string){if(stage.value==='item'){item.value=v;stage.value='location';msg.value=`추천 위치 ${suggested.value}를 스캔하세요.`;return}if(stage.value==='location'){if(v!==suggested.value){msg.value=`다른 위치입니다. ${suggested.value}로 이동하세요.`;return}location.value=v;stage.value='done';msg.value='적치가 완료되었습니다.'}}
</script>
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'완료':'적치중'" :online="online"><p class="msg">{{msg}}</p><div v-if="item" class="item"><small>상품</small><strong>{{item}}</strong></div><div v-if="stage!=='item'" class="location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'상품 스캔':'위치 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='done'" label="다음 상품" @click="stage='item';item='';location='';msg='적치할 상품을 스캔하세요.'"/></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.item,.location{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.item small,.location small{display:block;color:var(--kbx-color-text-muted)}.item strong{font-size:22px}.location strong{font-size:32px}</style>
@@ -1,12 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { KbxBarcodeCapture, KbxWmsActionButton, KbxWmsMobilePage } from '@kbx/ui'
import { receivingScreen } from './receiving.definition'
const props=defineProps<{taskId:string}>(); const online=ref(true); const stage=ref<'po'|'item'|'qty'|'completed'>('po'); const poNo=ref(''); const item=ref({code:'',name:'',expected:0,received:0}); const message=ref('입고예정번호 또는 ASN을 스캔하세요.')
const progress=computed(()=>stage.value==='completed'?'완료':stage.value==='po'?'입고대기':'검수중')
function scan(value:string){ if(stage.value==='po'){poNo.value=value; stage.value='item'; message.value='상품을 스캔하세요.'; return} if(stage.value==='item'){item.value={code:value,name:'스캔 품목',expected:10,received:1}; stage.value='qty'; message.value='수량을 확인하세요.'}}
function complete(){stage.value='completed';message.value='입고 검수가 완료되었습니다.'}
</script>
<template><KbxWmsMobilePage :screen="receivingScreen" :progress="progress" :online="online"><p class="msg">{{message}}</p><section v-if="poNo" class="card"><small>입고예정</small><strong>{{poNo}}</strong></section><section v-if="item.code" class="card"><small>상품</small><strong>{{item.name}}</strong><span>{{item.code}}</span><div class="qty">예정 {{item.expected}} · 검수 {{item.received}}</div></section><KbxBarcodeCapture v-if="stage==='po'||stage==='item'" :enabled="online" :label="stage==='po'?'입고예정 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='qty'" label="검수 완료" @click="complete"/><KbxWmsActionButton v-else-if="stage==='completed'" label="다음 입고" @click="stage='po';poNo='';item={code:'',name:'',expected:0,received:0}"/></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.card{display:flex;flex-direction:column;gap:5px;border:1px solid var(--kbx-color-border);border-radius:8px;padding:16px;margin-bottom:12px}.card small,.card span{color:var(--kbx-color-text-muted)}.card strong{font-size:24px}.qty{font-size:20px;font-weight:650}</style>
@@ -1,18 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { KbxDataGrid, KbxQueuePage, KbxSearchPanel } from '@kbx/ui'
import { routeForWmsTask, searchWmsWork } from './workApi'
import { wmsWorkColumns, wmsWorkScreen, wmsWorkSearchFields, type WmsWorkRow } from './work.definition'
const router=useRouter();const search=reactive({taskType:'',status:'READY',owner:'mine',keyword:''});const rows=ref<WmsWorkRow[]>([]);const selection=ref<WmsWorkRow[]>([]);const loading=ref(false);const searched=ref(false);const error=ref<unknown>(null);const activeExceptionKey=ref<string|null>(null)
async function executeSearch(){loading.value=true;error.value=null;try{rows.value=await searchWmsWork(search);searched.value=true}catch(cause){error.value=cause;searched.value=true}finally{loading.value=false}}
function executeCommand(id:string){if(id==='search')return executeSearch();if(id==='start'&&selection.value[0])return router.push(routeForWmsTask(selection.value[0]))}
const summaryItems=computed(()=>[
{key:'total',label:'전체 작업',value:rows.value.length},
{key:'ready',label:'대기',value:rows.value.filter(x=>x.status==='READY').length},
{key:'blocked',label:'예외',value:rows.value.filter(x=>x.status==='BLOCKED').length,emphasis:true},
])
const exceptionCounters=computed(()=>[{key:'blocked',label:'확인 필요한 작업',count:rows.value.filter(x=>x.status==='BLOCKED').length,severity:'warning' as const}])
async function filterException(key:string|null){activeExceptionKey.value=key;search.status=key==='blocked'?'BLOCKED':'READY';await executeSearch()}
</script>
<template><KbxQueuePage :screen="wmsWorkScreen" :selection-count="selection.length" :content-state="error?'error':loading&&!searched?'loading':!searched?'idle':rows.length===0?'empty':'ready'" :refreshing="loading&&searched" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" breadcrumb="WMS > 작업관리" @command="executeCommand" @exception-filter="filterException"><template #search><KbxSearchPanel v-model="search" :fields="wmsWorkSearchFields" @search="executeSearch"/></template><template #content><KbxDataGrid :rows="rows" :columns="wmsWorkColumns" row-key="taskId" selection="single" :loading="loading" @selection-changed="selection=$event" @row-double-clicked="row=>router.push(routeForWmsTask(row))"/></template></KbxQueuePage></template>
@@ -1,12 +0,0 @@
import type { WmsWorkRow } from './work.definition'
export interface WmsWorkSearch { taskType?: string; status?: string; owner?: string; keyword?: string }
export async function searchWmsWork(_search: WmsWorkSearch): Promise<WmsWorkRow[]> { return [] }
export function routeForWmsTask(row: WmsWorkRow): string {
switch (row.taskType) {
case 'RECEIVING': return `/wms/receiving/${row.taskId}`
case 'PUTAWAY': return `/wms/putaway/${row.taskId}`
case 'PICKING': return `/wms/picking/${row.taskId}`
case 'COUNTING': return `/wms/counting/${row.taskId}`
default: return `/wms/work?task=${encodeURIComponent(row.taskId)}`
}
}
@@ -1,17 +0,0 @@
import type { KbxScreenPreference } from '@kbx/contracts'
const PREFIX = 'kbx.screen.preference.'
export function loadScreenPreference(screenId: string): KbxScreenPreference | null {
const raw = localStorage.getItem(PREFIX + screenId)
if (!raw) return null
try { return JSON.parse(raw) as KbxScreenPreference } catch { return null }
}
export function saveScreenPreference(preference: KbxScreenPreference) {
localStorage.setItem(PREFIX + preference.screenId, JSON.stringify(preference))
}
export function clearScreenPreference(screenId: string) {
localStorage.removeItem(PREFIX + screenId)
}
@@ -1,8 +0,0 @@
{
"foundationIteration": 36,
"contractVersion": "1.22.0",
"baseline": "v21",
"declaredChangeLevel": "major",
"releaseMode": "recipe-verification-generated-test-plan-home-attention-ordering-hardening",
"notes": "v36 closes T01-T09 recipes to executable verification evidence. Every recipe now declares required scenario kinds, behavioral tags, evidence classes, and stable verification checks; generated verification proves 9/9 recipe coverage and requires a representative E2E scenario on a screen that actually uses each template. T02/T06/T07 gain canonical E2E recovery scenarios. The secure scaffolder now emits a typed per-screen recipe test plan beside each new Vertical Slice, while keeping explicit write-permission fail-closed behavior. Home attention becomes deterministic and operationally accurate: business priority remains dirty -> failed/partial job -> urgent notification -> running job -> ordinary notification, items within one priority are newest-first, and overflow reports the real authorized total instead of only the visible slice. Screen recipe test-profile and canonical-scenario changes are now release-impact tracked."
}
@@ -1,43 +0,0 @@
<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>
@@ -1,36 +0,0 @@
<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>
@@ -1,40 +0,0 @@
<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>
@@ -1,34 +0,0 @@
<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>
@@ -1,28 +0,0 @@
<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
@@ -1,50 +0,0 @@
<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>
@@ -1,18 +0,0 @@
<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>
@@ -1,63 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const sourcePath = path.join(root, 'packages/kbx-ui/src/tokens/source/kbx.tokens.json')
const cssPath = path.join(root, 'packages/kbx-ui/src/tokens/kbx.css')
const manifestPath = path.join(root, 'generated/token-manifest.json')
const figmaPath = path.join(root, 'design/figma/variables.contract.json')
const source = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
const byPath = new Map(source.tokens.map(t => [t.path, t]))
function cssValue(value) {
const ref = /^\{(.+)\}$/.exec(value)
if (!ref) return value
const token = byPath.get(ref[1])
if (!token) throw new Error(`Unknown token reference: ${value}`)
return `var(${token.css})`
}
const roles = ['primitive', 'semantic', 'component']
const lines = [':root {']
for (const role of roles) {
lines.push(` /* ${role[0].toUpperCase()}${role.slice(1)} tokens */`)
for (const token of source.tokens.filter(t => t.role === role)) {
lines.push(` ${token.css}: ${cssValue(token.value)};`)
}
lines.push('')
}
lines.push('}')
lines.push('')
lines.push('html, body, button, input, select, textarea {')
lines.push(' font-family: var(--kbx-font-family);')
lines.push('}')
lines.push('')
for (const [mode, overrides] of Object.entries(source.modes ?? {})) {
if (mode === 'compact') continue
lines.push(`[data-kbx-density="${mode}"] {`)
for (const [tokenPath, value] of Object.entries(overrides)) {
const token = byPath.get(tokenPath)
if (!token) throw new Error(`Unknown mode token: ${tokenPath}`)
lines.push(` ${token.css}: ${value};`)
}
lines.push('}')
lines.push('')
}
lines.push(':focus-visible {')
lines.push(' outline: 2px solid var(--kbx-color-focus);')
lines.push(' outline-offset: 2px;')
lines.push('}')
lines.push('')
fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
fs.mkdirSync(path.dirname(figmaPath), { recursive: true })
fs.writeFileSync(cssPath, lines.join('\n'))
fs.writeFileSync(manifestPath, JSON.stringify({version: source.version, tokens: source.tokens, modes: source.modes}, null, 2) + '\n')
const collections = {
'KBX Primitive': source.tokens.filter(t => t.role === 'primitive').map(t => ({name:t.path, type:t.type, value:t.value, description:t.description ?? ''})),
'KBX Semantic': source.tokens.filter(t => t.role === 'semantic').map(t => ({name:t.path, type:t.type, value:t.value, description:t.description ?? ''})),
'KBX Component': source.tokens.filter(t => t.role === 'component').map(t => ({name:t.path, type:t.type, value:t.value, description:t.description ?? '', modes:Object.fromEntries(Object.entries(source.modes ?? {}).filter(([,o]) => t.path in o).map(([mode,o]) => [mode,o[t.path]]))}))
}
fs.writeFileSync(figmaPath, JSON.stringify({schemaVersion:'1.0', tokenVersion:source.version, collections}, null, 2) + '\n')
console.log(`Generated ${source.tokens.length} design tokens -> CSS / token manifest / Figma variable contract`)
@@ -0,0 +1,355 @@
# KBX Foundation v50 — FE Operational Truth · Retryability · Home Overflow · Theme Hardening
## v50 핵심
- T01~T09 주요 Template에 `errorRetryable` 계약을 전파해 영구 오류에서 무의미한 `[다시 조회]` 제거
- `KbxTemplateStateBoundary 1.3.0`: transient error와 non-retryable error를 UI 계약으로 분리
- `WMS-WORK-001 1.2.0`: `WMS_WORK_QUERY_NOT_CONNECTED``retryable:false`로 수정, API 미연결에 무한 Retry 착시 제거
- Home Attention: 기본 8개 유지, 총 건수 >24이면 `전체`가 아니라 `우선 24개 보기`로 정확히 표현
- 24개 이후 남은 Attention은 `작업 센터 전체 / 알림 센터 전체`로 직접 연결
- `KbxHomePage 1.10.0`, `KbxQueuePage 1.9.0`, 기타 T01~T09 Template 공개 Retry 계약 Minor Upgrade
- Reference HTML/JavaScript도 28개 fixture로 8→24 bounded disclosure와 Center navigation 재현
- KBX Business Light/Dark + Forced Colors semantic theme 유지, Theme는 Retry/Permission/Domain truth를 결정하지 않음
- Browser Reference QA **16/16 PASS**, 전체 `validate:kbx` PASS, Design Debt **116 <= 131**
상세: `docs/frontend/KBX-FE-QA-Hardening-v50.md`
검증: `docs/validation-report-v50.md`
---
# KBX Foundation v49 — FE Workbench · WMS Truthful Boundary · Theme Hardening
## v49 핵심
- Home `확인 필요`가 상위 8개만 숨긴 채 끝나지 않도록 `8개 ↔ 전체(최대 24개)` 접근성 Disclosure 추가
- Home 우선업무 출처를 미저장/실패/중요알림/진행/알림으로 요약해 AX 판단량 감소
- `KbxQueuePage 1.8.0`: T06 Empty/Error/Retry copy와 Empty recovery Action 계약 추가
- `WMS-WORK-001 1.1.0`: Demo에서는 실제 필터 가능한 Queue, 일반 Runtime에서는 미연결 API를 `0건`으로 가장하지 않고 Integration Problem으로 fail-explicit
- Receiving/Putaway/Counting `1.1.0`: client-only 성공 상태를 Production에서 차단하고 Demo Mode에서만 FE scanner state machine 검증
- `KbxWmsMobilePage 1.7.0`: Production execution 차단 시 빈 sticky action footer를 숨기는 `actionsVisible` 계약
- WMS 네트워크 표시를 `useKbxNetworkState()`로 통일하고 hardcoded online 제거
- Reference Home도 동일한 8→전체 Priority disclosure와 KBX Business Light/Dark semantic theme 적용
- Browser Reference QA **10/10 PASS**: Attention disclosure, keyboard collapse, Dark theme, 1280×720 overflow 0, runtime error 0
- WMS에서 Server validation / Idempotency / Audit 이전의 성공표시는 금지한다는 경계를 FE Gate로 고정
상세: `docs/frontend/KBX-FE-QA-Hardening-v49.md`
검증: `docs/validation-report-v49.md`
---
# KBX Foundation v48 — FE Template Completion · Real Search/File · Theme-safe Recovery Hardening
## v48 핵심
- 실제 Vue T04/T06/T07/T08에 `KbxProblemFeedback` 기반 Actionable Problem Recovery 확장
- T04~T08 Template 계층의 `problem / retry / dismiss` 계약 통일
- `KbxProblemFeedback 1.1.0`: 상위 Validation Problem의 bounded summary 추가, Field/Cell Validation 역할은 유지
- Reference T05/T06/T07: Toast/표시 위주의 조회를 실제 검색 → Loading → 결과/Empty → 복구 상태 전이로 교체
- Chromium QA에서 발견한 Search Panel Mouse no-op 결함 수정: T05/T06/T07 `[조회]` click과 Enter/F3 동일 실행
- Reference T08: 샘플 파일명 주입 제거, 실제 Browser File input/Drop + `.xlsx/.xls` + 20MB guard
- T08 6단계(`파일→매핑→검증→미리보기→반영→결과`)와 CSS 6-column 정합화
- KBX Business Light/Dark semantic theme, Home/Workspace, Permission/Domain server-authority 경계 유지
- Browser Reference QA **30/30 PASS**: T05~T08 completion loops, Dark theme, 1280px overflow 0, runtime error 0
상세: `docs/frontend/KBX-FE-QA-Hardening-v48.md`
검증: `docs/validation-report-v48.md`
---
# KBX Foundation v47 — Actionable Problem Recovery · Theme-safe FE Hardening
## v47 핵심
- 실제 T01/T02/T03에서 Command 실패를 조회 Error와 분리: `business-rule / permission / not-found / integration / system` 인라인 Problem Surface
- `KbxProblemFeedback 1.0.0`: 문제 유형 텍스트, 원인, 참조번호, retryable 재시도, 닫기
- Server `problem.actions`는 기본 미노출 + 명시적 allow-list만 렌더링하여 임의 Action ID → FE Command 실행 차단
- `OMS-ORD-001` Bulk 출고, `OMS-ORD-002` 저장/확정, `ERP-MST-ITEM-001` 목록 로드/저장/사용중지 실패 시 현재 화면·선택·입력 Context 유지
- `KbxListPage 1.8.0`, `KbxMasterPage 1.7.0`, `KbxTransactionPage 1.7.0`
- Reference HTML/JavaScript: 예외 주문 출고지시 → `업무 규칙` 인라인 오류 → 문제 주문만 선택 복구
- Browser Reference QA **12/12 PASS**: Dark Theme, 문제표시/복구/닫기, 1280px overflow 0, runtime error 0
- 전체 Problem 계약에서도 Validation은 Field/Cell, Conflict는 최신값 비교, Command Problem은 인라인 복구로 역할 분리
상세: `docs/frontend/KBX-FE-QA-Hardening-v47.md`
검증: `docs/validation-report-v47.md`
---
# KBX Foundation v46 — Safe Workspace Resume · T09 Recovery · Theme-preserving FE Hardening
## v46 핵심
- 열린 Workspace의 **clean 업무 위치만 sessionStorage**에 복원, Query/Hash/Dirty Form·Grid 값은 저장 금지
- 복원 전 Permission + Screen Registry + Safe Route 재검증, `resumed` 상태는 실제 업무 진입 시 제거
- Home `바로 시작`에서 `복원` 업무를 구분하고 미저장 편집은 안전상 복원 제외 사실만 표시
- Home에서 active tab이 없어도 첫 Workspace Tab을 keyboard roving entry로 제공
- Navigation Preference의 최초 localStorage read까지 fail-soft로 통일
- `KbxWorkspaceTabs 1.3.0`, `KbxHomePage 1.8.0`, `KbxWmsMobilePage 1.6.0`
- T09 WMS Picking: TanStack Query Loading/Error/Ready → 공통 Mobile State Boundary, 오류 `[작업 다시 불러오기]` 실제 refetch 연결
- Reference HTML/CSS/JavaScript Browser QA **20/20 PASS**: clean resume, dirty exclusion, storage-blocked Shell, Dark Theme, 1280px overflow
- Theme/Permission/Domain 경계는 유지: Theme/Resume는 FE 표현·생산성 상태이며 업무 truth가 아님
- 전체 `validate:kbx` PASS: 153 tokens / 20 screens / 84 components / 69 catalog entries / 306 TS·Vue units / Design Debt **116 <= 131**
상세: `docs/frontend/KBX-FE-QA-Hardening-v46.md`
검증: `docs/validation-report-v46.md`
---
# KBX Foundation v45 — Safe Preference · Home Context · Theme Hardening
## v45 핵심
- 실제 Vue `OMS-ORD-001``기본조건 저장 / 마지막 조회조건 기억`을 연결하되 자유검색어는 localStorage 저장에서 제외
- Screen Preference key를 `Tenant/User scope + ScreenId + ScreenVersion`으로 격리하고 size/type 검증 + fail-soft 적용
- Home의 실패/진행 작업 클릭 시 Runtime Center 전체가 아니라 해당 Operation/Notification으로 Focus
- `KbxSearchPanel 1.4.0`, `KbxOperationCenter 1.1.0`, `KbxNotificationCenter 1.1.0`
- HTML/JavaScript Reference Lab에도 동일한 안전한 조회조건 기억과 Light/Dark Theme parity 적용
- Browser QA 14/14: 저장/복원, 자유검색어 비저장, Dark Theme, 1280 Home overflow, runtime error 0
- Domain Rule·Permission·Concurrency·Audit은 여전히 Server authority이며 Preference는 표현/생산성 상태에 한정
상세: `docs/frontend/KBX-FE-QA-Hardening-v45.md`
검증: `docs/validation-report-v45.md`
## v44 핵심
- 실제 App artifact version `v44-fe-theme-state`로 정합화
- Header 작업/알림 trigger ↔ runtime panel `aria-expanded/aria-controls` 연결
- runtime panel: 명시 제목/닫기, Esc 닫기, open 시 panel focus, close 시 trigger focus restore
- route 전환/메뉴검색/Utility Rail 진입 시 non-modal runtime panel을 정리해 surface stacking 방지
- Windows Forced Colors에서 Light/Dark palette 대신 OS system color로 fail-safe
- Forced Colors에서도 현재 화면/선택/Focus를 색상 하나가 아니라 outline/텍스트 상태로 유지
- T01 Empty → `[조회조건 초기화]` → 즉시 재조회 공통 Recovery contract
- FE Reference Lab 작업/알림을 실제 Vue와 동일한 non-modal System Panel로 정렬
- FE Reference Lab에도 동일 high-contrast bridge 적용
- Browser matrix **28/28 PASS**: T01~T09 Light/Dark, T01 Empty recovery, 1280px, Forced Colors, runtime error 0
- v44 전용 static QA gate를 전체 `validate:kbx`에 편입
상세: `docs/frontend/KBX-FE-QA-Hardening-v44.md`
검증: `docs/validation-report-v44.md`
---
# KBX Foundation v43 — Semantic Theme · Home Navigation Hierarchy · Light/Dark FE Hardening
이번 iteration은 v42의 first-class Vue runtime 위에 **KBX Business Light / Dark를 실제 업무 Semantic Theme으로 통합**한다. Theme를 장식용 팔레트로 만들지 않고 `surface / text / border / primary / success / warning / danger / focus / AI` 의미 계약을 Application Shell, Home, PrimeVue, AG Grid, 공통 Component, FE Reference Lab까지 공유한다.
## v43 핵심
- Design Token source 1.9.0: `KBX Business Light / Dark` semantic override 계약
- Theme runtime API + `light|dark` allow-list/fail-safe preference persistence
- Global Header에 명시적 Light/Dark 전환 + 접근성 label + narrow-header reflow
- PrimeVue dark selector와 KBX theme root 연결
- AG Grid Quartz foreground/background/accent/border/header/hover/selection을 KBX semantic token으로 연결
- Home: `확인 필요 → 바로 시작 → 모듈 업무`의 시각 우선순위를 warning/danger/info semantic으로 강화하되 Dashboard card 증식 금지
- Side Navigation 현재 화면 primary inset marker
- Network/Dialog/Toast/Reference surface 하드코딩 감소
- FE Reference Lab Light/Dark parity + 실제 Dark inheritance 결함 발견/수정
- Theme Browser QA **16/16 PASS**: T01~T09 Dark render, T09 WMS mobile mode, 1280px overflow 0, runtime error 0
- 전체 `validate:kbx` PASS / 305 TS·Vue script units / Design Debt **116 (ratchet 131 대비 감소)**
### v43에서 완료로 계산하지 않는 항목
첨부 표준은 Semantic Color/Density/Accessibility를 요구하지만 Light/Dark 자체를 명시적으로 강제하지 않는다. Dark는 사용자 요청에 따른 구현 확장이다. 또한 현재 실행환경은 package registry 접근이 차단되어 **실제 Vite + PrimeVue + AG Grid live-runtime Theme E2E를 통과했다고 주장하지 않는다.** Browser 증적은 실제 FE Reference HTML/CSS/JS asset 기준이며 production CSP는 별도 정적 Gate로 검증한다. Server-side full-query XLSX 역시 기존 잔여 P1이다.
상세: `docs/frontend/KBX-FE-QA-Hardening-v43.md`
검증: `docs/validation-report-v43.md`
---
# KBX Foundation v42 — First-class Vue Runtime · Secure Demo Transport · Home Context · Excel Menu · Zoom Hardening
이번 iteration은 v41의 Golden Screen parity를 넘어 **실제 FE 제품을 독립적으로 기동·빌드·검증할 수 있는 Runtime 경계**를 우선 완성한다. BE 결합을 더 늘리지 않고 `apps/web`을 Vite 8 application으로 명시하고, Demo Mode도 별도 Mock UI가 아니라 실제 Axios/kbxApi 경계의 transport adapter로 구성했다.
## v42 핵심
- `apps/web` first-class Vite 8 package / `index.html` / `main.ts` / `App.vue` / typecheck-build scripts
- Vue + Pinia + Router + TanStack Query + PrimeVue + `installKbx()` 실제 application bootstrap
- `--mode demo`에서만 fixture HTTP adapter 사용, 일반 runtime은 real transport + permission fail-closed
- Demo unknown operation은 성공 위장이 아니라 `DEMO_OPERATION_UNSUPPORTED` 명시 실패
- Home/Notification의 OMS 예외 Context를 `useOrderSearch()`가 allow-list로 hydrate하고 자동 조회
- `KbxCommandBar``KbxExcelMenu` 실제 통합, Excel 5개 표준 진입점 + keyboard/focus contract
- 일부 row만 로드된 경우 local CSV를 전체 Export로 오인하지 않도록 다운로드 차단
- 56rem/40rem Shell pressure hardening: 200% Zoom에서 Workspace 우선 보존
- v42 runtime-readiness Gate를 전체 `validate:kbx`에 편입
- 304 TS/Vue syntax + 전체 Governance PASS / Design Debt **122 (ratchet 131 대비 감소)**
### v42에서 완료로 계산하지 않는 항목
현재 실행환경은 npm registry 접근이 차단되어 pnpm 의존성 설치가 불가능하다. 따라서 **실제 Vite build/server와 Vue/PrimeVue/AG Grid Playwright를 통과했다고 주장하지 않는다.** 또한 T01의 전체 검색조건 XLSX/Background Export API는 아직 미연결이며, 부분 row CSV는 전체 Export로 오인될 수 있어 의도적으로 차단한다.
상세: `docs/frontend/KBX-FE-QA-Hardening-v42.md`
검증: `docs/validation-report-v42.md`
---
# KBX Foundation v41 — Actual Vue Golden Screen Parity · Controlled Grid Selection · Drawer/Dirty/Focus Hardening
이번 iteration은 v40의 FE Reference Lab을 더 확장하지 않고, **실제 Vue/PrimeVue/AG Grid Golden Screen이 동일한 업무 UX 계약을 지키는지**를 우선 검수한다. 대상은 `OMS-ORD-001`, `OMS-ORD-002`, `ERP-MST-ITEM-001`, `KbxDataGrid`, `KbxLookup`, `KbxUnsavedChangesDialog`, Application Shell navigation이다.
## v41 핵심
- OMS-ORD-001 주문번호 Link의 no-op 제거: 실제 Order Detail Drawer 연결
- 행 Double Click도 Drawer 우선으로 통일하여 List 조회 Context 보존
- Drawer async request sequence guard로 늦게 도착한 응답의 stale overwrite 방지
- KbxDataGrid: AG Grid 33+ multiRow selection contract, checkbox/header checkbox, 외부 Selection State ↔ 내부 Grid selection 동기화
- Grid error navigation이 Header/Form 오류를 Cell 오류로 잘못 세지 않도록 grid-scoped error 분리
- KbxDataGrid에 `focusCell`, `focusNextEditableCell`, `selectRowByKey`, `clearSelection` 제어 API 추가
- KbxLookup: 직접 코드 Enter 성공도 다음 Control로 Focus 이동, 선택/취소 Focus 정책 분리, 접근성 label 고유화
- ERP-MST-ITEM-001: Dirty 상태에서 다른 Master 선택/New 전환 시 3-way Guard로 편집값 덮어쓰기 방지
- OMS-ORD-002: F2 품목 선택 후 다음 Editable Cell Focus, New/Copy 내부 전환에도 Dirty Guard 적용
- KbxUnsavedChangesDialog: Esc/Tab/Shift+Tab Focus Trap + 닫힌 후 Focus Restore
- OMS 주문관리의 구현되지 않은 `보류` no-op Command 제거
- Side Navigation 즐겨찾기 접근성 이름과 현재 화면 상태 보강
- Component version: KbxDataGrid 1.5.0 / KbxLookup 1.4.0 / UnsavedChangesDialog 1.1.0 / SideNavigation 1.3.1
- 전체 `validate:kbx` PASS / Design Debt **131 유지**
### v41에서 완료로 계산하지 않는 항목
현재 OMS 주문조회 `엑셀` 동작은 AG Grid CSV export 수준이다. KBX 표준의 Excel 메뉴 전체 계약(현재 조회결과 Excel, 양식, 업로드, 붙여넣기, 최근 업로드)은 아직 완료가 아니므로 **잔여 P1**로 유지한다. 또한 이 산출물에는 `node_modules`가 없어 실제 Vue runtime E2E/visual regression을 실행하지 못했다. 이번 v41 증적은 TypeScript/Vue syntax transpile + 전체 Foundation governance/static parity gate이며, 실제 앱 런타임 E2E는 다음 Gate다.
상세: `docs/frontend/KBX-FE-QA-Hardening-v41.md`
검증: `docs/validation-report-v41.md`
---
# KBX Foundation v40 — FE Completion Loop · Validation-safe Shortcut · Master/Transaction/Queue/Reconcile Hardening
이번 iteration은 BE 구조나 새 Metadata Framework를 늘리지 않고, 실제 HTML/CSS/JavaScript FE에서 **T01~T09의 최소 업무 완료 루프가 닫히는지**를 기준으로 고도화한다. v39의 Navigation/Lookup/Drawer/Job/WMS 기반 위에서 T01 실제 검색, T02 Master CRUD, T03 Header+Detail 저장/확정, T06 Queue Action, T07 Reconcile Resolution을 실제 상태 전이로 연결했다.
## v40 핵심
- T01 검색조건(판매채널/상태/키워드/예외) ↔ 실제 Grid 결과 결합
- T02 Master 신규/수정/복사/검증/F2 Lookup/저장/상태/Dirty 전환
- T03 Header+Detail 입력/계산/Validation/F8 저장/작성→확정
- T06 선택 기반 작업자 배정/Wave 생성 실제 Row 갱신
- T07 선택 기반 재처리 후 Expected/Actual/Difference 및 mismatch 목록 갱신
- Global F8이 실제 화면 Save Handler를 호출하도록 수정 — Validation 우회 제거
- Hash/Refresh/Close까지 Dirty 보호 확대
- Browser QA **49/49 PASS**, Runtime Error 0
- 1280×720 document overflow 0, WMS 390×844 Full View
- Design Debt ratchet **131 유지**
상세: `docs/frontend/KBX-FE-QA-Hardening-v40.md`
검증: `docs/validation-report-v40.md`
---
# KBX Foundation v39 — FE Product Parity · Lookup · Dirty Navigation · Job/WMS Hardening
이번 iteration은 BE 구조를 늘리지 않고 실제 HTML/CSS/JavaScript와 핵심 Vue Component의 사용성을 고도화한다. v38의 상태전이 기반 위에 **Context-aware Home, 화면 이동 Dirty Guard, 실제 F2 Lookup, Utility Drawer, T08 Background Job UX, T09 Mobile Full View/중복 Scan Guard**를 추가했다.
## v39 핵심
- Home 예외 → 실제 대상 필터/Context 적용 후 이동
- Dirty 상태에서 Tab Close뿐 아니라 Side Nav/Home/다른 Tab 이동도 3-way Guard
- T02/T03 F2 Lookup: Search/Arrow/Enter/Esc/Focus Next
- 실제 `KbxLookup.vue`: 선택 후 다음 Control, 취소 시 Trigger Focus 복원
- Help/AI/Suggestion/Job/Notification 실제 Drawer + inert/Focus Trap
- T08 6단계 + Background Progress + 화면 이탈/복귀
- T09 중복 Barcode Guard + Network Failure No-Commit UX
- 767px 이하 WMS Mobile Full View
- 1280×720 및 720px zoom-pressure document overflow 없음
- Browser QA 31/31 PASS · Runtime Error 0
- Design Debt ratchet 131 유지
상세: `docs/frontend/KBX-FE-QA-Hardening-v39.md`
---
# KBX Foundation v38 — FE QA Hardening · Interaction-complete Reference Lab
이 패키지는 기존 v36 Foundation 거버넌스를 유지하면서 v37 FE Reference Lab을 **실제 상태 전이·복구·키보드·보안 실패 격리** 기준으로 고도화한 v38 작업본이다. 이번 iteration은 BE 구조를 확장하지 않고 실제 HTML/CSS/JavaScript FE 완성도에 집중한다.
## v38 핵심
- Home: 예외 우선 → 이어서 처리 → 모듈별 업무 → Ctrl+K 검색
- T01: Quick Filter/전체선택/Bulk/Drawer/Data State/선택 기반 Command disable
- T02: Master→Detail 실제 전환 + Dirty 보호
- T03: Header/Detail edit + Enter 흐름 + Dirty/Save
- T04: Fast Entry input + TSV paste + Add/Duplicate/Fill Down + error gate
- T05: 품목→Location→재고이력 동시 문맥 갱신
- T06/T07: Queue/불일치 필터 실제 적용
- T08: 파일 gate + 이전/다음 + Mapping/Validation/Commit/Result
- T09: keyboard-wedge Scan + wrong barcode recovery + quantity/exception dialog
- CSP + dynamic HTML escape + localStorage fail-soft + prompt/eval/document.write 금지
- Headless Chromium interaction evidence 및 1440×900/390px 화면 증적
- Design Debt ratchet **131 유지**
## v38 검증
```bash
node scripts/validate-fe-reference.mjs
node scripts/validate-kbx.mjs
```
상세: `docs/frontend/KBX-FE-QA-Hardening-v38.md`
---
# KBX Foundation v36 — Recipe Verification · Generated Test Plan · Deterministic Home Workbench
KBX v36는 v35의 Screen Recipe / Secure Scaffolder 기반을 실제 검증계약까지 닫는 Foundation iteration이다. 목표는 화면·컴포넌트 수를 더 늘리는 것이 아니라, T01~T09를 선택한 순간부터 표준 UX, 보안, 복구, 테스트 증적이 함께 따라오도록 만드는 것이다.
## v36 핵심
- T01~T09 `testProfile` 추가
- 필수 Scenario Kind
- 필수 Behavioral Tag
- 필수 Evidence
- 필수 Recovery/Interaction Check
- `generated/screen-recipe-verification-manifest.json` 추가
- T01~T09 Recipe Verification **9/9 자동 폐쇄성**
- 각 Recipe는 실제 해당 Template을 사용하는 Screen의 Canonical E2E Scenario를 최소 1개 요구
- T02 Master / T06 Queue / T07 Reconcile Canonical E2E Recovery Scenario 추가
- Scaffolder가 신규 Vertical Slice에 `*.test-plan.ts` 자동 생성
- 생성 Test Plan은 Canonical Scenario / Required Check / Required Evidence를 타입 계약으로 보존
- 기존 Master / Transaction / Fast Entry의 명시적 `--write-permission` fail-closed 정책 유지
- Home Attention Queue를 동일 우선순위 내 최신 발생 순으로 정렬
- Home은 표시 상한과 별개로 권한 필터 후 실제 전체 건수 및 overflow를 정확히 표시
- `buildKbxHomeAttentionQueue()` public API 추가, 기존 `buildKbxHomeAttention()` 호환 유지
- Screen Recipe `testProfile` / `canonicalScenarioIds` 변경을 Release Impact에서 추적
- Design Debt ratchet **131 유지**
## 검증
```bash
node scripts/validate-kbx.mjs
```
최종 검증 기준:
- 299 TS/Vue script units
- 20 Screen Definitions
- 84 Component Definitions
- 66 Component Catalog entries
- 153 Design Tokens
- 9 Screen Recipes
- 30 Canonical Test Scenarios
- Recipe Verification 9/9
- 16 Navigation Entries
- Design Debt 131 <= 131
- Release Impact major / major
## 문서
- `docs/screen-recipe-verification-home-attention-v36.md`
- `docs/kbx-v36-standard-traceability.md`
- `docs/validation-report-v36.md`
- `docs/release/migration-guide.md`
## FE Reference Lab (v37)
Backend 없이 실제 브라우저에서 Application Shell, 홈 네비게이션과 T01~T09를 검증하려면 `apps/web/fe-reference/`를 사용한다.
```bash
cd apps/web/fe-reference
python3 -m http.server 4173
```
FE 정적 계약 검증:
```bash
pnpm validate:fe-reference
```
상세 설계 기록: `docs/frontend/KBX-FE-Completion-v37.md`
@@ -0,0 +1 @@
VITE_KBX_DEMO_MODE=true
@@ -0,0 +1,113 @@
# KBX FE Reference Lab v50
Backend/API 없이 브라우저에서 즉시 실행하는 KBX 업무 UI 원형이다. 목적은 별도 Demo UI를 만드는 것이 아니라 **T01~T09의 실제 HTML/CSS/JavaScript 상호작용, Theme State, Keyboard, Recovery, WMS Mobile**을 빠르게 재현하는 것이다.
## 실행
```bash
cd apps/web/fe-reference
python3 -m http.server 4173
```
운영 Domain을 흉내 내는 제품이 아니다. Permission, Domain Validation, DB Commit, Audit, Concurrency, Idempotency는 Server 최종 책임이다.
## v50 Operational Truth / Home Overflow QA
- Home Attention 기본 8개, 최대 priority surface 24개
- 총 건수 >24이면 `전체 보기`라고 과장하지 않고 `우선 24개 보기`
- 남은 항목은 `작업 센터 전체 / 알림 센터 전체`로 이동
- Work Center open → Esc close → Home trigger Focus restore
- KBX Business Light/Dark에서 동일한 Label/Action/Focus 유지
- 실제 Template 코드의 non-retryable error는 `[다시 시도]`를 숨기는 공통 계약을 사용
- 증적: `docs/evidence/v50-browser-operational-truth.json` (16/16)
- Preview: `previews/v50-home-overflow-dark-1440x900.png`
## v46 검증 범위
- KBX Business Light / Dark + Forced Colors 의미 체계 유지
- clean Workspace 위치만 `sessionStorage`에 복원
- Query/Hash/Dirty Form·Grid 값은 session snapshot에서 제외
- 복원 tab `↺` + Home `복원` 표시, 실제 진입 시 resumed marker 제거
- Home/no-active-tab 상태에서도 첫 Workspace Tab keyboard entry 보장
- Storage API 차단 시 Preference/Resume 없이 fail-soft 기동
- T09 WMS common state boundary의 Error → `[작업 다시 불러오기]` recovery loop
- Browser Reference QA 20/20: clean resume, dirty exclusion, Dark Theme, 1280px overflow, runtime error 0
## v44 검증 범위
- KBX Business Light / Dark semantic theme
- Windows Forced Colors system-color fallback
- Home: 확인 필요 → 바로 시작 → 모듈별 업무
- Header: 작업/알림 **비모달 System Panel**, Esc, Focus Restore
- Help/AI/Suggestion/Detail: Drawer contract 유지
- T01: 검색 → Loading/Empty/Error/Ready → 선택/Bulk/Drawer → Empty 복구
- T02: Master CRUD + Dirty Guard + Lookup
- T03: Header/Detail + Validation-safe F8
- T04: Fast Entry/Paste/Fill Down
- T05: Master→Location→재고이력
- T06: Actionable Queue
- T07: Reconcile/Resolution
- T08: Excel Import + Background Job
- T09: 390×844 WMS Mobile + Scanner/Network/Duplicate Guard
## Theme 원칙
Light/Dark는 표현 모드이며 업무 상태나 Permission의 Source of Truth가 아니다. Forced Colors가 활성화되면 OS `Canvas`, `CanvasText`, `Highlight`, `LinkText`를 우선한다. Theme 변경으로 Layout/Density/Keyboard 문법을 변경하지 않는다.
## QA 증적
- `previews/v44-home-light-1440x900.png`
- `previews/v44-home-dark-1440x900.png`
- `previews/v44-runtime-panel-dark-1440x900.png`
- `previews/v44-t01-dark-1440x900.png`
- `previews/v44-t01-forced-colors-1440x900.png`
- `previews/v44-t09-dark-390x844.png`
- `docs/evidence/v44-theme-state-browser.json` — 14/14
- `docs/evidence/v44-browser-matrix.json` — 28/28
실행환경의 localhost/file navigation 제약 때문에 Browser QA는 동일 HTML/CSS/JS asset을 Playwright inline harness로 실행했다. 실제 Vue/Vite/PrimeVue/AG Grid runtime E2E와 혼동하지 않는다.
## 검증
```bash
node scripts/validate-fe-reference.mjs
node scripts/validate-fe-state-navigation-v44.mjs
node scripts/validate-kbx.mjs
```
## v45 Safe Preference QA
- T01 `기본조건 저장`, `마지막 조회조건 기억` 실제 JavaScript 동작
- 판매채널/상태/오류유형만 저장하고 자유검색어는 저장하지 않음
- 저장값 allow-list 복원, 잘못된 값은 기본값으로 복구
- Light/Dark Theme와 개인화 UI 동시 검증
- 증적: `docs/evidence/v45-browser-preference-theme.json` (14/14)
## v46 Safe Workspace Resume QA
- clean Workspace tab만 `sessionStorage` 복원
- Query/Hash, Dirty Form/Grid 값은 복원 snapshot에서 제외
- Home `바로 시작``복원` 상태 표시
- active tab이 없는 Home에서도 첫 Workspace tab `tabindex=0`
- Storage API 차단 시 Home/Shell fail-soft
- KBX Business Light/Dark와 resume marker semantic parity
- 증적: `docs/evidence/v46-browser-resume-theme.json` (20/20)
- Preview: `previews/v46-home-resume-dark-1440x900.png`
## v47 Actionable Problem
T01에서 예외 주문을 선택해 출고지시하면 업무규칙 Problem을 인라인으로 표시하고 `문제 N건만 선택`으로 복구한다. 단순 성공 Toast로 거짓 완료시키지 않는다. Light/Dark는 동일 semantic state를 사용한다.
## v48 Template Completion QA
- T05 재고현황: 실제 창고/품목 검색, Loading, Empty, 조건 초기화, Master→Location→History
- T06 작업 Queue: 실제 작업자/통합검색/빠른필터, Selection, Empty Recovery
- T07 Reconcile: 실제 검색/불일치 필터, Selection, Reference 재처리 상태변경
- T08 Excel Import: 실제 Browser File input/Drop, `.xlsx/.xls`/20MB guard, 6-step visual flow
- Search Panel의 조회 버튼은 Enter/F3뿐 아니라 Mouse click도 동일 실행 경로를 사용한다.
- KBX Business Light/Dark semantic theme는 동일 업무 상태와 조작 계약을 유지한다.
- Browser 증적: `docs/evidence/v48-browser-template-completion.json` (30/30)
- Preview: `previews/v48-t05-inventory-dark-1440x900.png`, `previews/v48-t08-file-dark-1440x900.png`
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" />
<meta name="referrer" content="no-referrer" />
<meta name="color-scheme" content="light dark" />
<title>KBX FE Reference Lab v52 · Operational Navigation · Screen Anatomy · T01~T09</title>
<link rel="stylesheet" href="./kbx-fe.css" />
</head>
<body>
<div id="app" class="kbx-app" data-density="compact" data-nav="expanded" data-module="COMMON">
<a class="skip-link" href="#workspace">본문 바로가기</a>
<header class="global-header">
<div class="brand-area">
<button id="homeButton" class="brand" type="button" aria-label="KBX 홈으로 이동">
<strong>KBX</strong><span>업무 플랫폼</span>
</button>
<label class="module-select"><span class="sr-only">업무 모듈</span>
<select id="moduleSelect"><option value="ALL">전체 모듈</option><option value="OMS">OMS</option><option value="ERP">ERP</option><option value="WMS">WMS</option><option value="COMMON">공통</option></select>
</label>
</div>
<button id="globalSearchButton" class="global-search" type="button" aria-keyshortcuts="Control+K">
<span>메뉴명 · 화면코드 · 업무명 검색</span><kbd>Ctrl K</kbd>
</button>
<div class="header-actions">
<button id="jobsButton" type="button" data-panel="jobs" aria-controls="systemPanel" aria-expanded="false">작업 <b>2</b></button>
<button id="noticeButton" type="button" data-panel="notice" aria-controls="systemPanel" aria-expanded="false">알림 <b>4</b></button>
<button id="densityButton" type="button" title="화면 밀도 전환">Compact</button>
<button id="themeButton" type="button" title="KBX Business 테마 전환" aria-label="어두운 테마로 변경"><span aria-hidden="true"></span> <span>Light</span></button>
<button type="button">사용자</button>
</div>
</header>
<div class="shell-body">
<aside id="sideNav" class="side-nav" aria-label="업무 메뉴">
<div class="side-nav-top">
<button id="sideHomeButton" class="nav-home active" type="button"><span aria-hidden="true"></span><span class="nav-label"></span></button>
<button id="collapseButton" class="collapse-button" type="button" aria-label="메뉴 접기" aria-expanded="true"></button>
</div>
<button id="sideSearchButton" class="side-search" type="button" aria-keyshortcuts="Control+K"><span class="nav-label">메뉴·화면 검색</span><kbd class="nav-label">Ctrl K</kbd><span class="collapsed-only" aria-hidden="true"></span></button>
<nav id="moduleSwitcher" class="module-switcher" aria-label="업무 모듈"></nav>
<div id="favoriteNav" class="personal-nav"></div>
<div id="recentNav" class="personal-nav"></div>
<div id="menuNav" class="menu-nav"></div>
</aside>
<main class="workspace-shell">
<nav id="workspaceTabs" class="workspace-tabs" role="tablist" aria-label="열린 업무"></nav>
<div id="workspace" class="workspace" tabindex="-1"></div>
</main>
</div>
<dialog id="menuSearchDialog" class="menu-search-dialog" aria-labelledby="menuSearchTitle">
<div class="search-dialog-shell">
<header><strong id="menuSearchTitle">업무 화면 검색</strong><button id="searchCloseButton" type="button" aria-label="닫기">×</button></header>
<label class="search-input-wrap"><span class="sr-only">검색어</span><input id="menuSearchInput" autocomplete="off" placeholder="예: 주문, 재고, OMS-ORD-001" role="combobox" aria-controls="menuSearchResults" /></label>
<div id="menuSearchResults" class="menu-search-results" role="listbox" aria-label="검색 결과"></div>
<footer><span>↑↓ 이동 · Enter 열기 · Esc 닫기</span><span>화면코드 검색 지원</span></footer>
</div>
</dialog>
<dialog id="unsavedDialog" class="confirm-dialog" aria-labelledby="unsavedTitle">
<form method="dialog" class="confirm-shell">
<header><strong id="unsavedTitle">저장하지 않은 변경사항이 있습니다.</strong></header>
<p id="unsavedMessage">변경사항을 저장하거나 버린 뒤 화면을 닫을 수 있습니다.</p>
<footer>
<button id="unsavedStay" value="cancel" class="btn" type="submit">계속 편집</button>
<button id="unsavedDiscard" value="discard" class="btn danger" type="button">변경 버리기</button>
<button id="unsavedSave" value="save" class="btn primary" type="button">저장 후 닫기</button>
</footer>
</form>
</dialog>
<dialog id="lookupDialog" class="lookup-dialog" aria-labelledby="lookupTitle">
<div class="lookup-dialog-shell">
<header><div><strong id="lookupTitle">업무 조회</strong><small id="lookupSubtitle">F2 조회</small></div><button id="lookupCloseButton" type="button" aria-label="닫기">×</button></header>
<form id="lookupSearchForm" class="lookup-search">
<label><span class="sr-only">검색어</span><input id="lookupSearchInput" autocomplete="off" maxlength="80" placeholder="코드 또는 명칭" /></label>
<button class="btn" type="submit">조회</button>
</form>
<div id="lookupResults" class="lookup-results" role="listbox" aria-label="조회 결과"></div>
<footer><span id="lookupStatus">↑↓ 이동 · Enter 선택 · Esc 닫기</span><button id="lookupSelectButton" class="btn primary" type="button">선택</button></footer>
</div>
</dialog>
<dialog id="quantityDialog" class="confirm-dialog" aria-labelledby="quantityTitle">
<form method="dialog" class="confirm-shell" id="quantityForm">
<header><strong id="quantityTitle">피킹 수량 입력</strong></header>
<label class="dialog-field"><span>수량</span><input id="quantityInput" type="number" inputmode="numeric" min="0" step="1" /></label>
<p class="dialog-help" id="quantityHelp">필요수량을 초과할 수 없습니다.</p>
<footer><button class="btn" value="cancel" type="submit">취소</button><button id="quantityApply" class="btn primary" type="button">적용</button></footer>
</form>
</dialog>
<dialog id="exceptionDialog" class="confirm-dialog" aria-labelledby="exceptionTitle">
<form method="dialog" class="confirm-shell" id="exceptionForm">
<header><strong id="exceptionTitle">재고부족 신고</strong></header>
<fieldset class="exception-options"><legend>사유</legend><label><input type="radio" name="exceptionReason" value="실물 없음" checked /> 실물 없음</label><label><input type="radio" name="exceptionReason" value="수량 부족" /> 수량 부족</label><label><input type="radio" name="exceptionReason" value="위치 오류" /> 위치 오류</label><label><input type="radio" name="exceptionReason" value="상품 이상" /> 상품 이상</label></fieldset>
<label class="dialog-field"><span>메모</span><textarea id="exceptionMemo" rows="3" maxlength="200" placeholder="선택 입력"></textarea></label>
<footer><button class="btn" value="cancel" type="submit">취소</button><button id="exceptionApply" class="btn primary" type="button">등록</button></footer>
</form>
</dialog>
<aside id="systemPanel" class="system-panel" hidden tabindex="-1" role="region" aria-labelledby="systemPanelTitle">
<header><strong id="systemPanelTitle">업무 센터</strong><button id="systemPanelClose" type="button" aria-label="패널 닫기">×</button></header>
<div id="systemPanelBody" class="system-panel-body"></div>
</aside>
<aside id="drawer" class="drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="drawerTitle" tabindex="-1">
<header><strong id="drawerTitle">상세</strong><button id="drawerClose" type="button" aria-label="닫기">×</button></header>
<div id="drawerBody" class="drawer-body"></div>
</aside>
<div id="drawerBackdrop" class="drawer-backdrop" hidden></div>
<div id="toastRegion" class="toast-region" aria-live="polite" aria-atomic="true"></div>
<div id="liveRegion" class="sr-only" aria-live="polite" aria-atomic="true"></div>
</div>
<script src="./kbx-fe.js"></script>
</body>
</html>
@@ -0,0 +1,151 @@
:root{
--blue-50:#eff6ff;--blue-100:#dbeafe;--blue-600:#2563eb;--blue-700:#1d4ed8;
--gray-0:#fff;--gray-25:#fcfcfd;--gray-50:#f8fafc;--gray-100:#f1f5f9;--gray-200:#e2e8f0;--gray-300:#cbd5e1;--gray-400:#94a3b8;--gray-500:#64748b;--gray-600:#475569;--gray-700:#334155;--gray-800:#1e293b;--gray-900:#0f172a;
--red-50:#fff1f2;--red-200:#fecdd3;--red-600:#e11d48;--amber-50:#fffbeb;--amber-200:#fde68a;--amber-700:#b45309;--green-50:#f0fdf4;--green-200:#bbf7d0;--green-700:#15803d;
--surface:var(--gray-0);--muted:var(--gray-50);--border:var(--gray-200);--border-strong:var(--gray-300);--text:var(--gray-900);--text-muted:var(--gray-500);--primary:var(--blue-600);--danger:var(--red-600);--warning:var(--amber-700);--success:var(--green-700);
--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--radius:4px;--shadow:0 12px 34px rgba(15,23,42,.14);
--header-h:56px;--tabs-h:40px;--nav-w:220px;--nav-collapsed:56px;--control-h:34px;--row-h:34px;--font:14px;--grid-font:13px;
}
.kbx-app[data-theme="dark"]{
color-scheme:dark;
--surface:#111827;--muted:#0b1220;--border:#334155;--border-strong:#64748b;--text:#e5e7eb;--text-muted:#94a3b8;--primary:#60a5fa;--danger:#fb7185;--warning:#fbbf24;--success:#34d399;
--blue-50:#102a43;--blue-100:#153b5c;--gray-0:#111827;--gray-25:#111827;--gray-50:#0b1220;--gray-100:#172033;--gray-200:#334155;--gray-300:#475569;--gray-400:#64748b;--gray-500:#94a3b8;--gray-600:#cbd5e1;--gray-700:#e2e8f0;--gray-800:#e5e7eb;--gray-900:#f8fafc;
--red-50:#3b1118;--red-200:#9f1239;--red-600:#fb7185;--amber-50:#33270b;--amber-200:#854d0e;--amber-700:#fbbf24;--green-50:#062e24;--green-200:#166534;--green-700:#34d399;--shadow:0 18px 48px rgba(0,0,0,.42);
}
*{box-sizing:border-box}html,body,#app{height:100%;margin:0}body{font-family:Pretendard,"Apple SD Gothic Neo","Noto Sans KR",Arial,sans-serif;font-size:var(--font);color:var(--text);background:var(--muted)}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}.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}.skip-link{position:fixed;top:-60px;left:8px;z-index:999;padding:8px 12px;background:var(--text);color:#fff;border-radius:4px}.skip-link:focus{top:8px}
.kbx-app{height:100%;display:flex;flex-direction:column;color:var(--text);background:var(--muted)}.kbx-app[data-density="comfortable"]{--control-h:38px;--row-h:40px;--font:14px;--grid-font:14px}
.global-header{height:var(--header-h);display:grid;grid-template-columns:var(--nav-w) minmax(320px,620px) 1fr;align-items:center;gap:16px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--surface);z-index:40}.brand-area{display:flex;align-items:center;gap:8px;min-width:0}.brand{height:36px;border:0;background:transparent;display:flex;align-items:baseline;gap:8px;padding:0 8px;border-radius:4px}.brand:hover{background:var(--muted)}.brand strong{font-size:17px}.brand span{font-size:12px;color:var(--text-muted)}.module-select{padding-left:8px;border-left:1px solid var(--border)}.module-select select{height:32px;width:88px;border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:0 8px}.global-search{height:36px;display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border-strong);border-radius:4px;background:var(--muted);color:var(--text-muted);padding:0 12px;text-align:left}.global-search:hover{border-color:var(--gray-400);background:var(--surface)}.global-search kbd,.side-search kbd{font-size:11px;border:1px solid var(--border);border-radius:3px;padding:2px 5px;background:var(--surface)}.header-actions{display:flex;justify-content:flex-end;gap:4px}.header-actions button{height:32px;border:0;background:transparent;border-radius:4px;padding:0 9px}.header-actions button:hover{background:var(--muted)}.header-actions b{color:var(--primary);margin-left:3px}
.shell-body{flex:1;min-height:0;display:flex}.side-nav{width:var(--nav-w);min-width:var(--nav-w);padding:8px;border-right:1px solid var(--border);background:var(--surface);overflow:auto;transition:width .16s ease,min-width .16s ease}.side-nav.collapsed{width:var(--nav-collapsed);min-width:var(--nav-collapsed);overflow:hidden}.side-nav-top{position:sticky;top:-8px;z-index:3;display:flex;gap:4px;padding:8px 0 8px;background:var(--surface)}.nav-home{flex:1;height:32px;display:flex;align-items:center;gap:8px;padding:0 8px;border:0;border-radius:4px;background:transparent;text-align:left}.nav-home:hover,.nav-home.active{background:var(--blue-50);color:var(--primary)}.collapse-button{width:28px;height:28px;border:1px solid var(--border);border-radius:4px;background:var(--surface)}.side-search{width:100%;height:34px;display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border);border-radius:4px;background:var(--muted);color:var(--text-muted);padding:0 8px}.module-switcher{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:8px}.module-switcher button{height:28px;border:1px solid var(--border);border-radius:4px;background:var(--surface);font-size:12px}.module-switcher button.active{border-color:var(--primary);color:var(--primary);background:var(--blue-50);font-weight:600}.personal-nav,.menu-nav{margin-top:12px}.personal-nav h2,.menu-group h2{margin:0 8px 4px;font-size:12px;font-weight:600;color:var(--text-muted)}.nav-row{display:flex;align-items:center;border-radius:4px}.nav-row:hover{background:var(--muted)}.nav-row .nav-entry{min-width:0;flex:1;height:32px;border:0;background:transparent;text-align:left;padding:0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nav-row .nav-entry.active{color:var(--primary);font-weight:600}.nav-row .star{width:28px;height:28px;border:0;background:transparent;color:var(--gray-400);border-radius:4px}.nav-row .star.on{color:var(--primary)}.collapsed .nav-label,.collapsed .personal-nav,.collapsed .menu-nav h2,.collapsed .nav-row .star{display:none}.collapsed .side-nav-top{flex-direction:column}.collapsed .nav-home{width:38px;flex:none;justify-content:center;padding:0}.collapsed .collapse-button{width:38px}.collapsed .side-search{width:38px;justify-content:center;padding:0}.collapsed .module-switcher{grid-template-columns:1fr}.collapsed .module-switcher button{width:38px;height:34px}.collapsed .menu-nav{display:block}.collapsed .menu-group{margin:0}.collapsed .nav-row .nav-entry{width:38px;flex:none;text-align:center;padding:0;font-size:0}.collapsed .nav-row .nav-entry::first-letter{font-size:12px}.collapsed-only{display:none}.collapsed .collapsed-only{display:inline}
.workspace-shell{flex:1;min-width:0;display:flex;flex-direction:column}.workspace-tabs{height:var(--tabs-h);display:flex;align-items:flex-end;gap:1px;padding:0 8px;border-bottom:1px solid var(--border);background:var(--muted);overflow-x:auto}.tab-shell{height:36px;display:flex;align-items:center;max-width:220px;border:1px solid transparent;border-bottom:0}.tab-shell.active{border-color:var(--border);background:var(--surface)}.tab-main{min-width:0;height:100%;display:flex;align-items:center;gap:6px;border:0;background:transparent;padding:0 8px;color:var(--text-muted)}.tab-shell.active .tab-main{color:var(--text);font-weight:500}.tab-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tab-close,.tab-pin{width:26px;height:100%;border:0;background:transparent;color:var(--text-muted)}.tab-close:hover,.tab-pin:hover{background:var(--muted)}.tab-dirty{color:var(--warning);font-size:10px}.workspace-tabs .empty-tabs{margin:auto;color:var(--text-muted);font-size:12px;padding-bottom:9px}.workspace{flex:1;min-height:0;overflow:auto;background:var(--surface)}
.home-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:16px}.home-heading{display:grid;grid-template-columns:1fr minmax(320px,520px);align-items:center;gap:24px;min-height:76px;border-bottom:1px solid var(--border)}.home-heading p,.home-heading small{margin:0;color:var(--text-muted);font-size:12px}.home-heading h1{margin:1px 0 3px;font-size:20px}.home-search{height:40px;display:flex;align-items:center;justify-content:space-between;border:1px solid var(--border-strong);background:var(--surface);border-radius:4px;padding:0 12px;color:var(--text-muted)}.attention-zone{border:1px solid var(--amber-200);background:var(--amber-50)}.section-head{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--muted)}.attention-zone .section-head{background:transparent;border-color:var(--amber-200)}.section-head h2{font-size:16px;margin:0}.section-head small{color:var(--text-muted)}.attention-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;padding:8px}.attention-item{min-height:44px;display:grid;grid-template-columns:72px minmax(0,1fr) auto 14px;align-items:center;gap:8px;border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:4px 8px;text-align:left}.attention-item.critical{border-color:var(--red-200);background:var(--red-50)}.attention-source{font-size:12px;font-weight:600;color:var(--text-muted)}.attention-item.critical .attention-source{color:var(--danger)}.attention-copy{min-width:0;display:grid}.attention-copy b,.attention-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.attention-copy b{font-weight:500}.attention-copy small{font-size:12px;color:var(--text-muted)}.attention-action{font-size:12px;color:var(--text-muted)}.home-section{border:1px solid var(--border);background:var(--surface)}.continue-list{display:flex;flex-direction:column}.continue-item{min-height:42px;display:grid;grid-template-columns:72px minmax(0,1fr) auto 14px;align-items:center;gap:8px;border:0;border-bottom:1px solid var(--border);background:var(--surface);padding:0 12px;text-align:left}.continue-item:last-child{border-bottom:0}.continue-item:hover{background:var(--muted)}.continue-source{font-size:12px;color:var(--text-muted);font-weight:600}.continue-item[data-source="dirty"] .continue-source{color:var(--warning)}.continue-item[data-source="favorite"] .continue-source{color:var(--primary)}.continue-copy{display:grid;min-width:0}.continue-copy small{color:var(--text-muted);font-size:12px}.work-explorer{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.module-column{border-right:1px solid var(--border);border-bottom:1px solid var(--border)}.module-column:nth-child(2n){border-right:0}.module-title{height:44px;display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--border)}.module-title strong{font-size:16px}.module-title span,.module-title small{font-size:12px;color:var(--text-muted)}.module-title small{margin-left:auto}.group-block{padding:8px}.group-block h3{font-size:12px;color:var(--text-muted);margin:0 4px 4px}.home-menu-row{display:flex;align-items:center;min-height:36px;border-radius:4px}.home-menu-row:hover{background:var(--muted)}.home-menu-row .open-screen{flex:1;min-width:0;height:36px;border:0;background:transparent;text-align:left;padding:0 8px;display:flex;align-items:center;justify-content:space-between}.home-menu-row .open-screen small{font-size:12px;color:var(--text-muted)}.home-menu-row .fav{width:28px;height:28px;border:0;background:transparent;color:var(--gray-400)}.home-menu-row .fav.on{color:var(--primary)}
.screen{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--surface)}.page-header{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:4px 16px;border-bottom:1px solid var(--border)}.page-identity{min-width:0}.breadcrumb{font-size:12px;color:var(--text-muted);margin-bottom:2px}.title-row{display:flex;align-items:center;gap:8px}.title-row h1{font-size:20px;margin:0;font-weight:600}.status-chip,.dirty-chip{font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 6px;background:var(--muted)}.dirty-chip{color:var(--warning);border-color:var(--amber-200);background:var(--amber-50)}.page-tools{display:flex;gap:4px}.page-tools button{height:28px;border:1px solid var(--border);border-radius:4px;background:var(--surface);padding:0 8px;font-size:12px}.command-bar{min-height:44px;display:flex;align-items:center;gap:8px;padding:0 16px;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:0;z-index:6}.command-group{display:flex;gap:6px}.command-separator{height:22px;width:1px;background:var(--border)}.btn{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface);padding:0 12px}.btn:hover{background:var(--muted)}.btn.primary{border-color:var(--primary);background:var(--primary);color:#fff}.btn.danger{border-color:var(--red-200);color:var(--danger);background:var(--red-50)}.btn:disabled{opacity:.5;cursor:not-allowed}.screen-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:8px 16px 12px}.search-panel{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 12px;padding:8px;border:1px solid var(--border);background:var(--muted)}.search-fields{display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap}.field-inline{display:flex;align-items:center;gap:6px}.field-inline>span{font-size:13px;color:var(--gray-700);white-space:nowrap}.control{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface);padding:0 8px;min-width:110px}.control.medium{min-width:180px}.control.wide{min-width:240px}.search-actions{display:flex;gap:4px}.quick-filter{display:flex;align-items:stretch;border:1px solid var(--border);background:var(--surface)}.quick-filter button{min-width:96px;min-height:44px;border:0;border-right:1px solid var(--border);background:var(--surface);text-align:left;padding:5px 10px}.quick-filter button:last-child{border-right:0}.quick-filter button.active{box-shadow:inset 0 -2px var(--primary);background:var(--blue-50)}.quick-filter b{display:block;font-size:16px}.quick-filter small{color:var(--text-muted)}.quick-filter .danger b{color:var(--danger)}.context-bar{min-height:34px;display:flex;align-items:center;gap:12px;padding:0 10px;border:1px solid var(--border);background:var(--muted);font-size:12px}.context-bar strong{font-size:13px}.bulk-bar[hidden]{display:none!important}.bulk-bar{min-height:42px;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--blue-100);background:var(--blue-50)}.bulk-bar strong{margin-right:6px}.grid-wrap{flex:1;min-height:220px;border:1px solid var(--border);overflow:auto;background:var(--surface)}.data-grid{width:100%;border-collapse:separate;border-spacing:0;font-size:var(--grid-font)}.data-grid th,.data-grid td{height:var(--row-h);padding:0 8px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);white-space:nowrap}.data-grid th{height:36px;position:sticky;top:0;z-index:2;background:var(--muted);font-weight:600;text-align:left}.data-grid th:last-child,.data-grid td:last-child{border-right:0}.data-grid tr:hover td{background:var(--blue-50)}.data-grid .num{text-align:right}.data-grid .center{text-align:center}.data-grid .link{color:var(--primary);text-decoration:none}.data-grid .warn{color:var(--warning);font-weight:600}.data-grid .error{color:var(--danger);font-weight:600}.data-grid td[contenteditable="true"]{background:var(--amber-50);outline:0}.data-grid td[contenteditable="true"]:focus{box-shadow:inset 0 0 0 2px var(--primary)}.summary-bar{min-height:34px;display:flex;align-items:center;gap:18px;padding:0 10px;border:1px solid var(--border);border-top:0;color:var(--text-muted);font-size:12px}.summary-bar strong{color:var(--text)}
.form-section{border:1px solid var(--border);background:var(--surface)}.form-section h2{height:38px;display:flex;align-items:center;margin:0;padding:0 12px;border-bottom:1px solid var(--border);background:var(--muted);font-size:15px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px;padding:12px}.form-field{display:grid;grid-template-columns:96px minmax(0,1fr);align-items:center;gap:8px;min-height:34px}.form-field label{font-size:13px;color:var(--gray-700)}.form-field .required::after{content:" *";color:var(--danger)}.lookup{display:grid;grid-template-columns:100px minmax(120px,1fr) 58px;gap:4px}.lookup input,.form-field input,.form-field select{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface);padding:0 8px;min-width:0}.lookup button{height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;background:var(--surface)}.transaction-layout{display:flex;flex-direction:column;gap:8px;min-height:0;flex:1}.master-layout{display:grid;grid-template-columns:minmax(280px,32%) minmax(0,1fr);gap:8px;min-height:0;flex:1}.master-pane,.detail-pane{border:1px solid var(--border);min-height:0;overflow:auto}.master-pane .search-panel{border:0;border-bottom:1px solid var(--border)}.detail-pane{padding:12px;display:flex;flex-direction:column;gap:10px}.split-layout{display:grid;grid-template-columns:minmax(260px,40%) minmax(0,1fr);gap:8px;min-height:0;flex:1}.pane{border:1px solid var(--border);min-height:0;overflow:auto}.pane-header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid var(--border);background:var(--muted)}.queue-summary{display:grid;grid-template-columns:repeat(5,1fr);border:1px solid var(--border)}.queue-summary button{min-height:58px;border:0;border-right:1px solid var(--border);background:var(--surface);text-align:left;padding:7px 10px}.queue-summary button:last-child{border-right:0}.queue-summary b{display:block;font-size:18px}.queue-summary .danger b{color:var(--danger)}.reconcile-summary{display:flex;gap:20px;padding:8px 10px;border:1px solid var(--border);background:var(--muted);font-size:13px}.reconcile-summary .mismatch strong{color:var(--danger)}.import-steps{display:grid;grid-template-columns:repeat(6,1fr);border:1px solid var(--border)}.import-step{min-height:42px;display:flex;align-items:center;justify-content:center;gap:6px;border-right:1px solid var(--border);background:var(--muted);font-size:13px}.import-step:last-child{border-right:0}.import-step.active{background:var(--blue-50);color:var(--primary);font-weight:600}.drop-zone{min-height:230px;border:1px dashed var(--border-strong);display:grid;place-items:center;text-align:center;background:var(--gray-25)}.drop-zone strong{display:block;font-size:16px;margin-bottom:8px}.validation-cards{display:flex;gap:8px}.validation-card{min-width:120px;border:1px solid var(--border);padding:8px 10px}.validation-card b{display:block;font-size:18px}.validation-card.error b{color:var(--danger)}
.mobile-stage{background:var(--gray-100);padding:20px;min-height:100%;display:flex;justify-content:center;align-items:flex-start}.wms-phone{width:390px;min-height:720px;background:var(--surface);border:1px solid var(--border-strong);box-shadow:0 8px 24px rgba(15,23,42,.1);display:flex;flex-direction:column}.wms-header{min-height:64px;display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border)}.wms-header h1{font-size:20px;margin:0}.network{font-size:12px;color:var(--success)}.wms-content{flex:1;padding:16px;display:flex;flex-direction:column;gap:14px}.scan-location{font-size:36px;font-weight:700;letter-spacing:.02em}.scan-box{height:110px;border:2px solid var(--border-strong);display:grid;place-items:center;font-weight:700;background:var(--muted)}.qty-grid{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--border)}.qty-grid div{text-align:center;padding:10px;border-right:1px solid var(--border)}.qty-grid div:last-child{border-right:0}.qty-grid b{display:block;font-size:24px}.wms-actions{position:sticky;bottom:0;display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px 16px;border-top:1px solid var(--border);background:var(--surface)}.wms-actions .btn{height:54px;font-size:16px;font-weight:600}.wms-actions .full{grid-column:1/-1}.error-panel{border:1px solid var(--red-200);background:var(--red-50);padding:12px}.error-panel strong{display:block;color:var(--danger);margin-bottom:4px}
.menu-search-dialog{width:min(680px,calc(100vw - 32px));padding:0;border:1px solid var(--border-strong);border-radius:6px;box-shadow:var(--shadow)}.menu-search-dialog::backdrop{background:rgba(15,23,42,.35)}.search-dialog-shell header{height:46px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--border)}.search-dialog-shell header button{width:30px;height:30px;border:0;background:transparent;font-size:20px}.search-input-wrap{display:block;padding:10px;border-bottom:1px solid var(--border)}.search-input-wrap input{width:100%;height:40px;border:1px solid var(--border-strong);border-radius:4px;padding:0 12px}.menu-search-results{max-height:420px;overflow:auto}.search-result{width:100%;min-height:48px;display:grid;grid-template-columns:82px minmax(0,1fr) auto;align-items:center;gap:10px;border:0;border-bottom:1px solid var(--border);background:var(--surface);padding:5px 12px;text-align:left}.search-result.active,.search-result:hover{background:var(--blue-50)}.search-result .code{font-size:11px;color:var(--text-muted)}.search-result .copy{display:grid}.search-result .copy small{color:var(--text-muted);font-size:12px}.search-result .module{font-size:12px;color:var(--text-muted)}.search-dialog-shell footer{height:34px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;background:var(--muted);color:var(--text-muted);font-size:11px}
.drawer{position:fixed;z-index:90;top:56px;right:0;bottom:0;width:min(560px,92vw);background:var(--surface);border-left:1px solid var(--border);box-shadow:-10px 0 24px rgba(15,23,42,.1);transform:translateX(100%);transition:transform .18s ease;display:flex;flex-direction:column}.drawer.open{transform:translateX(0)}.drawer header{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--border)}.drawer header button{width:30px;height:30px;border:0;background:transparent;font-size:20px}.drawer-body{flex:1;overflow:auto;padding:14px}.detail-list{display:grid;gap:0;border:1px solid var(--border)}.detail-row{display:grid;grid-template-columns:110px 1fr;min-height:36px;border-bottom:1px solid var(--border);align-items:center}.detail-row:last-child{border-bottom:0}.detail-row span{color:var(--text-muted);padding:0 10px}.detail-row strong{font-weight:500}.toast-region{position:fixed;right:16px;bottom:16px;z-index:120;display:grid;gap:8px}.toast{min-width:260px;max-width:420px;padding:10px 12px;border:1px solid var(--border-strong);background:var(--surface);box-shadow:var(--shadow);border-radius:4px}.toast.success{border-color:var(--green-200);background:var(--green-50)}.toast.error{border-color:var(--red-200);background:var(--red-50)}
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[contenteditable="true"]:focus-visible{outline:2px solid var(--primary);outline-offset:1px}
@media(max-width:1100px){.global-header{grid-template-columns:var(--nav-w) minmax(260px,1fr) auto}.header-actions button:nth-child(-n+2){display:none}.work-explorer{grid-template-columns:1fr}.module-column{border-right:0}.attention-grid{grid-template-columns:1fr}.queue-summary{grid-template-columns:repeat(3,1fr)}}
@media(max-width:820px){.global-header{grid-template-columns:auto 1fr auto}.module-select,.brand span,.global-search kbd,.header-actions button:not(#densityButton):not(#themeButton){display:none}.side-nav{width:56px;min-width:56px}.side-nav .nav-label,.side-nav .personal-nav,.side-nav .menu-nav h2,.side-nav .nav-row .star{display:none}.side-nav .module-switcher{grid-template-columns:1fr}.home-heading{grid-template-columns:1fr}.home-page{padding:12px}.form-grid{grid-template-columns:1fr}.master-layout,.split-layout{grid-template-columns:1fr}.master-pane{max-height:280px}}
/* v38 FE interaction hardening */
.section-head>div{display:grid;gap:2px}.section-head>.section-actions{display:flex;grid-auto-flow:column;align-items:center;gap:8px}.section-count{font-size:12px;color:var(--text-muted);white-space:nowrap}.section-toggle{min-height:28px;padding:0 8px;border:1px solid var(--amber-200);border-radius:4px;background:var(--surface);color:var(--text);font-size:12px;font-weight:600}.section-toggle:hover{background:var(--muted)}.attention-centers{display:flex;align-items:center;gap:4px}.attention-centers .section-toggle{border-color:var(--border)}.page-tools button[aria-pressed="true"]{color:var(--primary);border-color:var(--blue-100);background:var(--blue-50)}
.search-panel.expanded{align-items:start}.search-advanced{grid-column:1/-1;display:flex;align-items:center;gap:8px 16px;padding-top:8px;border-top:1px solid var(--border)}.checkbox-inline{min-height:var(--control-h)}
.data-state{min-height:220px;display:grid;place-items:center;align-content:center;gap:7px;border:1px solid var(--border);background:var(--gray-25);text-align:center;padding:24px}.data-state strong{font-size:16px}.data-state span{max-width:560px;color:var(--text-muted);font-size:13px}.data-state.error{border-color:var(--red-200);background:var(--red-50)}.data-state.error strong{color:var(--danger)}.data-state.loading strong::before{content:"";display:inline-block;width:12px;height:12px;margin-right:8px;border:2px solid var(--gray-300);border-top-color:var(--primary);border-radius:50%;vertical-align:-2px;animation:kbx-spin .7s linear infinite}@keyframes kbx-spin{to{transform:rotate(360deg)}}
.row-link,.text-action{border:0;background:transparent;color:var(--primary);padding:0;text-align:left}.row-link:hover,.text-action:hover{text-decoration:underline}.selected-row td{background:var(--blue-50)!important}.checkbox-control{display:flex;align-items:center;gap:6px;font-size:13px}.detail-status{display:flex;gap:6px;margin-bottom:10px}.status-chip.danger{border-color:var(--red-200);background:var(--red-50);color:var(--danger)}
.workflow-strip{min-height:38px;display:flex;align-items:center;gap:0;border:1px solid var(--border);background:var(--muted);font-size:12px}.workflow-strip span{padding:0 10px;border-right:1px solid var(--border);color:var(--text-muted)}.workflow-strip span.active{color:var(--primary);font-weight:600}.workflow-strip strong{margin-left:auto;padding:0 10px;font-weight:500}.detail-grid-section{display:flex;min-height:230px;flex:1;flex-direction:column;border:1px solid var(--border)}.detail-grid-section .grid-wrap{border:0}.cell-editor,.fast-cell{width:100%;height:calc(var(--row-h) - 6px);border:1px solid transparent;background:transparent;padding:0 4px}.cell-editor:hover,.fast-cell:hover{border-color:var(--border-strong);background:var(--surface)}.cell-editor:focus,.fast-cell:focus{border-color:var(--primary);background:var(--surface);outline:1px solid var(--primary)}.cell-editor.num,.fast-cell.num{text-align:right}.fast-cell.invalid{border-color:var(--red-200);background:var(--red-50)}
.history-pane{min-height:190px}.queue-summary button.active{box-shadow:inset 0 -2px var(--primary);background:var(--blue-50)}.queue-summary button.danger.active{box-shadow:inset 0 -2px var(--danger);background:var(--red-50)}.context-bar .text-action{margin-left:auto}
.inline-actions,.wizard-actions{display:flex;align-items:center;gap:6px}.inline-actions{padding:8px;border:1px solid var(--border);border-top:0}.wizard-actions{justify-content:flex-end;padding-top:8px}.import-step.done{background:var(--green-50);color:var(--success)}.drop-zone.dragging{border-color:var(--primary);background:var(--blue-50)}.commit-panel{max-width:760px;width:100%;margin:8px auto 0}.commit-body{padding:16px}.commit-body dl{display:flex;gap:8px;margin:12px 0}.commit-body dl div{min-width:130px;border:1px solid var(--border);padding:8px}.commit-body dt{font-size:12px;color:var(--text-muted)}.commit-body dd{margin:2px 0 0;font-size:18px;font-weight:600}.warning-copy{padding:8px;border:1px solid var(--amber-200);background:var(--amber-50);color:var(--warning)}
.wms-label{font-size:12px;color:var(--text-muted);font-weight:600}.wms-item p{margin:4px 0 0;color:var(--text-muted)}.scan-box{cursor:text;text-align:center}.scan-box strong{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis}.scan-box small{display:block;margin-top:6px;color:var(--text-muted);font-weight:400}.scan-box.scan-error{border-color:var(--danger);background:var(--red-50)}.success-panel{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--green-200);background:var(--green-50)}.success-panel strong{color:var(--success)}
.confirm-dialog{width:min(480px,calc(100vw - 32px));padding:0;border:1px solid var(--border-strong);border-radius:6px;box-shadow:var(--shadow)}.confirm-dialog::backdrop{background:rgba(15,23,42,.35)}.confirm-shell{margin:0}.confirm-shell>header{min-height:48px;display:flex;align-items:center;padding:0 14px;border-bottom:1px solid var(--border)}.confirm-shell>p,.dialog-field,.exception-options{margin:0;padding:14px}.confirm-shell>footer{display:flex;justify-content:flex-end;gap:6px;padding:10px 14px;border-top:1px solid var(--border);background:var(--muted)}.dialog-field{display:grid;gap:6px}.dialog-field>span,.dialog-help{font-size:13px;color:var(--text-muted)}.dialog-field input,.dialog-field textarea{width:100%;border:1px solid var(--border-strong);border-radius:4px;padding:8px}.exception-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;border:0}.exception-options legend{padding:0 0 8px;font-size:13px;color:var(--text-muted)}.exception-options label{display:flex;align-items:center;gap:6px}.dialog-help{padding:0 14px 14px!important}
.drawer-backdrop{position:fixed;z-index:85;inset:56px 0 0;background:rgba(15,23,42,.18)}.drawer-actions{position:sticky;bottom:0;display:flex;justify-content:flex-end;gap:6px;margin:16px -14px -14px;padding:10px 14px;border-top:1px solid var(--border);background:var(--surface)}.search-empty{padding:20px;color:var(--text-muted);text-align:center}
@media(max-width:820px){.search-advanced{flex-direction:column;align-items:stretch}.workflow-strip{overflow:auto}.workflow-strip strong{display:none}.exception-options{grid-template-columns:1fr}}
/* v39 FE product-parity hardening */
.lookup-dialog{width:min(720px,calc(100vw - 32px));padding:0;border:1px solid var(--border-strong);border-radius:6px;box-shadow:var(--shadow)}
.lookup-dialog::backdrop{background:rgba(15,23,42,.35)}
.lookup-dialog-shell{display:grid;grid-template-rows:auto auto auto auto;background:var(--surface)}
.lookup-dialog-shell>header{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid var(--border)}
.lookup-dialog-shell>header>div{display:grid;gap:2px}.lookup-dialog-shell>header small{font-size:12px;color:var(--text-muted)}
.lookup-dialog-shell>header button{width:32px;height:32px;border:0;background:transparent;font-size:20px}
.lookup-search{display:grid;grid-template-columns:1fr auto;gap:6px;padding:10px;border-bottom:1px solid var(--border);background:var(--muted)}
.lookup-search input{width:100%;height:var(--control-h);border:1px solid var(--border-strong);border-radius:4px;padding:0 10px;background:var(--surface)}
.lookup-results{min-height:180px;max-height:52vh;overflow:auto;background:var(--surface)}.lookup-result{width:100%;min-height:42px;display:grid;grid-template-columns:110px minmax(0,1fr);align-items:center;gap:10px;padding:4px 12px;border:0;border-bottom:1px solid var(--border);background:var(--surface);text-align:left}
.lookup-result:hover,.lookup-result.active{background:var(--blue-50)}.lookup-result .code{font-variant-numeric:tabular-nums;color:var(--text-muted)}.lookup-result .copy{display:grid;min-width:0}.lookup-result .copy b{font-weight:500}.lookup-result .copy small{font-size:12px;color:var(--text-muted)}
.lookup-dialog-shell>footer{min-height:44px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 10px;border-top:1px solid var(--border);background:var(--muted);font-size:12px;color:var(--text-muted)}
.lookup input[readonly]{background:var(--muted);color:var(--text-muted)}.lookup-trigger{min-width:58px}
.order-data-row:focus{outline:2px solid var(--primary);outline-offset:-2px}.order-data-row:focus td{background:var(--blue-50)}
.utility-panel{display:grid;gap:12px}.utility-panel h2,.utility-panel h3,.utility-panel p{margin:0}.utility-panel h2{font-size:18px}.utility-panel h3{font-size:14px;margin-top:4px}.utility-panel ol{margin:0;padding-left:22px;display:grid;gap:6px}.utility-kicker{font-size:12px;color:var(--text-muted)}
.shortcut-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;border:1px solid var(--border);background:var(--border)}.shortcut-list div{display:grid;grid-template-columns:52px 1fr;min-height:34px;align-items:center;background:var(--surface)}.shortcut-list dt{padding:0 8px;font-weight:600}.shortcut-list dd{margin:0;color:var(--text-muted)}
.utility-note{padding:10px 12px;border:1px solid var(--border);background:var(--muted);font-size:12px;color:var(--text-muted);line-height:1.5}.utility-actions{display:flex;flex-wrap:wrap;gap:6px}
.ai-attention{display:grid;gap:6px}.ai-attention button{min-height:48px;display:grid;grid-template-columns:90px 52px minmax(0,1fr);align-items:center;gap:8px;border:1px solid var(--border);background:var(--surface);padding:5px 10px;text-align:left}.ai-attention button:hover{background:var(--muted)}.ai-attention b{font-size:16px}.ai-attention small{color:var(--text-muted)}
.suggestion-types{display:flex;gap:12px;padding:10px;border:1px solid var(--border)}.suggestion-types legend{padding:0 4px;color:var(--text-muted);font-size:12px}.suggestion-types label{display:flex;align-items:center;gap:5px}.suggestion-copy{display:grid;gap:6px}.suggestion-copy span{font-size:13px;color:var(--text-muted)}.suggestion-copy textarea{width:100%;resize:vertical;border:1px solid var(--border-strong);border-radius:4px;padding:8px}.suggestion-context{display:grid;grid-template-columns:auto 1fr;align-items:start;gap:2px 7px}.suggestion-context small{grid-column:2;color:var(--text-muted)}
.system-row{width:100%;min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--border);background:var(--surface);padding:6px 10px;text-align:left}.system-row+ .system-row{border-top:0}.system-row span:first-child{display:grid;gap:2px}.system-row small{font-size:12px;color:var(--text-muted)}.system-row.critical{border-color:var(--red-200);background:var(--red-50)}.system-row.static{cursor:default}
.import-steps.six{grid-template-columns:repeat(6,1fr)}.import-review{display:flex;flex-wrap:wrap;gap:8px 14px;padding:10px;border:1px solid var(--border);background:var(--muted);font-size:12px}.job-progress-head{display:flex;align-items:end;justify-content:space-between;gap:12px}.job-progress-head>div{display:grid;gap:3px}.job-progress-head small{color:var(--text-muted)}.job-progress-head>b{font-size:22px}.progress-track{height:12px;overflow:hidden;border:1px solid var(--border-strong);background:var(--gray-100)}.progress-track span{display:block;height:100%;background:var(--primary);transition:width .16s linear}.result-actions{display:flex;gap:6px;justify-content:flex-end;margin-top:12px}
.network.unstable{color:var(--warning)}.network.offline{color:var(--danger)}
@media(max-width:1280px){
.global-header{grid-template-columns:var(--nav-w) minmax(280px,1fr) auto;gap:8px}.header-actions button{padding:0 6px}.page-header{position:sticky;top:0;z-index:11}.command-bar{overflow-x:auto;white-space:nowrap}.screen-body{min-width:0}.search-panel{align-items:flex-start}.search-fields{flex-wrap:wrap}.home-page{max-width:none}.work-explorer{grid-template-columns:1fr 1fr}.grid-wrap{max-width:100%;overflow:auto}
}
@media(max-width:900px){
.page-header{min-height:52px;height:auto;align-items:flex-start;gap:8px;padding-top:6px;padding-bottom:6px}.page-tools{flex-wrap:wrap;justify-content:flex-end}.command-bar{position:sticky;top:52px;z-index:10}.quick-filter{overflow-x:auto}.quick-filter button{min-width:96px}.context-bar,.summary-bar{overflow-x:auto;white-space:nowrap}.import-steps.six{overflow-x:auto;grid-template-columns:repeat(6,minmax(92px,1fr))}.validation-cards{overflow-x:auto}.lookup-dialog-shell>footer{align-items:flex-start;flex-direction:column}
}
@media(max-width:620px){
.attention-item,.continue-item{grid-template-columns:64px minmax(0,1fr) 14px}.attention-action{display:none}.suggestion-types{flex-direction:column}.shortcut-list{grid-template-columns:1fr}.ai-attention button{grid-template-columns:78px 44px minmax(0,1fr)}
}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
.drawer[aria-hidden="true"]{visibility:hidden;pointer-events:none}
@media(max-width:767px){
.kbx-app[data-mode="wms-mobile"] .global-header,.kbx-app[data-mode="wms-mobile"] .side-nav,.kbx-app[data-mode="wms-mobile"] .workspace-tabs{display:none}
.kbx-app[data-mode="wms-mobile"] .shell-body,.kbx-app[data-mode="wms-mobile"] .workspace-shell,.kbx-app[data-mode="wms-mobile"] .workspace{height:100%;min-height:100%;width:100%}
.kbx-app[data-mode="wms-mobile"] .mobile-stage{min-height:100%;padding:0;background:var(--surface)}
.kbx-app[data-mode="wms-mobile"] .wms-phone{width:100%;min-height:100%;border:0;box-shadow:none}
.kbx-app[data-mode="wms-mobile"] .wms-content{min-height:0}
}
/* v40 FE completion-loop hardening */
.validation-summary{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--red-200);background:var(--red-50);color:var(--danger)}
.validation-summary strong{margin-right:4px}.validation-summary button{min-height:28px;border:1px solid var(--red-200);border-radius:4px;background:var(--surface);color:var(--danger);padding:0 8px;text-align:left}.validation-summary button:hover,.validation-summary button:focus-visible{background:var(--red-50)}
.action-result{min-height:38px;display:flex;align-items:center;gap:10px;padding:6px 10px;border:1px solid var(--green-200);background:var(--green-50);color:var(--green-700)}.action-result span{min-width:0;flex:1}.action-result .text-action{margin-left:auto}
.master-pane .context-bar{border-top:0}.detail-pane>.pane-header{border-bottom:1px solid var(--border)}
.data-grid input[type="checkbox"]{width:16px;height:16px;vertical-align:middle}.detail-grid-section .pane-header .text-action{margin-left:auto}
@media(max-width:900px){.validation-summary{align-items:flex-start;flex-direction:column}.action-result{align-items:flex-start;flex-wrap:wrap}.action-result .text-action{margin-left:0}}
/* v43 KBX Business Theme: hierarchy first, decoration second */
.brand strong{color:var(--primary)}.nav-row .nav-entry.active{box-shadow:inset 4px 0 0 var(--primary);padding-left:10px}.attention-zone{border-color:var(--amber-200)}.attention-item{border-left:4px solid var(--amber-200)}.attention-item.critical{border-left-color:var(--danger)}.continue-source{justify-self:start;border:1px solid var(--border);border-radius:999px;background:var(--muted);padding:3px 8px}.continue-item[data-source="dirty"] .continue-source{background:var(--amber-50);border-color:var(--amber-200)}.continue-item[data-source="favorite"] .continue-source{background:var(--blue-50);border-color:var(--blue-100)}
.kbx-app[data-theme="dark"] .global-search:hover,.kbx-app[data-theme="dark"] .home-search:hover{background:var(--blue-50)}
/* v44 OS high-contrast bridge: OS colors override Light/Dark without changing workflow geometry. */
@media (forced-colors: active){
:root,.kbx-app[data-theme="dark"],.kbx-app[data-theme="light"]{--surface:Canvas;--surface-muted:Canvas;--surface-subtle:Canvas;--surface-hover:Highlight;--text:CanvasText;--muted:CanvasText;--border:ButtonBorder;--border-strong:CanvasText;--primary:LinkText;--success:CanvasText;--warning:CanvasText;--danger:CanvasText;--info-surface:Canvas;--warning-surface:Canvas;--danger-surface:Canvas}
button,input,select,textarea,a{forced-color-adjust:auto}.active,[aria-current="page"],[aria-pressed="true"],[aria-selected="true"]{outline:1px solid Highlight;outline-offset:-2px}
}
/* v44 non-modal runtime centers: preserve workspace context while inspecting jobs/notifications. */
.system-panel{position:fixed;z-index:82;right:8px;top:60px;width:min(420px,calc(100vw - 24px));max-height:70vh;overflow:auto;border:1px solid var(--border);border-radius:6px;background:var(--surface);box-shadow:var(--shadow)}
.system-panel[hidden]{display:none}.system-panel>header{min-height:36px;display:flex;align-items:center;justify-content:space-between;padding:0 8px;border-bottom:1px solid var(--border)}.system-panel>header button{width:28px;height:28px;border:0;border-radius:4px;background:transparent;color:var(--text)}.system-panel>header button:hover{background:var(--surface-hover)}.system-panel-body{padding:8px}
/* v45 safe screen preference: keep repetitive filters without persisting free-text business data. */
.search-preference{min-height:34px;display:grid;grid-template-columns:18px auto;align-items:center;column-gap:6px;margin-left:auto;padding:0 4px;color:var(--text-muted);font-size:12px}.search-preference input{width:16px;height:16px;margin:0}.search-preference span{color:var(--text);font-size:13px}.search-preference small{grid-column:2;max-width:320px;color:var(--text-muted)}
@media(max-width:900px){.search-preference{margin-left:0}}
.tab-resumed{font-size:var(--font-sm);font-weight:700;color:var(--primary);line-height:1}
.problem-feedback{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--info-border);border-left:4px solid var(--primary);background:var(--info-surface)}
.problem-feedback.warning{border-color:var(--warning-border);border-left-color:var(--warning);background:var(--warning-surface)}
.problem-feedback.danger{border-color:var(--danger-border);border-left-color:var(--danger);background:var(--danger-surface)}
.problem-copy{display:grid;gap:4px;min-width:0}.problem-heading{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.problem-heading span{font-size:12px;font-weight:600;color:var(--text-muted)}.problem-copy p,.problem-copy small{margin:0;color:var(--text-muted);font-size:13px;line-height:1.5}.problem-actions{display:flex;align-items:center;justify-content:flex-end;gap:4px;flex-wrap:wrap}
@media(max-width:48rem){.problem-feedback{flex-direction:column}.problem-actions{width:100%;justify-content:flex-start}}
/* v51 FE presentation workbench: module identity is chrome-only; business status keeps semantic colors. */
:root{--workspace:#f5f7fa;--shell:#fff;--nav-surface:#fbfcfe;--section-heading:#f7f9fc;--module-accent:#175cd3;--module-accent-surface:#eff8ff;--module-accent-border:#b2ddff;--shell-shadow:0 1px 2px rgba(16,24,40,.05)}
.kbx-app[data-theme="dark"]{--workspace:#0a1020;--shell:#111827;--nav-surface:#0d1524;--section-heading:#151f31;--module-accent:#84adff;--module-accent-surface:#102a43;--module-accent-border:#285b8f;--shell-shadow:0 1px 2px rgba(0,0,0,.42)}
[data-module="OMS"]{--module-accent:#175cd3;--module-accent-surface:#eff8ff;--module-accent-border:#b2ddff}
[data-module="ERP"]{--module-accent:#6938ef;--module-accent-surface:#f4f3ff;--module-accent-border:#d9d6fe}
[data-module="WMS"]{--module-accent:#087e8b;--module-accent-surface:#ecfdff;--module-accent-border:#a5f0fc}
[data-module="COMMON"]{--module-accent:#475467;--module-accent-surface:#f2f4f7;--module-accent-border:#d0d5dd}
.kbx-app[data-theme="dark"] [data-module="OMS"],.kbx-app[data-theme="dark"][data-module="OMS"]{--module-accent:#84adff;--module-accent-surface:#102a43;--module-accent-border:#285b8f}
.kbx-app[data-theme="dark"] [data-module="ERP"],.kbx-app[data-theme="dark"][data-module="ERP"]{--module-accent:#b9a2ff;--module-accent-surface:#241b45;--module-accent-border:#6d5bd0}
.kbx-app[data-theme="dark"] [data-module="WMS"],.kbx-app[data-theme="dark"][data-module="WMS"]{--module-accent:#63c5da;--module-accent-surface:#0d2f36;--module-accent-border:#1f6672}
.kbx-app[data-theme="dark"] [data-module="COMMON"],.kbx-app[data-theme="dark"][data-module="COMMON"]{--module-accent:#b3b8c4;--module-accent-surface:#172033;--module-accent-border:#475467}
.kbx-app{background:var(--workspace)}.global-header{background:var(--shell);box-shadow:var(--shell-shadow)}.brand strong,.header-actions b{color:var(--module-accent)}.global-search:hover{border-color:var(--module-accent);background:var(--module-accent-surface)}.side-nav{background:var(--nav-surface)}.side-nav-top{background:var(--nav-surface)}.nav-home:hover,.nav-home.active,.module-switcher button.active{background:var(--module-accent-surface);color:var(--module-accent);border-color:var(--module-accent-border)}.nav-row .nav-entry.active{color:var(--module-accent);box-shadow:inset 4px 0 0 var(--module-accent)}.nav-row .star.on{color:var(--module-accent)}.workspace-shell,.workspace{background:var(--workspace)}.tab-shell.active{box-shadow:inset 0 2px 0 var(--module-accent)}
.screen{background:var(--workspace)}.page-header{background:var(--shell);box-shadow:var(--shell-shadow);border-left:4px solid var(--module-accent)}.status-chip{border-color:var(--module-accent-border);background:var(--module-accent-surface);color:var(--module-accent)}.command-bar{background:var(--shell);box-shadow:var(--shell-shadow)}.screen-body{background:var(--workspace)}
.home-page{max-width:1280px}.home-pulse{min-height:40px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border);background:var(--surface)}.home-pulse button{min-height:40px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:0 8px;padding:4px 12px;border:0;border-right:1px solid var(--border);background:transparent;text-align:left}.home-pulse button:last-child{border-right:0}.home-pulse button:hover:not(:disabled){background:var(--muted)}.home-pulse button:disabled{cursor:default}.home-pulse span{font-size:12px;color:var(--text-muted)}.home-pulse b{grid-row:1 / span 2;grid-column:2;font-size:16px;font-variant-numeric:tabular-nums}.home-pulse small{font-size:12px;color:var(--module-accent)}.module-column .module-title{box-shadow:inset 4px 0 0 var(--module-accent);background:var(--section-heading)}.module-column .module-title strong{color:var(--module-accent)}
.screen[data-template="T01"] .search-panel{box-shadow:inset 4px 0 0 var(--module-accent)}.screen[data-template="T02"] .master-pane,.screen[data-template="T02"] .detail-pane,.screen[data-template="T03"] .form-section,.screen[data-template="T03"] .grid-wrap,.screen[data-template="T04"] .grid-wrap,.screen[data-template="T05"] .pane,.screen[data-template="T06"] .grid-wrap,.screen[data-template="T07"] .grid-wrap,.screen[data-template="T08"] .import-stage{box-shadow:var(--shell-shadow)}.screen[data-template="T02"] .detail-pane,.screen[data-template="T03"] .form-section,.screen[data-template="T04"] .fast-guide,.screen[data-template="T05"] .master-pane,.screen[data-template="T06"] .queue-summary,.screen[data-template="T07"] .reconcile-summary,.screen[data-template="T08"] .import-steps{border-top:2px solid var(--module-accent)}.screen[data-template="T06"] .queue-summary{background:var(--surface)}.screen[data-template="T07"] .reconcile-summary{background:var(--surface)}.screen[data-template="T08"] .import-steps{background:var(--section-heading)}
@media(max-width:820px){.home-pulse{grid-template-columns:repeat(2,minmax(0,1fr))}.home-pulse button:nth-child(2){border-right:0}.home-pulse button:nth-child(-n+2){border-bottom:1px solid var(--border)}}
@media(forced-colors:active){:root,.kbx-app[data-theme="dark"],.kbx-app[data-theme="light"]{--workspace:Canvas;--shell:Canvas;--nav-surface:Canvas;--section-heading:Canvas;--module-accent:Highlight;--module-accent-surface:Canvas;--module-accent-border:Highlight;--shell-shadow:none}}
/* v52 operational navigation: Home is a launcher, resume point, exception ingress, and full work explorer. */
.home-module-rail{display:grid;grid-template-columns:auto repeat(5,minmax(0,1fr));align-items:stretch;border:1px solid var(--border);background:var(--surface)}
.home-module-label{display:flex;align-items:center;padding:0 var(--space-3);border-right:1px solid var(--border);background:var(--section-heading);font-size:.75rem;font-weight:600;color:var(--text-muted)}
.home-module-rail button{min-width:0;min-height:var(--control-h);display:grid;grid-template-columns:minmax(0,1fr);align-content:center;gap:var(--space-1);padding:var(--space-2) var(--space-3);border:0;border-right:1px solid var(--border);background:transparent;text-align:left}
.home-module-rail button:last-child{border-right:0}.home-module-rail button:hover{background:var(--muted)}
.home-module-rail button b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.875rem}.home-module-rail button small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.75rem;color:var(--text-muted);font-variant-numeric:tabular-nums}
.home-module-rail button[aria-pressed="true"]{box-shadow:inset 0 -.125rem 0 var(--module-accent);background:var(--module-accent-surface);color:var(--module-accent)}
.home-module-rail button[data-module="OMS"],.home-module-rail button[data-module="ERP"],.home-module-rail button[data-module="WMS"],.home-module-rail button[data-module="COMMON"]{border-top:.125rem solid var(--module-accent)}
#homeExplorer:focus-visible{outline:.125rem solid var(--module-accent);outline-offset:var(--space-1)}
@media(max-width:64rem){.home-module-rail{grid-template-columns:repeat(2,minmax(0,1fr))}.home-module-label{grid-column:1 / -1;min-height:var(--control-h);border-right:0;border-bottom:1px solid var(--border)}.home-module-rail button{border-bottom:1px solid var(--border)}.home-module-rail button:nth-last-child(-n+2){border-bottom:0}}
@media(max-width:40rem){.home-module-rail{grid-template-columns:1fr}.home-module-rail button{border-right:0}.home-module-rail button:nth-last-child(2){border-bottom:1px solid var(--border)}}
@media(forced-colors:active){.home-module-rail button[aria-pressed="true"]{outline:1px solid Highlight;outline-offset:-1px;box-shadow:none}}
@@ -0,0 +1,753 @@
(() => {
'use strict';
const screens = [
{ id:'HOME', title:'홈', module:'COMMON', group:'홈', type:'home', template:'HOME', path:'#home', keywords:['시작','메뉴'] },
{ id:'OMS-ORD-001', title:'주문관리', module:'OMS', group:'주문', type:'list', template:'T01', path:'#orders', keywords:['주문조회','출고대기','오류'] },
{ id:'ERP-MST-ITEM-001', title:'품목관리', module:'ERP', group:'기준정보', type:'master', template:'T02', path:'#items', keywords:['품목','상품','SKU'] },
{ id:'OMS-ORD-002', title:'주문등록', module:'OMS', group:'주문', type:'transaction', template:'T03', path:'#order-new', keywords:['신규주문','등록'] },
{ id:'ERP-PRICE-001', title:'품목단가 일괄입력', module:'ERP', group:'기준정보', type:'fast-entry', template:'T04', path:'#prices', keywords:['단가','Fast Entry','붙여넣기'] },
{ id:'ERP-INV-001', title:'재고현황', module:'ERP', group:'재고', type:'master-detail', template:'T05', path:'#inventory', keywords:['현재고','가용재고','로케이션'] },
{ id:'WMS-WORK-001', title:'오늘 물류작업', module:'WMS', group:'작업관리', type:'queue', template:'T06', path:'#wms-work', keywords:['피킹','입고','예외','Queue'] },
{ id:'COMMON-REC-001', title:'재고·출고 대사', module:'COMMON', group:'운영', type:'reconcile', template:'T07', path:'#reconcile', keywords:['대사','불일치','정합성'] },
{ id:'OMS-ORD-003', title:'주문 Excel 업로드', module:'OMS', group:'주문', type:'import', template:'T08', path:'#import', keywords:['엑셀','업로드','매핑'] },
{ id:'WMS-PICK-001', title:'출고 피킹', module:'WMS', group:'현장업무', type:'wms-mobile', template:'T09', path:'#picking', keywords:['PDA','스캔','바코드'] },
];
const orders = [
{no:'ORD-260811-1024',channel:'쿠팡',time:'16:42',buyer:'김민수',item:'런닝화 외 1',qty:3,amount:128000,stock:'정상',status:'출고대기',exception:''},
{no:'ORD-260811-1023',channel:'스마트스토어',time:'16:39',buyer:'이지은',item:'셔츠 BLACK',qty:1,amount:48000,stock:'⚠ 부족',status:'보류',exception:'재고부족'},
{no:'ORD-260811-1022',channel:'자사몰',time:'16:31',buyer:'박서준',item:'운동화 270',qty:2,amount:196000,stock:'정상',status:'할당완료',exception:''},
{no:'ORD-260811-1021',channel:'쿠팡',time:'16:28',buyer:'최유진',item:'양말 3PACK',qty:4,amount:36000,stock:'정상',status:'출고대기',exception:''},
{no:'ORD-260811-1018',channel:'11번가',time:'16:12',buyer:'정하늘',item:'후드티 GRAY',qty:1,amount:69000,stock:'정상',status:'오류',exception:'송장실패'},
{no:'ORD-260811-1014',channel:'쿠팡',time:'15:57',buyer:'한도윤',item:'운동화 BLACK',qty:2,amount:178000,stock:'정상',status:'출고대기',exception:''},
{no:'ORD-260811-1007',channel:'자사몰',time:'15:43',buyer:'오서연',item:'스니커즈 WHITE',qty:1,amount:82000,stock:'정상',status:'신규',exception:''},
{no:'ORD-260811-1002',channel:'스마트스토어',time:'15:31',buyer:'윤지호',item:'양말 WHITE',qty:5,amount:45000,stock:'정상',status:'출고대기',exception:'주소확인'},
];
const masterItems = [
{id:'ABC001',name:'남성 운동화',group:'신발',spec:'BLACK / 270',unit:'EA',warehouse:'서울물류센터',warehouseCode:'WH01',barcode:'8801234567890',lot:true,expiry:false,status:'사용'},
{id:'ABC002',name:'러닝화',group:'신발',spec:'WHITE / 265',unit:'EA',warehouse:'서울물류센터',warehouseCode:'WH01',barcode:'8801234567891',lot:false,expiry:false,status:'사용'},
{id:'ABC003',name:'양말',group:'의류',spec:'WHITE / FREE',unit:'PACK',warehouse:'부산물류센터',warehouseCode:'WH02',barcode:'8801234567892',lot:false,expiry:false,status:'사용'},
{id:'ABC004',name:'셔츠',group:'의류',spec:'BLACK / L',unit:'EA',warehouse:'서울물류센터',warehouseCode:'WH01',barcode:'8801234567893',lot:false,expiry:false,status:'사용'},
{id:'ABC005',name:'후드티',group:'의류',spec:'GRAY / XL',unit:'EA',warehouse:'인천물류센터',warehouseCode:'WH03',barcode:'8801234567894',lot:false,expiry:false,status:'사용'},
];
const inventoryItems = [
{id:'ABC001',name:'운동화',stock:1280,locations:[['서울센터','A-01-01',300,100,20],['서울센터','A-01-02',530,0,0],['부산센터','B-03-01',450,30,10]]},
{id:'ABC002',name:'양말',stock:721,locations:[['서울센터','A-02-01',421,50,0],['부산센터','B-02-07',300,0,10]]},
{id:'ABC003',name:'셔츠',stock:312,locations:[['서울센터','A-05-03',212,20,5],['인천센터','C-01-01',100,0,0]]},
{id:'ABC004',name:'후드티',stock:187,locations:[['서울센터','A-06-02',87,10,0],['부산센터','B-06-01',100,0,0]]},
];
const queueRows = [
{no:'WK-260811-184',type:'피킹',wave:'W260811-12',worker:'김작업',progress:'72%',sla:'17:10',status:'진행',exception:false},
{no:'WK-260811-182',type:'검수',wave:'W260811-11',worker:'박물류',progress:'91%',sla:'16:55',status:'지연',exception:true},
{no:'WK-260811-176',type:'입고',wave:'-',worker:'이현장',progress:'38%',sla:'17:30',status:'진행',exception:false},
{no:'WK-260811-171',type:'피킹',wave:'W260811-10',worker:'최창고',progress:'100%',sla:'16:30',status:'완료',exception:false},
];
const reconcileRows = [
{no:'ORD-001',expected:10,actual:10,diff:0,reason:'-',status:'정상'},
{no:'ORD-002',expected:10,actual:9,diff:-1,reason:'피킹부족',status:'확인필요'},
{no:'ORD-003',expected:5,actual:0,diff:-5,reason:'연계실패',status:'미처리'},
{no:'ORD-004',expected:12,actual:12,diff:0,reason:'-',status:'정상'},
];
const state = {
activeId:'HOME', module:'ALL', collapsed:false, density:'compact', theme:'light', homeAttentionExpanded:false,
favorites:new Set(['OMS-ORD-001','ERP-INV-001','WMS-WORK-001']), recents:['OMS-ORD-001','ERP-MST-ITEM-001','ERP-INV-001'], tabs:[],
selectedOrders:new Set(), orderQuickFilter:'all', orderAdvanced:false, orderDataState:'ready', commandProblem:null, searchIndex:0,
orderSearch:{channel:'all',status:'all',keyword:'',exception:'all'}, orderRememberSearch:false, orderSavedDefaults:null,
selectedMasterId:'ABC001', masterQuery:'', masterMode:'edit', masterDraft:null, masterErrors:{}, pendingMasterId:null,
transactionStatus:'작성', transactionErrors:[], transactionHeader:{orderDate:'2026-08-11',channel:'직접주문',customerCode:'10001',customerName:'대한상사',owner:'홍길동',warehouseCode:'WH01',warehouseName:'서울물류센터',receiver:'홍길동',phone:'010-1234-5678',postal:'06236',address:'서울특별시 강남구 테헤란로'},
transactionLines:[{code:'ABC001',name:'운동화',spec:'BLACK / 270',available:83,qty:2,price:98000,remark:''},{code:'XYZ003',name:'양말',spec:'WHITE',available:721,qty:3,price:9000,remark:''}],
inventoryItemId:'ABC001', inventorySearch:{warehouse:'all',keyword:''}, inventoryDataState:'ready',
queueFilter:'all', queueSearch:{worker:'',keyword:''}, queueDataState:'ready', selectedQueueRows:new Set(), queueActionResult:'',
mismatchOnly:true, reconcileSearch:{date:'2026-08-11',keyword:''}, reconcileDataState:'ready', selectedReconcileRows:new Set(), reconcileActionResult:'',
importStep:1, importFileName:'', importFileSize:0, importFileType:'', importJobStatus:'idle', importProgress:0, importProcessed:0,
pickingQty:4, pickingRequired:6, scanBuffer:'', scanError:'', networkStatus:'online', pendingSync:0, lastScanValue:'', lastScanAt:0,
pendingCloseId:null, pendingNavigationId:null, pendingNavigationOptions:null, pendingUnsavedMode:null, workspaceDirtyDiscarded:0,
lookupEntity:'', lookupField:'', lookupIndex:0,
fastRows:[
{code:'ABC001',name:'운동화',price:'98,000',date:'2026-08-11',remark:''},
{code:'ABC002',name:'러닝화',price:'89,000',date:'2026-08-11',remark:''},
{code:'ABC003',name:'양말',price:'9,000',date:'2026-08-11',remark:''},
{code:'ABC004',name:'셔츠',price:'48,000',date:'2026-08-11',remark:''},
...Array.from({length:8},()=>({code:'',name:'',price:'',date:'',remark:''})),
],
};
const $ = (selector, root=document) => root.querySelector(selector);
const $$ = (selector, root=document) => [...root.querySelectorAll(selector)];
const esc = value => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
const knownIds = new Set(screens.map(x=>x.id));
const app=$('#app'), workspace=$('#workspace'), sideNav=$('#sideNav'), tabs=$('#workspaceTabs');
const searchDialog=$('#menuSearchDialog'), searchInput=$('#menuSearchInput'), searchResults=$('#menuSearchResults');
const unsavedDialog=$('#unsavedDialog'), lookupDialog=$('#lookupDialog'), lookupSearchInput=$('#lookupSearchInput'), lookupResults=$('#lookupResults'), quantityDialog=$('#quantityDialog'), exceptionDialog=$('#exceptionDialog');
const drawer=$('#drawer'), drawerTitle=$('#drawerTitle'), drawerBody=$('#drawerBody'), drawerBackdrop=$('#drawerBackdrop');
const systemPanel=$('#systemPanel'),systemPanelTitle=$('#systemPanelTitle'),systemPanelBody=$('#systemPanelBody');
let drawerRestoreFocus=null,systemPanelRestoreFocus=null;
let lookupRestoreFocus=null;
let importTimer=null;
const lookupData={
customer:[
{id:'c-10001',code:'10001',name:'대한상사',secondary:'서울 · 정상'},
{id:'c-10002',code:'10002',name:'대한유통',secondary:'경기 · 정상'},
{id:'c-10003',code:'10003',name:'대한물류',secondary:'인천 · 정상'},
{id:'c-20001',code:'20001',name:'한빛상사',secondary:'부산 · 정상'},
],
warehouse:[
{id:'w-WH01',code:'WH01',name:'서울물류센터',secondary:'상온 · 사용'},
{id:'w-WH02',code:'WH02',name:'부산물류센터',secondary:'상온 · 사용'},
{id:'w-WH03',code:'WH03',name:'인천물류센터',secondary:'상온 · 사용'},
],
};
function screen(id){ return screens.find(x=>x.id===id) || screens[0]; }
function labelModule(module){ return module==='COMMON'?'공통':module; }
function iconFor(item){ return ({T01:'조',T02:'기',T03:'등',T04:'입',T05:'재',T06:'작',T07:'대',T08:'엑',T09:'피'})[item.template] || '·'; }
function announce(message){ $('#liveRegion').textContent=message; }
function toast(message,tone='success'){
const el=document.createElement('div'); el.className=`toast ${tone}`; el.textContent=message; $('#toastRegion').append(el); announce(message); setTimeout(()=>el.remove(),2600);
}
function persist(){
try{ localStorage.setItem('kbx-fe-reference-state',JSON.stringify({favorites:[...state.favorites],density:state.density,collapsed:state.collapsed,theme:state.theme,orderRememberSearch:state.orderRememberSearch,orderSavedDefaults:state.orderSavedDefaults})); }catch{}
}
function persistWorkspaceSession(){
const payload={tabs:state.tabs.filter(tab=>!tab.dirty).slice(0,10).map(tab=>({id:tab.id,pinned:Boolean(tab.pinned)})),dirtyDiscardedCount:Math.min(10,state.tabs.filter(tab=>tab.dirty).length)};
try{sessionStorage.setItem('kbx-fe-reference-workspace-v1',JSON.stringify(payload));}catch{}
}
function restoreWorkspaceSession(){
let raw='';try{raw=sessionStorage.getItem('kbx-fe-reference-workspace-v1')||'';}catch{return;}
if(!raw||raw.length>16000)return;
try{const value=JSON.parse(raw);if(!Array.isArray(value.tabs))return;const seen=new Set();state.tabs=value.tabs.slice(0,10).flatMap(item=>{const id=typeof item?.id==='string'&&knownIds.has(item.id)&&item.id!=='HOME'?item.id:'';if(!id||seen.has(id))return [];seen.add(id);return [{id,title:screen(id).title,pinned:Boolean(item.pinned),dirty:false,resumed:true}];});state.workspaceDirtyDiscarded=Math.min(10,Math.max(0,Number(value.dirtyDiscardedCount)||0));}catch{}
}
function restore(){
let raw='';
try{ raw=localStorage.getItem('kbx-fe-reference-state')||''; }catch{return;}
if(!raw)return;
try{
const value=JSON.parse(raw);
if(Array.isArray(value.favorites))state.favorites=new Set(value.favorites.filter(id=>knownIds.has(id)&&id!=='HOME'));
if(['compact','comfortable'].includes(value.density))state.density=value.density;
if(typeof value.collapsed==='boolean')state.collapsed=value.collapsed;
if(['light','dark'].includes(value.theme))state.theme=value.theme;
if(typeof value.orderRememberSearch==='boolean')state.orderRememberSearch=value.orderRememberSearch;
if(value.orderSavedDefaults&&typeof value.orderSavedDefaults==='object'){
const d=value.orderSavedDefaults;
const safe={channel:['all','쿠팡','스마트스토어','자사몰','11번가'].includes(d.channel)?d.channel:'all',status:['all','신규','출고대기','할당완료','보류','오류'].includes(d.status)?d.status:'all',exception:['all','재고부족','주소확인','송장실패'].includes(d.exception)?d.exception:'all'};
state.orderSavedDefaults=safe;state.orderSearch={...state.orderSearch,...safe,keyword:''};
}
}catch{}
}
function markDirty(id,dirty=true){ const tab=state.tabs.find(x=>x.id===id); if(tab){tab.dirty=dirty;tab.resumed=false;persistWorkspaceSession();renderTabs();} }
function addRecent(id){ if(id==='HOME')return; state.recents=[id,...state.recents.filter(x=>x!==id)].slice(0,6); }
function setHash(s){ if(location.hash!==s.path)location.hash=s.path; }
function openScreen(id,{focus=true,updateHash=true,force=false}={}){
const target=screen(id);
const current=state.tabs.find(t=>t.id===state.activeId);
if(!force&&target.id!==state.activeId&&current?.dirty){
state.pendingNavigationId=target.id;
state.pendingNavigationOptions={focus,updateHash};
state.pendingUnsavedMode='navigate';
$('#unsavedMessage').textContent=`${current.title}에 저장하지 않은 변경사항이 있습니다. 이동하기 전에 저장하거나 변경을 버리세요.`;
if(!unsavedDialog.open)unsavedDialog.showModal();
return false;
}
state.activeId=target.id; addRecent(target.id);
const existingTab=state.tabs.find(t=>t.id===target.id);if(existingTab)existingTab.resumed=false;
if(target.id!=='HOME'&&!existingTab)state.tabs.push({id:target.id,title:target.title,pinned:false,dirty:false,resumed:false});
persistWorkspaceSession();if(updateHash)setHash(target); renderAll(); if(focus)workspace.focus({preventScroll:true});
return true;
}
function closeTabNow(id){
const index=state.tabs.findIndex(t=>t.id===id); if(index<0)return;
const wasActive=state.activeId===id; state.tabs.splice(index,1);persistWorkspaceSession();
if(wasActive){ const fallback=state.tabs[index-1] || state.tabs[index] || null; openScreen(fallback?.id || 'HOME'); }
else renderTabs();
}
function requestCloseTab(id){
const tab=state.tabs.find(t=>t.id===id); if(!tab)return;
if(!tab.dirty){closeTabNow(id);return;}
state.pendingCloseId=id; state.pendingUnsavedMode='close';
$('#unsavedMessage').textContent=`${tab.title}에 저장하지 않은 변경사항이 있습니다.`;
if(!unsavedDialog.open)unsavedDialog.showModal();
}
function groupScreens(list=screens.filter(x=>x.id!=='HOME')){
const map=new Map();
list.forEach(item=>{ if(!map.has(item.module))map.set(item.module,new Map());const groups=map.get(item.module);if(!groups.has(item.group))groups.set(item.group,[]);groups.get(item.group).push(item); });
return map;
}
function isFav(id){return state.favorites.has(id)}
function toggleFavorite(id){ if(!knownIds.has(id)||id==='HOME')return; isFav(id)?state.favorites.delete(id):state.favorites.add(id);persist();renderNavigation();if(state.activeId==='HOME')renderHome(); }
function navRow(item,favorite=true,icon=''){
const collapsedLabel=icon||iconFor(item);
return `<div class="nav-row" data-screen="${esc(item.id)}"><button class="nav-entry ${state.activeId===item.id?'active':''}" type="button" title="${esc(item.title)}" ${state.activeId===item.id?'aria-current="page"':''}>${state.collapsed?esc(collapsedLabel):esc(item.title)}</button>${favorite?`<button class="star ${isFav(item.id)?'on':''}" type="button" aria-label="${isFav(item.id)?'즐겨찾기 해제':'즐겨찾기 추가'}" aria-pressed="${isFav(item.id)}">${isFav(item.id)?'★':'☆'}</button>`:''}</div>`;
}
function bindNavRow(row){ const id=row.dataset.screen; $('.nav-entry',row).onclick=()=>openScreen(id);const star=$('.star',row);if(star)star.onclick=e=>{e.stopPropagation();toggleFavorite(id)}; }
function renderNavigation(){
app.dataset.density=state.density;app.dataset.theme=state.theme;app.dataset.nav=state.collapsed?'collapsed':'expanded';app.dataset.module=state.module==='ALL'?(screen(state.activeId).module||'COMMON'):state.module;document.documentElement.style.colorScheme=state.theme;sideNav.classList.toggle('collapsed',state.collapsed);
$('#collapseButton').textContent=state.collapsed?'':'';$('#collapseButton').setAttribute('aria-label',state.collapsed?'메뉴 펼치기':'메뉴 접기');$('#collapseButton').setAttribute('aria-expanded',String(!state.collapsed));
$('#densityButton').textContent=state.density==='compact'?'Compact':'Comfortable';
const themeButton=$('#themeButton');if(themeButton){themeButton.innerHTML=state.theme==='dark'?'<span aria-hidden="true">☾</span> <span>Dark</span>':'<span aria-hidden="true">☀</span> <span>Light</span>';themeButton.setAttribute('aria-label',state.theme==='dark'?'밝은 테마로 변경':'어두운 테마로 변경');}
const modules=['OMS','ERP','WMS','COMMON'];
$('#moduleSwitcher').innerHTML=modules.map(m=>`<button type="button" class="${state.module===m?'active':''}" data-module="${m}" aria-pressed="${state.module===m}" title="${esc(labelModule(m))}">${state.collapsed?(m==='COMMON'?'공':m[0]):esc(labelModule(m))}</button>`).join('');
$$('#moduleSwitcher button').forEach(button=>button.onclick=()=>{state.module=button.dataset.module;$('#moduleSelect').value=state.module;renderNavigation();if(state.activeId==='HOME')renderHome();});
$('#moduleSelect').value=state.module;
const favoriteItems=screens.filter(x=>isFav(x.id)&&x.id!=='HOME'&&(state.module==='ALL'||x.module===state.module));
$('#favoriteNav').innerHTML=favoriteItems.length?`<h2>즐겨찾기</h2>${favoriteItems.slice(0,6).map(item=>navRow(item)).join('')}`:'';
const recentItems=state.recents.map(screen).filter(x=>x.id!=='HOME'&&(state.module==='ALL'||x.module===state.module));
$('#recentNav').innerHTML=recentItems.length?`<h2>최근메뉴</h2>${recentItems.slice(0,4).map(item=>navRow(item,false,'↺')).join('')}`:'';
const visible=screens.filter(x=>x.id!=='HOME'&&(state.module==='ALL'||x.module===state.module));const grouped=groupScreens(visible);
$('#menuNav').innerHTML=[...grouped.entries()].map(([module,groups])=>`<section class="menu-group"><h2>${esc(labelModule(module))} 업무</h2>${[...groups.entries()].map(([group,items])=>`<div class="menu-subgroup"><h2>${esc(group)}</h2>${items.map(item=>navRow(item)).join('')}</div>`).join('')}</section>`).join('');
$$('.nav-row',sideNav).forEach(bindNavRow);$('#sideHomeButton').classList.toggle('active',state.activeId==='HOME');$('#sideHomeButton').setAttribute('aria-current',state.activeId==='HOME'?'page':'false');
}
function renderTabs(){
if(!state.tabs.length){tabs.innerHTML='<div class="empty-tabs">열린 업무가 없습니다.</div>';return;}
const focusableId=state.tabs.some(t=>t.id===state.activeId)?state.activeId:state.tabs[0]?.id;
tabs.innerHTML=state.tabs.map(t=>`<div class="tab-shell ${state.activeId===t.id?'active':''}" data-tab="${esc(t.id)}"><button class="tab-main" role="tab" aria-selected="${state.activeId===t.id}" tabindex="${focusableId===t.id?'0':'-1'}" type="button">${t.resumed&&!t.dirty?'<span class="tab-resumed" title="이전 세션에서 복원된 업무 위치" aria-label="이전 세션에서 복원됨">↺</span>':''}<span class="tab-title">${esc(t.title)}</span>${t.dirty?'<span class="tab-dirty" title="저장하지 않은 변경" aria-label="저장하지 않은 변경">●</span>':''}</button><button class="tab-pin" type="button" aria-label="${t.pinned?'고정 해제':'고정'}" aria-pressed="${t.pinned}">${t.pinned?'◆':'◇'}</button><button class="tab-close" type="button" aria-label="${esc(t.title)} 닫기">×</button></div>`).join('');
const shells=$$('.tab-shell',tabs);
shells.forEach((shell,index)=>{
const id=shell.dataset.tab;const main=$('.tab-main',shell);
main.onclick=()=>openScreen(id);$('.tab-close',shell).onclick=e=>{e.stopPropagation();requestCloseTab(id)};
$('.tab-pin',shell).onclick=e=>{e.stopPropagation();const tab=state.tabs.find(x=>x.id===id);if(tab){tab.pinned=!tab.pinned;persistWorkspaceSession();renderTabs();}};
main.onkeydown=e=>{let next=index;if(e.key==='ArrowRight')next=(index+1)%shells.length;else if(e.key==='ArrowLeft')next=(index-1+shells.length)%shells.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=shells.length-1;else return;e.preventDefault();const target=shells[next]?.dataset.tab;if(target){openScreen(target,{focus:false});$('.tab-main',shells[next])?.focus();}};
});
}
const btn=(label,cls='',id='',disabled=false)=>`<button ${id?`id="${esc(id)}"`:''} type="button" class="btn ${cls}" ${disabled?'disabled aria-disabled="true"':''}>${esc(label)}</button>`;
function defaultCommands(primary='저장 F8',prefix='default'){
return `<div class="command-group">${btn('조회 F3','',''+prefix+'Search')}${btn('신규','',''+prefix+'New')}${btn(primary,'primary',''+prefix+'Save')}</div><span class="command-separator"></span><div class="command-group">${btn('엑셀 ▼')}${btn('더보기 ⋯')}</div>`;
}
function pageShell(s,body,commands=''){
return `<section class="screen" data-screen-id="${esc(s.id)}" data-template="${esc(s.template)}" data-module="${esc(s.module)}"><header class="page-header"><div class="page-identity"><div class="breadcrumb">${esc(s.module)} &gt; ${esc(s.group)}</div><div class="title-row"><h1>${esc(s.title)}</h1><span class="status-chip">${esc(s.template)}</span></div></div><div class="page-tools"><button id="pageFavorite" type="button" aria-label="${isFav(s.id)?'즐겨찾기 해제':'즐겨찾기 추가'}" aria-pressed="${isFav(s.id)}">${isFav(s.id)?'★':'☆'}</button><button id="pageHelp" type="button">도움말</button><button id="pageAi" type="button">AI</button><button id="pageSuggestion" type="button">제안</button></div></header>${commands?`<div class="command-bar">${commands}</div>`:''}<div class="screen-body">${body}</div></section>`;
}
function setBackgroundInert(value){for(const el of [$('.global-header'),$('.shell-body')]){if(!el)continue;value?el.setAttribute('inert',''):el.removeAttribute('inert');}}
function openDrawerContent(title,html){
drawerRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;drawerTitle.textContent=title;drawerBody.innerHTML=html;
drawer.classList.add('open');drawer.setAttribute('aria-hidden','false');drawerBackdrop.hidden=false;setBackgroundInert(true);setTimeout(()=>$('#drawerClose').focus(),0);
}
function applyIntent(intent){
if(intent==='order-stock'){state.orderQuickFilter='stock';state.selectedOrders.clear();state.orderDataState='ready';return openScreen('OMS-ORD-001');}
if(intent==='order-errors'){state.orderQuickFilter='error';state.selectedOrders.clear();state.orderDataState='ready';return openScreen('OMS-ORD-001');}
if(intent==='reconcile-mismatch'){state.mismatchOnly=true;return openScreen('COMMON-REC-001');}
if(intent==='import-progress'){state.importStep=state.importJobStatus==='completed'?6:Math.max(1,state.importStep);return openScreen('OMS-ORD-003');}
}
function showUtilityPanel(s,kind){
if(kind==='help'){openDrawerContent(`${s.title} 도움말`,`<section class="utility-panel"><p class="utility-kicker">화면 도움말 · ${esc(s.id)}</p><h2>${esc(s.title)}</h2><p>이 화면은 ${esc(s.group)} 업무를 표준 ${esc(s.template)} 문법으로 처리합니다.</p><h3>기본 사용법</h3><ol><li>조회 조건 또는 업무 대상을 확인합니다.</li><li>필요한 행이나 값을 선택합니다.</li><li>Command Bar의 업무 동사를 실행합니다.</li></ol><h3>단축키</h3><dl class="shortcut-list"><div><dt>F2</dt><dd>Lookup</dd></div><div><dt>F3</dt><dd>조회</dd></div><div><dt>F8</dt><dd>저장</dd></div><div><dt>Esc</dt><dd>현재 Context 닫기</dd></div></dl><div class="utility-note">화면의 Disabled 상태는 UX 표현이며 최종 권한과 업무 규칙은 서버에서 재검증해야 합니다.</div></section>`);return;}
if(kind==='ai'){openDrawerContent(`${s.title} AI 도우미`,`<section class="utility-panel"><p class="utility-kicker">현재 화면 · ${esc(s.id)}</p><h2>확인할 항목</h2><div class="ai-attention"><button type="button" data-ai-intent="order-stock"><span>재고부족</span><b>7건</b><small>주문관리에서 보기</small></button><button type="button" data-ai-intent="order-errors"><span>처리오류</span><b>16건</b><small>오류 주문만 보기</small></button><button type="button" data-ai-intent="reconcile-mismatch"><span>대사 불일치</span><b>43건</b><small>불일치만 보기</small></button></div><h3>빠른 질문</h3><div class="utility-actions"><button class="btn" type="button" data-ai-intent="order-stock">왜 출고가 안 된 주문이 있어?</button><button class="btn" type="button" data-ai-intent="reconcile-mismatch">불일치만 보여줘</button></div><div class="utility-note">AI는 설명·제안만 수행합니다. 실제 변경은 구조화된 Proposal과 사용자 확인 후 Domain Command로 실행해야 합니다.</div></section>`);$$('[data-ai-intent]',drawerBody).forEach(button=>button.onclick=()=>{const intent=button.dataset.aiIntent;closeDrawer();setTimeout(()=>applyIntent(intent),0);});return;}
openDrawerContent(`${s.title} 제안`,`<form id="suggestionForm" class="utility-panel"><p class="utility-kicker">사용자 제안 · ${esc(s.id)}</p><h2>이 화면에 의견 보내기</h2><fieldset class="suggestion-types"><legend>유형</legend><label><input type="radio" name="type" value="불편" checked> 불편</label><label><input type="radio" name="type" value="오류"> 오류</label><label><input type="radio" name="type" value="개선 제안"> 개선 제안</label></fieldset><label class="suggestion-copy"><span>내용</span><textarea id="suggestionText" required maxlength="500" rows="7" placeholder="업무 중 불편했던 상황과 기대하는 동작을 적어주세요."></textarea></label><label class="suggestion-context"><input type="checkbox" checked> 현재 화면 정보 포함 <small>ScreenId, Route, Version, 필터 구조만 포함 · 업무 민감데이터 제외</small></label><div class="drawer-actions"><button class="btn primary" type="submit">보내기</button></div></form>`);$('#suggestionForm').onsubmit=e=>{e.preventDefault();const value=$('#suggestionText').value.trim();if(!value){$('#suggestionText').focus();return;}closeDrawer();toast('화면 의견을 접수했습니다. 업무 민감데이터는 포함하지 않았습니다.');};setTimeout(()=>$('#suggestionText')?.focus(),0);
}
function closeSystemPanel(restoreFocus=true){
if(systemPanel.hidden)return;systemPanel.hidden=true;$('#jobsButton').setAttribute('aria-expanded','false');$('#noticeButton').setAttribute('aria-expanded','false');
const restore=systemPanelRestoreFocus;systemPanelRestoreFocus=null;if(restoreFocus)restore?.focus();
}
function showSystemPanel(kind){
const trigger=kind==='jobs'?$('#jobsButton'):$('#noticeButton');
if(!systemPanel.hidden&&trigger.getAttribute('aria-expanded')==='true'){closeSystemPanel();return;}
closeDrawer();systemPanelRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;systemPanelTitle.textContent=kind==='jobs'?'작업 센터':'알림 센터';
$('#jobsButton').setAttribute('aria-expanded',String(kind==='jobs'));$('#noticeButton').setAttribute('aria-expanded',String(kind!=='jobs'));
if(kind==='jobs'){const progress=state.importJobStatus==='running'?`${state.importProgress}%`:'완료';systemPanelBody.innerHTML=`<section class="utility-panel"><p class="utility-kicker">백그라운드 작업</p><h2>진행 중 / 최근 작업</h2><button class="system-row" type="button" data-system-intent="import-progress"><span><b>Excel 주문 Import</b><small>${esc(progress)} · 정상 ${state.importProcessed.toLocaleString('ko-KR')}건</small></span><span>열기 </span></button><div class="system-row static"><span><b>송장 재처리</b><small>대기 · 자동 재시도</small></span><span>대기</span></div></section>`;}else{systemPanelBody.innerHTML=`<section class="utility-panel"><p class="utility-kicker">업무 알림</p><h2>확인 필요 4건</h2><button class="system-row critical" type="button" data-system-intent="order-errors"><span><b>송장 발급 실패 3건</b><small>원인 확인 후 재처리</small></span><span>열기 </span></button><button class="system-row" type="button" data-system-intent="order-stock"><span><b>재고부족 주문 7건</b><small>예외 주문만 확인</small></span><span>열기 </span></button></section>`;}
systemPanel.hidden=false;$$('[data-system-intent]',systemPanelBody).forEach(button=>button.onclick=()=>{const intent=button.dataset.systemIntent;closeSystemPanel(false);setTimeout(()=>applyIntent(intent),0);});setTimeout(()=>systemPanel.focus(),0);
}
function bindPageTools(s){
const fav=$('#pageFavorite');if(fav)fav.onclick=()=>toggleFavorite(s.id);
const help=$('#pageHelp');if(help)help.onclick=()=>showUtilityPanel(s,'help');
const ai=$('#pageAi');if(ai)ai.onclick=()=>showUtilityPanel(s,'ai');
const suggestion=$('#pageSuggestion');if(suggestion)suggestion.onclick=()=>showUtilityPanel(s,'suggestion');
}
function grid(cols,rows,{caption='업무 데이터',className=''}={}){
return `<div class="grid-wrap ${className}"><table class="data-grid"><caption class="sr-only">${esc(caption)}</caption><thead><tr>${cols.map(c=>`<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
function formField(label,value,{required=false,name='',readonly=false}={}){
return `<div class="form-field"><label class="${required?'required':''}">${esc(label)}</label><input ${name?`data-field="${esc(name)}"`:''} value="${esc(value)}" ${readonly?'readonly':''}></div>`;
}
function lookupField(label,code,name,{field='',entity=field}={}){
return `<div class="form-field"><label>${esc(label)}</label><div class="lookup" data-lookup="${esc(entity)}" data-lookup-field="${esc(field)}"><input ${field?`data-field="${esc(field)}Code"`:''} value="${esc(code)}" autocomplete="off"><input ${field?`data-field="${esc(field)}Name"`:''} value="${esc(name)}" readonly><button type="button" class="lookup-trigger" title="F2 조회" aria-label="${esc(label)} 조회" aria-keyshortcuts="F2">검색</button></div></div>`;
}
function checkboxField(label,checked,{field=''}={}){return `<div class="form-field"><label>${esc(label)}</label><label class="checkbox-control"><input ${field?`data-field="${esc(field)}"`:''} type="checkbox" ${checked?'checked':''}> 사용</label></div>`;}
function lookupItems(){
const list=lookupData[state.lookupEntity]||[];const q=(lookupSearchInput.value||'').trim().toLowerCase();
return list.filter(item=>!q||`${item.code} ${item.name} ${item.secondary}`.toLowerCase().includes(q));
}
function renderLookupResults(){
const list=lookupItems();if(state.lookupIndex>=list.length)state.lookupIndex=Math.max(0,list.length-1);
lookupResults.innerHTML=list.length?list.map((item,index)=>`<button type="button" class="lookup-result ${index===state.lookupIndex?'active':''}" data-index="${index}" role="option" aria-selected="${index===state.lookupIndex}"><span class="code">${esc(item.code)}</span><span class="copy"><b>${esc(item.name)}</b><small>${esc(item.secondary)}</small></span></button>`).join(''):'<div class="search-empty">조회된 항목이 없습니다.</div>';
$('#lookupStatus').textContent=`${list.length}건 · ↑↓ 이동 · Enter 선택 · Esc 닫기`;
$$('.lookup-result',lookupResults).forEach((button,index)=>{button.onmousemove=()=>{state.lookupIndex=index;renderLookupResults();};button.onclick=()=>{state.lookupIndex=index;selectLookup();};});
$('#lookupSelectButton').disabled=!list.length;
}
function openLookup(root){
if(!root)return;state.lookupEntity=root.dataset.lookup||'';state.lookupField=root.dataset.lookupField||'';state.lookupIndex=0;lookupRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;
const label=root.closest('.form-field')?.querySelector('label')?.textContent?.trim()||'업무';$('#lookupTitle').textContent=`${label} 검색`;$('#lookupSubtitle').textContent='F2 · 코드/명칭 조회';lookupSearchInput.value='';renderLookupResults();
if(!lookupDialog.open)lookupDialog.showModal();setTimeout(()=>lookupSearchInput.focus(),0);
}
function focusAfterLookup(root){
const focusables=$$('input:not([disabled]),select:not([disabled]),button:not([disabled]),textarea:not([disabled])',workspace);
const current=root?.querySelector('button.lookup-trigger');const index=focusables.indexOf(current);(focusables[index+1]||lookupRestoreFocus)?.focus();
}
function applyLookupToActive(field,item){
if(state.activeId==='ERP-MST-ITEM-001'&&field==='warehouse'&&state.masterDraft){state.masterDraft.warehouseCode=item.code;state.masterDraft.warehouse=item.name;}
if(state.activeId==='OMS-ORD-002'){
if(field==='customer'){state.transactionHeader.customerCode=item.code;state.transactionHeader.customerName=item.name;}
if(field==='warehouse'){state.transactionHeader.warehouseCode=item.code;state.transactionHeader.warehouseName=item.name;}
}
}
function selectLookup(){
const list=lookupItems();const item=list[state.lookupIndex];if(!item)return;const root=$(`.lookup[data-lookup-field="${CSS.escape(state.lookupField)}"]`,workspace);
if(root){const code=$(`[data-field="${CSS.escape(state.lookupField)}Code"]`,root);const name=$(`[data-field="${CSS.escape(state.lookupField)}Name"]`,root);if(code)code.value=item.code;if(name)name.value=item.name;applyLookupToActive(state.lookupField,item);markDirty(state.activeId,true);}
lookupDialog.close();announce(`${item.code} ${item.name}을 선택했습니다.`);toast(`${item.name}을 선택했습니다.`);setTimeout(()=>focusAfterLookup(root),0);
}
function bindLookups(){
$$('.lookup',workspace).forEach(root=>{const trigger=$('.lookup-trigger',root);if(trigger)trigger.onclick=()=>openLookup(root);$$('input',root).forEach(input=>input.onkeydown=e=>{if(e.key==='F2'){e.preventDefault();openLookup(root);}});});
}
function dataState(kind,title,detail,retryId=''){
const action=retryId?btn('다시 조회','primary',retryId):'';
return `<section class="data-state ${esc(kind)}" role="status"><strong>${esc(title)}</strong><span>${esc(detail)}</span>${action}</section>`;
}
function homeAttentionItems(){
return [
{source:'실패 작업',title:'송장 발급 실패 3건',detail:'오류 주문 필터로 바로 이동',id:'OMS-ORD-001',critical:true,intent:'order-errors'},
{source:'예외',title:'재고부족 주문 7건',detail:'재고부족 필터를 적용해 이동',id:'OMS-ORD-001',critical:true,intent:'order-stock'},
{source:'중요 알림',title:'대사 불일치 43건',detail:'불일치만 표시해 이동',id:'COMMON-REC-001',critical:false,intent:'reconcile-mismatch'},
{source:'진행 작업',title:state.importJobStatus==='running'?`Excel 주문 반영 ${state.importProgress}%`:state.importJobStatus==='completed'?'Excel 주문 반영 완료':'Excel 주문 업로드 대기',detail:'작업상태 보기',id:'OMS-ORD-003',critical:false,intent:'import-progress'},
{source:'미저장',title:'주문등록 수정 중',detail:'저장하지 않은 변경사항 이어서 편집',id:'OMS-ORD-002',critical:false,intent:''},
{source:'알림',title:'주소 확인 필요 4건',detail:'배송지 오류 주문만 확인',id:'OMS-ORD-001',critical:false,intent:'order-errors'},
{source:'알림',title:'SKU 매칭 실패 2건',detail:'상품 매핑 오류 주문만 확인',id:'OMS-ORD-001',critical:false,intent:'order-errors'},
{source:'진행 작업',title:'피킹 작업 1건 지연',detail:'현재 물류작업 Queue에서 확인',id:'WMS-WORK-001',critical:false,intent:''},
{source:'알림',title:'품목 바코드 확인 1건',detail:'품목 기준정보에서 확인',id:'ERP-MST-ITEM-001',critical:false,intent:''},
{source:'진행 작업',title:'재고 실사 작업 확인',detail:'물류 작업 Queue에서 확인',id:'WMS-WORK-001',critical:false,intent:''},
...Array.from({length:18},(_,index)=>({source:index%3===0?'진행 작업':'알림',title:`추가 확인 업무 ${index+1}`,detail:index%3===0?'작업 센터에서 전체 진행상태 확인':'알림 센터에서 전체 알림 확인',id:index%3===0?'WMS-WORK-001':'OMS-ORD-001',critical:false,intent:''})),
];
}
function attention(source,title,detail,id,critical=false,intent=''){return `<button type="button" class="attention-item ${critical?'critical':''}" data-screen="${esc(id)}" ${intent?`data-intent="${esc(intent)}"`:''}><span class="attention-source">${esc(source)}</span><span class="attention-copy"><b>${esc(title)}</b><small>${esc(detail)}</small></span><span class="attention-action">열기</span><span aria-hidden="true"></span></button>`;}
function continueItem(source,title,detail,id,type){return `<button type="button" class="continue-item" data-screen="${esc(id)}" data-source="${esc(type)}"><span class="continue-source">${esc(source)}</span><span class="continue-copy"><b>${esc(title)}</b><small>${esc(detail)}</small></span><span class="attention-action">${type==='dirty'?'이어서':'열기'}</span><span aria-hidden="true"></span></button>`;}
function homeMenuRow(item){return `<div class="home-menu-row" data-screen="${esc(item.id)}"><button class="open-screen" type="button"><span>${esc(item.title)}</span><small>${esc(item.template)}</small></button><button class="fav ${isFav(item.id)?'on':''}" type="button" aria-label="${isFav(item.id)?'즐겨찾기 해제':'즐겨찾기 추가'}" aria-pressed="${isFav(item.id)}">${isFav(item.id)?'★':'☆'}</button></div>`;}
function renderHome(){
const allScreens=screens.filter(x=>x.id!=='HOME');const visible=allScreens.filter(x=>state.module==='ALL'||x.module===state.module);const grouped=groupScreens(visible);
const moduleKeys=['OMS','ERP','WMS','COMMON'].filter(module=>allScreens.some(item=>item.module===module));
const moduleRail=`<nav class="home-module-rail" aria-label="업무 영역 바로가기"><span class="home-module-label">업무영역</span><button type="button" data-home-module="ALL" aria-pressed="${state.module==='ALL'}"><b>전체</b><small>${allScreens.length}개 화면</small></button>${moduleKeys.map(module=>{const items=allScreens.filter(item=>item.module===module);const favoriteCount=items.filter(item=>isFav(item.id)).length;const openCount=state.tabs.filter(tab=>screen(tab.id).module===module).length;return `<button type="button" data-home-module="${esc(module)}" data-module="${esc(module)}" aria-pressed="${state.module===module}"><b>${esc(labelModule(module))}</b><small>${items.length}개 · ★ ${favoriteCount} · 열림 ${openCount}</small></button>`;}).join('')}</nav>`;
const dirtyTab=state.tabs.find(t=>t.dirty);const resumedTab=state.tabs.find(t=>t.resumed&&!t.dirty);const regularOpen=state.tabs.find(t=>!t.dirty&&!t.resumed);
const allAttention=homeAttentionItems();const attentionCap=24;const visibleAttention=state.homeAttentionExpanded?allAttention.slice(0,attentionCap):allAttention.slice(0,8);const attentionOverflow=Math.max(allAttention.length-visibleAttention.length,0);const attentionToggleLabel=state.homeAttentionExpanded?'상위 8개만 보기':allAttention.length<=attentionCap?`전체 ${allAttention.length}개 보기`:`우선 ${attentionCap}개 보기`;const attentionCenters=state.homeAttentionExpanded&&attentionOverflow?`<div class="attention-centers" aria-label="나머지 확인 항목"><button id="homeJobsAll" class="section-toggle" type="button">작업 센터 전체</button><button id="homeNoticesAll" class="section-toggle" type="button">알림 센터 전체</button></div>`:'';
workspace.innerHTML=`<section class="home-page"><header class="home-heading"><div><p>KBX 업무 시작</p><h1>홈</h1><small>검색하고, 이어서 처리하고, 예외만 확인하는 한국형 업무 시작점${state.workspaceDirtyDiscarded?` · 이전 미저장 ${state.workspaceDirtyDiscarded}개는 안전상 복원 제외`:''}</small></div><button class="home-search" id="homeSearch" type="button" aria-keyshortcuts="Control+K"><span>업무 화면 검색</span><kbd>Ctrl K</kbd></button></header>
<nav class="home-pulse" aria-label="업무 시작 상태"><button id="homePulseScreens" type="button"><span>사용 가능 화면</span><b>${allScreens.length}</b><small>검색</small></button><button id="homePulseDirty" type="button" ${dirtyTab?'':'disabled'}><span>미저장 업무</span><b>${state.tabs.filter(tab=>tab.dirty).length}</b><small>${dirtyTab?'이어서':'없음'}</small></button><button id="homePulseJobs" type="button"><span>진행 작업</span><b>2</b><small>작업 센터</small></button><button id="homePulseNotices" type="button"><span>새 알림</span><b>4</b><small>알림 센터</small></button></nav>
${moduleRail}
<section class="attention-zone"><div class="section-head"><div><h2>확인 필요</h2><small>정상 8,224건은 자동 처리 · 사용자는 예외와 실패만 확인</small></div><div class="section-actions"><span class="section-count">전체 ${allAttention.length}개 · ${visibleAttention.length}개 표시</span><button id="homeAttentionToggle" class="section-toggle" type="button" aria-controls="homeAttentionGrid" aria-expanded="${state.homeAttentionExpanded}">${attentionToggleLabel}</button>${attentionCenters}</div></div><div id="homeAttentionGrid" class="attention-grid">${visibleAttention.map(item=>attention(item.source,item.title,item.detail,item.id,item.critical,item.intent)).join('')}</div></section>
<section class="home-section"><div class="section-head"><div><h2>바로 시작</h2><small>미저장 → 열린 업무 → 즐겨찾기 → 최근 순으로 중복 없이 연결</small></div><span class="section-count">업무 문맥 유지</span></div><div class="continue-list">${continueItem('미저장',dirtyTab?.title||'주문등록',dirtyTab?`${screen(dirtyTab.id).module} · 저장하지 않은 변경`:'OMS · 주문 · ORD 임시작성',dirtyTab?.id||'OMS-ORD-002','dirty')}${resumedTab?continueItem('복원',resumedTab.title,`${screen(resumedTab.id).module} · 이전 세션의 업무 위치`,resumedTab.id,'open'):continueItem('열림',regularOpen?.title||'품목관리',regularOpen?`${screen(regularOpen.id).module} · 열린 업무`:'ERP · 기준정보',regularOpen?.id||'ERP-MST-ITEM-001','open')}${continueItem('즐겨찾기','재고현황','ERP · 재고','ERP-INV-001','favorite')}${continueItem('최근','오늘 물류작업','WMS · 작업관리','WMS-WORK-001','recent')}</div></section>
<section id="homeExplorer" class="home-section" tabindex="-1"><div class="section-head"><div><h2>${state.module==='ALL'?'전체 업무':`${esc(labelModule(state.module))} 업무`}</h2><small>신규 화면은 빈 Canvas가 아니라 T01~T09 원형에서 시작 · 검증된 업무 문법을 재사용</small></div><div class="section-actions">${state.module!=='ALL'?'<button id="homeShowAll" class="section-toggle" type="button">전체 업무 보기</button>':''}<span class="section-count">${visible.length}개 화면</span></div></div><div class="work-explorer">${[...grouped.entries()].map(([module,groups])=>`<section class="module-column" data-module="${esc(module)}"><div class="module-title"><strong>${esc(labelModule(module))}</strong><span>${module==='OMS'?'주문·채널':module==='ERP'?'기준정보·구매·재고':module==='WMS'?'물류 작업':'공통 운영'}</span><small>${[...groups.values()].flat().length}개</small></div>${[...groups.entries()].map(([group,items])=>`<div class="group-block"><h3>${esc(group)}</h3>${items.map(homeMenuRow).join('')}</div>`).join('')}</section>`).join('')}</div></section>
</section>`;
$('#homeSearch').onclick=openSearch;$('#homePulseScreens').onclick=openSearch;const pulseDirty=$('#homePulseDirty');if(pulseDirty&&!pulseDirty.disabled)pulseDirty.onclick=()=>openScreen(dirtyTab.id);$('#homePulseJobs').onclick=()=>showSystemPanel('jobs');$('#homePulseNotices').onclick=()=>showSystemPanel('notices');$$('[data-home-module]',workspace).forEach(button=>button.onclick=()=>{state.module=button.dataset.homeModule;$('#moduleSelect').value=state.module;renderNavigation();renderHome();setTimeout(()=>$('#homeExplorer')?.scrollIntoView({block:'start',behavior:'smooth'}),0);});const showAll=$('#homeShowAll');if(showAll)showAll.onclick=()=>{state.module='ALL';$('#moduleSelect').value='ALL';renderNavigation();renderHome();setTimeout(()=>$('#homeExplorer')?.focus({preventScroll:true}),0);};const attentionToggle=$('#homeAttentionToggle');if(attentionToggle)attentionToggle.onclick=()=>{state.homeAttentionExpanded=!state.homeAttentionExpanded;renderHome();};const jobsAll=$('#homeJobsAll');if(jobsAll)jobsAll.onclick=()=>showSystemPanel('jobs');const noticesAll=$('#homeNoticesAll');if(noticesAll)noticesAll.onclick=()=>showSystemPanel('notices');$$('.attention-item',workspace).forEach(b=>b.onclick=()=>b.dataset.intent?applyIntent(b.dataset.intent):openScreen(b.dataset.screen));$$('.continue-item',workspace).forEach(b=>b.onclick=()=>openScreen(b.dataset.screen));$$('.home-menu-row',workspace).forEach(row=>{$('.open-screen',row).onclick=()=>openScreen(row.dataset.screen);$('.fav',row).onclick=()=>toggleFavorite(row.dataset.screen);});
}
function filteredOrders(){
let visible=orders;
if(state.orderQuickFilter==='new')visible=visible.filter(x=>x.status==='신규');
else if(state.orderQuickFilter==='ready')visible=visible.filter(x=>x.status==='출고대기');
else if(state.orderQuickFilter==='stock')visible=visible.filter(x=>x.stock.includes('부족'));
else if(state.orderQuickFilter==='error')visible=visible.filter(x=>x.exception||x.status==='오류');
if(state.orderSearch.channel!=='all')visible=visible.filter(x=>x.channel===state.orderSearch.channel);
if(state.orderSearch.status!=='all')visible=visible.filter(x=>x.status===state.orderSearch.status);
if(state.orderSearch.exception!=='all')visible=visible.filter(x=>x.exception===state.orderSearch.exception || (state.orderSearch.exception==='재고부족'&&x.stock.includes('부족')));
const keyword=state.orderSearch.keyword.trim().toLowerCase();
if(keyword)visible=visible.filter(x=>`${x.no} ${x.buyer} ${x.item} ${x.channel} ${x.exception}`.toLowerCase().includes(keyword));
return visible;
}
function searchOption(value,label,current){return `<option value="${esc(value)}" ${current===value?'selected':''}>${esc(label)}</option>`;}
function problemSurface(problem){
if(!problem)return '';
const kind={ 'business-rule':'업무 규칙', permission:'권한', 'not-found':'데이터', integration:'외부 연계', system:'시스템' }[problem.type]||'업무 오류';
const tone=['permission','system'].includes(problem.type)?'danger':problem.type==='integration'?(problem.retryable?'warning':'danger'):'warning';
const actions=(problem.actions||[]).map(action=>`<button type="button" class="btn secondary" data-problem-action="${esc(action.id)}">${esc(action.label)}</button>`).join('');
return `<section class="problem-feedback ${tone}" role="alert" data-problem-type="${esc(problem.type)}"><div class="problem-copy"><div class="problem-heading"><span>${esc(kind)}</span><strong>${esc(problem.title)}</strong></div>${problem.detail?`<p>${esc(problem.detail)}</p>`:''}${problem.correlationId?`<small>참조번호 ${esc(problem.correlationId)}</small>`:''}</div><div class="problem-actions">${actions}${problem.retryable?'<button id="problemRetry" type="button" class="btn secondary">다시 시도</button>':''}<button id="problemDismiss" type="button" class="btn ghost">닫기</button></div></section>`;
}
function clearProblem(){state.commandProblem=null;}
function activeOrderSearchCount(){return [state.orderSearch.channel!=='all',state.orderSearch.status!=='all',Boolean(state.orderSearch.keyword.trim()),state.orderSearch.exception!=='all'].filter(Boolean).length;}
function safeOrderDefaults(){return {channel:['all','쿠팡','스마트스토어','자사몰','11번가'].includes(state.orderSearch.channel)?state.orderSearch.channel:'all',status:['all','신규','출고대기','할당완료','보류','오류'].includes(state.orderSearch.status)?state.orderSearch.status:'all',exception:['all','재고부족','주소확인','송장실패'].includes(state.orderSearch.exception)?state.orderSearch.exception:'all'};}
function saveOrderDefaults(message){state.orderSavedDefaults=safeOrderDefaults();persist();toast(message||'현재 조회조건을 기본조건으로 저장했습니다. 자유검색어는 저장하지 않습니다.');}
function orderSearchPanel(){
const advanced=state.orderAdvanced?`<div class="search-advanced"><label class="field-inline"><span>택배사</span><select class="control" disabled title="Reference Lab: 택배사 데이터셋 미포함"><option>전체</option></select></label><label class="field-inline"><span>창고</span><select class="control" disabled title="Reference Lab: 창고 검색 데이터셋 미포함"><option>전체</option></select></label><label class="field-inline"><span>오류유형</span><select id="orderException" class="control">${searchOption('all','전체',state.orderSearch.exception)}${searchOption('재고부족','재고부족',state.orderSearch.exception)}${searchOption('주소확인','주소확인',state.orderSearch.exception)}${searchOption('송장실패','송장실패',state.orderSearch.exception)}</select></label><label class="search-preference"><input id="rememberOrderSearch" type="checkbox" ${state.orderRememberSearch?'checked':''}><span>마지막 조회조건 기억</span><small>주문자·주문번호·상품 자유검색어는 저장하지 않음</small></label></div>`:'';
const activeCount=activeOrderSearchCount();
return `<form class="search-panel ${state.orderAdvanced?'expanded':''}" id="orderSearchForm"><div class="search-fields"><label class="field-inline"><span>기간</span><input id="orderPeriod" class="control medium" value="2026-08-11 ~ 2026-08-11" readonly aria-label="주문기간"></label><label class="field-inline"><span>판매채널</span><select id="orderChannel" class="control">${searchOption('all','전체',state.orderSearch.channel)}${searchOption('쿠팡','쿠팡',state.orderSearch.channel)}${searchOption('스마트스토어','스마트스토어',state.orderSearch.channel)}${searchOption('자사몰','자사몰',state.orderSearch.channel)}${searchOption('11번가','11번가',state.orderSearch.channel)}</select></label><label class="field-inline"><span>상태</span><select id="orderStatus" class="control">${searchOption('all','전체',state.orderSearch.status)}${searchOption('신규','신규',state.orderSearch.status)}${searchOption('출고대기','출고대기',state.orderSearch.status)}${searchOption('할당완료','할당완료',state.orderSearch.status)}${searchOption('보류','보류',state.orderSearch.status)}${searchOption('오류','오류',state.orderSearch.status)}</select></label><label class="field-inline"><span>통합검색</span><input id="orderKeyword" class="control wide" value="${esc(state.orderSearch.keyword)}" placeholder="주문번호/주문자/상품" maxlength="80"></label></div><div class="search-actions">${btn('조회','primary','searchExecute')}${btn(state.orderAdvanced?`상세조건 ${activeCount?`(${activeCount})`:''} 닫기`:`상세조건${activeCount?` ${activeCount}`:''}`,'','toggleAdvanced')}${btn('초기화','','searchReset')}${btn('기본조건 저장','','saveOrderDefaults')}</div>${advanced}</form>`;
}
function captureOrderSearch(){
state.orderSearch.channel=$('#orderChannel')?.value||'all';
state.orderSearch.status=$('#orderStatus')?.value||'all';
state.orderSearch.keyword=$('#orderKeyword')?.value||'';
state.orderSearch.exception=$('#orderException')?.value||state.orderSearch.exception||'all';
const remember=$('#rememberOrderSearch');if(remember)state.orderRememberSearch=remember.checked;
}
function orderRows(){
return filteredOrders().map((order,index)=>`<tr class="order-data-row ${state.selectedOrders.has(order.no)?'selected-row':''}" data-order-row="${esc(order.no)}" tabindex="${index===0?'0':'-1'}"><td class="center"><input type="checkbox" aria-label="${esc(order.no)} 선택" data-order="${esc(order.no)}" ${state.selectedOrders.has(order.no)?'checked':''}></td><td><a href="#" class="link order-link" data-order="${esc(order.no)}">${esc(order.no)}</a></td><td>${esc(order.channel)}</td><td class="center">${esc(order.time)}</td><td>${esc(order.buyer)}</td><td>${esc(order.item)}</td><td class="num">${order.qty.toLocaleString('ko-KR')}</td><td class="num">₩${order.amount.toLocaleString('ko-KR')}</td><td class="center ${order.stock.includes('부족')?'warn':''}">${esc(order.stock)}</td><td class="center ${order.status==='오류'?'error':''}">${esc(order.status)}</td><td class="${order.exception?'error':''}">${esc(order.exception||'-')}</td></tr>`).join('');
}
function renderOrderData(){
if(state.orderDataState==='loading')return dataState('loading','조회 중...','기존 조회조건을 유지한 채 데이터를 불러오고 있습니다.');
if(state.orderDataState==='empty')return dataState('empty','조회된 주문이 없습니다.','조회조건을 변경하거나 초기화해 보세요.','orderRetry');
if(state.orderDataState==='error')return dataState('error','주문을 조회하지 못했습니다.','네트워크 연결을 확인한 후 다시 시도하세요. 입력한 조회조건은 보존됩니다.','orderRetry');
const visible=filteredOrders();
if(!visible.length)return dataState('empty','현재 필터에 해당하는 주문이 없습니다.','다른 빠른 필터를 선택해 보세요.');
const allSelected=visible.every(x=>state.selectedOrders.has(x.no));
const table=grid([`<input>`,'주문번호','채널','주문시각','주문자','대표상품','수량','주문금액','재고','출고상태','오류'],orderRows(),{caption:'주문 조회 결과'}).replace('<th scope="col">&lt;input&gt;</th>',`<th scope="col" class="center"><input id="selectVisibleOrders" type="checkbox" aria-label="현재 표시된 주문 전체 선택" ${allSelected?'checked':''}></th>`);
return table;
}
function renderT01(s){
const selection=state.selectedOrders.size;
const quick=[['all','전체','8,241'],['new','신규','2,138'],['ready','출고대기','927'],['stock','재고부족','7'],['error','처리오류','16']];
const body=`${problemSurface(state.commandProblem)}${orderSearchPanel()}<div class="quick-filter" role="group" aria-label="주문 빠른 필터">${quick.map(([key,label,count])=>`<button type="button" data-order-filter="${key}" class="${state.orderQuickFilter===key?'active':''} ${['stock','error'].includes(key)?'danger':''}" aria-pressed="${state.orderQuickFilter===key}"><small>${esc(label)}</small><b>${esc(count)}</b></button>`).join('')}</div><div class="context-bar"><strong>오늘 주문</strong><span>조회 8,241건</span><span>표시 ${filteredOrders().length}건</span><span>선택 <b id="selectionContext">${selection}</b>건</span><span>오류 16건</span></div><div id="bulkBar" class="bulk-bar" ${selection?'':'hidden'}><strong><span id="bulkCount">${selection}</span>건 선택</strong>${btn('출고지시','primary','bulkShip')}${btn('보류')}${btn('담당자 변경')}${btn('택배사 변경')}</div><div id="orderDataRegion">${renderOrderData()}</div><div class="summary-bar"><span>전체 8,241건</span><span>선택 <strong id="selectionSummary">${selection}</strong>건</span><span>상품수량 12,824</span><span>금액 <strong>₩182,420,300</strong></span></div>`;
const commands=`<div class="command-group">${btn('조회 F3','','commandSearch')}${btn('신규','','commandNew')}${btn('주문복사')}</div><span class="command-separator"></span><div class="command-group">${btn('출고지시','primary','commandShip',selection===0)}${btn('보류','','commandHold',selection===0)}</div><span class="command-separator"></span><div class="command-group">${btn('인쇄')}${btn('엑셀 ▼')}</div>`;
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT01();
}
function executeOrderSearch(){ clearProblem();captureOrderSearch();if(state.orderRememberSearch){state.orderSavedDefaults=safeOrderDefaults();persist();}state.selectedOrders.clear();state.orderDataState='loading';renderT01(screen('OMS-ORD-001'));setTimeout(()=>{state.orderDataState='ready';renderT01(screen('OMS-ORD-001'));toast(`현재 조건으로 ${filteredOrders().length}건을 조회했습니다.${state.orderRememberSearch?' 안전한 조회조건을 기억했습니다.':''}`);},260); }
function bindT01(){
$('#orderSearchForm').onsubmit=e=>{e.preventDefault();executeOrderSearch()};$('#searchExecute').onclick=executeOrderSearch;$('#commandSearch').onclick=executeOrderSearch;
$('#toggleAdvanced').onclick=()=>{captureOrderSearch();state.orderAdvanced=!state.orderAdvanced;renderT01(screen('OMS-ORD-001'));};
$('#searchReset').onclick=()=>{clearProblem();state.orderQuickFilter='all';state.orderAdvanced=false;state.orderSearch={channel:'all',status:'all',keyword:'',exception:'all'};state.selectedOrders.clear();state.orderDataState='ready';renderT01(screen('OMS-ORD-001'));toast('조회조건을 초기화했습니다.');};
const saveDefaults=$('#saveOrderDefaults');if(saveDefaults)saveDefaults.onclick=()=>{captureOrderSearch();saveOrderDefaults();renderT01(screen('OMS-ORD-001'));};
const remember=$('#rememberOrderSearch');if(remember)remember.onchange=()=>{captureOrderSearch();state.orderRememberSearch=remember.checked;if(remember.checked)state.orderSavedDefaults=safeOrderDefaults();persist();toast(remember.checked?'마지막 조회조건 기억을 켰습니다. 자유검색어는 저장하지 않습니다.':'마지막 조회조건 기억을 껐습니다.');renderT01(screen('OMS-ORD-001'));};
const retry=$('#orderRetry');if(retry)retry.onclick=executeOrderSearch;
$$('[data-order-filter]',workspace).forEach(button=>button.onclick=()=>{clearProblem();state.orderQuickFilter=button.dataset.orderFilter;state.selectedOrders.clear();state.orderDataState='ready';renderT01(screen('OMS-ORD-001'));announce(`${button.textContent.trim()} 필터를 적용했습니다.`);});
$$('input[data-order]',workspace).forEach(cb=>cb.onchange=()=>{cb.checked?state.selectedOrders.add(cb.dataset.order):state.selectedOrders.delete(cb.dataset.order);renderT01(screen('OMS-ORD-001'));});
const all=$('#selectVisibleOrders');if(all)all.onchange=()=>{filteredOrders().forEach(order=>all.checked?state.selectedOrders.add(order.no):state.selectedOrders.delete(order.no));renderT01(screen('OMS-ORD-001'));};
$$('.order-link',workspace).forEach(link=>link.onclick=e=>{e.preventDefault();showOrderDetail(link.dataset.order);});
const orderRows=$$('.order-data-row',workspace);orderRows.forEach((row,index)=>row.onkeydown=e=>{
if(['INPUT','A','BUTTON'].includes(e.target?.tagName))return;
if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();const next=Math.max(0,Math.min(orderRows.length-1,index+(e.key==='ArrowDown'?1:-1)));orderRows.forEach((x,i)=>x.tabIndex=i===next?0:-1);orderRows[next]?.focus();}
else if(e.key===' '){e.preventDefault();const no=row.dataset.orderRow;no&&(state.selectedOrders.has(no)?state.selectedOrders.delete(no):state.selectedOrders.add(no));renderT01(screen('OMS-ORD-001'));setTimeout(()=>$$('.order-data-row',workspace)[index]?.focus(),0);}
else if(e.key==='Enter'){e.preventDefault();showOrderDetail(row.dataset.orderRow);}
});
for(const id of ['bulkShip','commandShip']){const b=$('#'+id);if(b)b.onclick=()=>{if(!state.selectedOrders.size)return;const selected=orders.filter(order=>state.selectedOrders.has(order.no));const blocked=selected.filter(order=>Boolean(order.exception));if(blocked.length){state.commandProblem={type:'business-rule',title:'출고할 수 없는 주문이 포함되어 있습니다.',detail:`${blocked.length}건은 재고·주소·송장 예외를 먼저 해결해야 합니다. 정상 주문은 이 Reference Lab에서 임의 처리하지 않습니다.`,actions:[{id:'show-blocked',label:`문제 ${blocked.length}건만 선택`}],retryable:false};renderT01(screen('OMS-ORD-001'));return;}toast(`${state.selectedOrders.size}건 출고지시를 요청했습니다.`);};}const dismiss=$('#problemDismiss');if(dismiss)dismiss.onclick=()=>{clearProblem();renderT01(screen('OMS-ORD-001'));};$$('[data-problem-action="show-blocked"]',workspace).forEach(button=>button.onclick=()=>{const blocked=orders.filter(order=>state.selectedOrders.has(order.no)&&order.exception);state.selectedOrders=new Set(blocked.map(order=>order.no));state.orderQuickFilter='all';state.orderSearch.exception='all';clearProblem();renderT01(screen('OMS-ORD-001'));announce(`문제 주문 ${blocked.length}건만 선택했습니다.`);});
const newButton=$('#commandNew');if(newButton)newButton.onclick=()=>openScreen('OMS-ORD-002');
}
function showOrderDetail(orderNo){
const order=orders.find(x=>x.no===orderNo);if(!order)return;drawerRestoreFocus=document.activeElement instanceof HTMLElement?document.activeElement:null;
openDrawerContent(`주문 ${order.no}`,`<div class="detail-status"><span class="status-chip">${esc(order.status)}</span>${order.exception?`<span class="status-chip danger">${esc(order.exception)}</span>`:''}</div><div class="detail-list"><div class="detail-row"><span>판매채널</span><strong>${esc(order.channel)}</strong></div><div class="detail-row"><span>주문자</span><strong>${esc(order.buyer)} / 010-****-1234</strong></div><div class="detail-row"><span>대표상품</span><strong>${esc(order.item)}</strong></div><div class="detail-row"><span>주문금액</span><strong>₩${order.amount.toLocaleString('ko-KR')}</strong></div><div class="detail-row"><span>예외</span><strong>${esc(order.exception||'없음')}</strong></div></div><h3>처리이력</h3><div class="detail-list"><div class="detail-row"><span>16:42</span><strong>주문수집</strong></div><div class="detail-row"><span>16:43</span><strong>결제확인</strong></div><div class="detail-row"><span>16:45</span><strong>재고할당</strong></div></div><div class="drawer-actions">${btn('전체 상세')}${btn('출고지시','primary','drawerShip',Boolean(order.exception))}</div>`);
}
function closeDrawer(){drawer.classList.remove('open');drawer.setAttribute('aria-hidden','true');drawerBackdrop.hidden=true;setBackgroundInert(false);const restore=drawerRestoreFocus;drawerRestoreFocus=null;restore?.focus();}
function masterDraftFrom(item){return item?{...item}:{id:'',name:'',group:'',spec:'',unit:'EA',warehouse:'',warehouseCode:'',barcode:'',lot:false,expiry:false,status:'사용'};}
function loadMasterDraft(id){const item=masterItems.find(x=>x.id===id);state.selectedMasterId=item?.id||'';state.masterMode='edit';state.masterDraft=masterDraftFrom(item||masterItems[0]);state.masterErrors={};}
function validateMasterDraft(){
const draft=state.masterDraft||masterDraftFrom();const errors={};
if(!draft.id.trim())errors.id='품목코드를 입력하세요.';
if(!draft.name.trim())errors.name='품목명을 입력하세요.';
if(!draft.warehouseCode.trim())errors.warehouse='기본창고를 선택하세요.';
if(state.masterMode==='new'&&masterItems.some(item=>item.id===draft.id.trim()))errors.id='이미 사용 중인 품목코드입니다.';
state.masterErrors=errors;return Object.keys(errors).length===0;
}
function masterValidationSummary(){const entries=Object.entries(state.masterErrors);return entries.length?`<div class="validation-summary" role="alert"><strong>저장할 수 없습니다. ${entries.length}개 항목을 확인하세요.</strong>${entries.map(([field,message])=>`<button type="button" data-master-error="${esc(field)}">${esc(message)}</button>`).join('')}</div>`:'';}
function performMasterTarget(target){
if(target==='__NEW__'){state.selectedMasterId='';state.masterMode='new';state.masterDraft=masterDraftFrom();state.masterErrors={};renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-field="code"]',workspace)?.focus(),0);return;}
if(target==='__COPY__'){const source=state.masterDraft||masterItems.find(x=>x.id===state.selectedMasterId)||masterItems[0];state.selectedMasterId='';state.masterMode='new';state.masterDraft={...masterDraftFrom(source),id:'',barcode:'',status:'사용'};state.masterErrors={};renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-field="code"]',workspace)?.focus(),0);return;}
loadMasterDraft(target);renderT02(screen('ERP-MST-ITEM-001'));
}
function requestMasterTarget(target){const tab=state.tabs.find(x=>x.id==='ERP-MST-ITEM-001');if(tab?.dirty){state.pendingMasterId=target;state.pendingUnsavedMode='master-select';$('#unsavedMessage').textContent='현재 품목에 저장하지 않은 변경사항이 있습니다. 저장하거나 변경을 버린 뒤 다른 품목으로 전환하세요.';if(!unsavedDialog.open)unsavedDialog.showModal();return;}performMasterTarget(target);}
function saveMaster(){
if(!validateMasterDraft()){renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-master-error]',workspace)?.focus(),0);return false;}
const draft={...state.masterDraft,id:state.masterDraft.id.trim(),name:state.masterDraft.name.trim(),warehouseCode:state.masterDraft.warehouseCode.trim()};
if(state.masterMode==='new'){masterItems.push(draft);state.selectedMasterId=draft.id;state.masterMode='edit';}
else{const current=masterItems.find(x=>x.id===state.selectedMasterId);if(current)Object.assign(current,draft);}
state.masterDraft={...draft};state.masterErrors={};markDirty('ERP-MST-ITEM-001',false);renderT02(screen('ERP-MST-ITEM-001'));toast('품목을 저장했습니다.');return true;
}
function renderT02(s){
if(!state.masterDraft)loadMasterDraft(state.selectedMasterId);
const draft=state.masterDraft;
const q=state.masterQuery.trim().toLowerCase();const visible=masterItems.filter(x=>!q||`${x.id} ${x.name} ${x.group}`.toLowerCase().includes(q));
const rows=visible.map(x=>`<tr class="${x.id===state.selectedMasterId?'selected-row':''}"><td><button type="button" class="row-link master-select" data-item="${esc(x.id)}">${esc(x.id)}</button></td><td>${esc(x.name)}</td><td>${esc(x.group)}</td><td class="center">${esc(x.status)}</td></tr>`).join('');
const modeLabel=state.masterMode==='new'?'신규 품목':'품목 상세';
const body=`${masterValidationSummary()}<div class="master-layout"><aside class="master-pane"><form class="search-panel" id="masterSearchForm"><div class="search-fields"><label class="field-inline"><span>검색</span><input id="masterQueryInput" class="control medium" value="${esc(state.masterQuery)}" placeholder="품목코드/품목명"></label></div><div class="search-actions">${btn('조회','primary','masterSearch')}</div></form><div class="context-bar"><strong>품목 목록</strong><span>표시 ${visible.length}건</span><span>${state.masterMode==='new'?'신규 입력 중':`선택 ${esc(state.selectedMasterId)}`}</span></div>${grid(['품목코드','품목명','그룹','상태'],rows,{caption:'품목 목록'})}</aside><main class="detail-pane"><div class="pane-header"><strong>${modeLabel}</strong><span>${state.masterMode==='new'?'고유 식별자와 Barcode는 새로 입력':'목록을 바꾸기 전에 미저장 상태를 확인'}</span></div><section class="form-section"><h2>기본정보</h2><div class="form-grid">${formField('품목코드',draft.id,{required:true,name:'code',readonly:state.masterMode!=='new'})}${formField('품목명',draft.name,{required:true,name:'name'})}${formField('품목그룹',draft.group,{name:'group'})}${formField('규격',draft.spec,{name:'spec'})}${formField('단위',draft.unit,{name:'unit'})}</div></section><section class="form-section"><h2>물류정보</h2><div class="form-grid">${lookupField('기본창고',draft.warehouseCode,draft.warehouse,{field:'warehouse'})}${formField('바코드',draft.barcode,{name:'barcode'})}${checkboxField('LOT 관리',draft.lot,{field:'lot'})}${checkboxField('유통기한 관리',draft.expiry,{field:'expiry'})}</div></section><div class="summary-bar"><span>상태 <strong>${esc(draft.status)}</strong></span><span>Mode <strong>${esc(state.masterMode)}</strong></span><span>Version 17</span><button id="auditButton" type="button" class="text-action">변경이력</button></div></main></div>`;
const commands=`<div class="command-group">${btn('조회 F3','','masterSearchTop')}${btn('신규','','masterNew')}${btn('저장 F8','primary','masterSave')}${btn('복사','','masterCopy')}</div><span class="command-separator"></span><div class="command-group">${btn(draft.status==='사용'?'사용중지':'사용재개','danger','masterSuspend')}${btn('엑셀 ▼')}</div>`;
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT02();
}
function bindT02(){
bindLookups();
$$('.master-select',workspace).forEach(button=>button.onclick=()=>requestMasterTarget(button.dataset.item));
$$('[data-field]',workspace).forEach(input=>{if(input.readOnly)return;const event=input.type==='checkbox'?'change':'input';input.addEventListener(event,()=>{if(!state.masterDraft)return;const key=input.dataset.field;if(key==='warehouseName')return;if(key==='code')state.masterDraft.id=input.value;else if(key==='warehouseCode'){state.masterDraft.warehouseCode=input.value;state.masterDraft.warehouse='';}else state.masterDraft[key]=input.type==='checkbox'?input.checked:input.value;state.masterErrors={};markDirty('ERP-MST-ITEM-001',true);});});
$$('[data-master-error]',workspace).forEach(button=>button.onclick=()=>{const field=button.dataset.masterError;const target=field==='warehouse'?$('.lookup-trigger',workspace):$(`[data-field="${CSS.escape(field==='id'?'code':field)}"]`,workspace);target?.focus();});
const runSearch=()=>{state.masterQuery=$('#masterQueryInput')?.value||'';renderT02(screen('ERP-MST-ITEM-001'));};
$('#masterSearchForm').onsubmit=e=>{e.preventDefault();runSearch();};$('#masterSearch').onclick=runSearch;$('#masterSearchTop').onclick=runSearch;
$('#masterSave').onclick=saveMaster;$('#masterNew').onclick=()=>requestMasterTarget('__NEW__');$('#masterCopy').onclick=()=>requestMasterTarget('__COPY__');
$('#masterSuspend').onclick=()=>{state.masterDraft.status=state.masterDraft.status==='사용'?'중지':'사용';markDirty('ERP-MST-ITEM-001',true);renderT02(screen('ERP-MST-ITEM-001'));};
$('#auditButton').onclick=()=>showUtilityPanel(screen('ERP-MST-ITEM-001'),'help');
}
function transactionTotals(){const valid=state.transactionLines.filter(line=>line.code);return {items:valid.length,qty:valid.reduce((sum,line)=>sum+Number(line.qty||0),0),amount:valid.reduce((sum,line)=>sum+Number(line.qty||0)*Number(line.price||0),0)};}
function validateTransaction(){
const h=state.transactionHeader;const errors=[];
if(!h.orderDate.trim())errors.push({field:'orderDate',message:'주문일을 입력하세요.'});
if(!h.customerCode.trim())errors.push({field:'customerCode',message:'거래처를 선택하세요.'});
if(!h.warehouseCode.trim())errors.push({field:'warehouseCode',message:'출고창고를 선택하세요.'});
if(!h.receiver.trim())errors.push({field:'receiver',message:'수취인을 입력하세요.'});
if(!h.address.trim())errors.push({field:'address',message:'주소를 입력하세요.'});
state.transactionLines.forEach((line,index)=>{if(!line.code)return;if(Number(line.qty)<=0)errors.push({row:index,field:'qty',message:`${index+1}행 수량은 0보다 커야 합니다.`});if(Number(line.price)<0)errors.push({row:index,field:'price',message:`${index+1}행 단가는 0 이상이어야 합니다.`});});
if(!state.transactionLines.some(line=>line.code))errors.push({field:'lines',message:'품목을 1개 이상 입력하세요.'});
state.transactionErrors=errors;return errors.length===0;
}
function transactionValidationSummary(){return state.transactionErrors.length?`<div class="validation-summary" role="alert"><strong>저장할 수 없습니다. ${state.transactionErrors.length}개 항목을 확인하세요.</strong>${state.transactionErrors.map((error,index)=>`<button type="button" data-transaction-error="${index}">${esc(error.message)}</button>`).join('')}</div>`:'';}
function resetTransaction(){state.transactionStatus='작성';state.transactionErrors=[];state.transactionHeader={orderDate:'2026-08-11',channel:'직접주문',customerCode:'',customerName:'',owner:'홍길동',warehouseCode:'WH01',warehouseName:'서울물류센터',receiver:'',phone:'',postal:'',address:''};state.transactionLines=[{code:'',name:'',spec:'',available:0,qty:1,price:0,remark:''}];markDirty('OMS-ORD-002',true);renderT03(screen('OMS-ORD-002'));setTimeout(()=>$('[data-field="customerCode"]',workspace)?.focus(),0);}
function renderT03(s){
const h=state.transactionHeader;const totals=transactionTotals();
const lineRows=state.transactionLines.map((line,rowIndex)=>`<tr><td><input class="cell-editor" data-line-row="${rowIndex}" data-line-field="code" value="${esc(line.code)}" aria-label="${rowIndex+1}행 품목코드"></td><td>${esc(line.name||fastName(line.code))}</td><td>${esc(line.spec)}</td><td class="num">${Number(line.available||0).toLocaleString('ko-KR')}</td><td class="num"><input class="cell-editor num" data-line-row="${rowIndex}" data-line-field="qty" inputmode="decimal" value="${esc(line.qty)}" aria-label="${rowIndex+1}행 수량"></td><td class="num"><input class="cell-editor num" data-line-row="${rowIndex}" data-line-field="price" inputmode="decimal" value="${Number(line.price||0).toLocaleString('ko-KR')}" aria-label="${rowIndex+1}행 단가"></td><td class="num">${(Number(line.qty||0)*Number(line.price||0)).toLocaleString('ko-KR')}</td><td><input class="cell-editor" data-line-row="${rowIndex}" data-line-field="remark" value="${esc(line.remark||'')}" aria-label="${rowIndex+1}행 비고"></td></tr>`).join('');
const body=`${transactionValidationSummary()}<div class="transaction-layout"><section class="workflow-strip">${['작성','확정','할당','피킹','검수','출고'].map(status=>`<span class="${state.transactionStatus===status?'active':''}">${status}</span>`).join('')}<strong>현재: ${esc(state.transactionStatus)} · ${state.transactionStatus==='작성'?'다음: 저장/확정':'확정 이후 주요 값은 서버 업무 규칙으로 제한'}</strong></section><section class="form-section"><h2>기본정보</h2><div class="form-grid">${formField('주문일',h.orderDate,{required:true,name:'orderDate'})}${formField('판매채널',h.channel,{name:'channel'})}${lookupField('거래처',h.customerCode,h.customerName,{field:'customer'})}${formField('담당자',h.owner,{name:'owner'})}${lookupField('출고창고',h.warehouseCode,h.warehouseName,{field:'warehouse'})}</div></section><section class="form-section"><h2>배송정보</h2><div class="form-grid">${formField('수취인',h.receiver,{required:true,name:'receiver'})}${formField('연락처',h.phone,{name:'phone'})}${formField('우편번호',h.postal,{name:'postal'})}${formField('주소',h.address,{required:true,name:'address'})}</div></section><section class="detail-grid-section"><div class="pane-header"><strong>상품</strong><span>코드·수량·단가 직접 편집 · Enter 다음 셀</span><button id="transactionAddLine" type="button" class="text-action">+ 행추가</button></div>${grid(['품목코드','품목명','규격','가용재고','수량','단가','금액','비고'],lineRows,{caption:'주문 품목 상세'})}</section><div class="summary-bar"><span>품목 <strong id="transactionItemCount">${totals.items}종</strong></span><span>총수량 <strong id="transactionQty">${totals.qty.toLocaleString('ko-KR')}</strong></span><span>주문금액 <strong id="transactionAmount">₩${totals.amount.toLocaleString('ko-KR')}</strong></span><span>상태 <strong>${esc(state.transactionStatus)}</strong></span></div></div>`;
const commands=`<div class="command-group">${btn('신규','','transactionNew')}${btn('저장 F8','primary','saveTransaction')}${btn('주문복사','','transactionCopy')}</div><span class="command-separator"></span><div class="command-group">${btn('확정','','confirmTransaction',state.transactionStatus!=='작성')}${btn('엑셀 ▼')}</div>`;
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT03();
}
function bindT03(){
bindLookups();
$$('[data-field]',workspace).forEach(input=>{if(input.readOnly)return;input.addEventListener('input',()=>{const key=input.dataset.field;if(key==='customerCode'){state.transactionHeader.customerCode=input.value;state.transactionHeader.customerName='';}else if(key==='warehouseCode'){state.transactionHeader.warehouseCode=input.value;state.transactionHeader.warehouseName='';}else if(key in state.transactionHeader)state.transactionHeader[key]=input.value;state.transactionErrors=[];markDirty('OMS-ORD-002',true);});});
const editors=$$('.cell-editor',workspace);editors.forEach((editor,index)=>{editor.oninput=()=>{const line=state.transactionLines[Number(editor.dataset.lineRow)];const field=editor.dataset.lineField;let value=editor.value;if(['qty','price'].includes(field))value=Number(String(value).replaceAll(',',''))||0;line[field]=value;if(field==='code')line.name=fastName(String(value).trim());state.transactionErrors=[];markDirty('OMS-ORD-002',true);const totals=transactionTotals();$('#transactionItemCount').textContent=`${totals.items}`;$('#transactionQty').textContent=totals.qty.toLocaleString('ko-KR');$('#transactionAmount').textContent=`${totals.amount.toLocaleString('ko-KR')}`;};editor.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();editors[Math.min(index+1,editors.length-1)]?.focus();}};});
$$('[data-transaction-error]',workspace).forEach(button=>button.onclick=()=>{const error=state.transactionErrors[Number(button.dataset.transactionError)];if(!error)return;if(Number.isInteger(error.row))$(`[data-line-row="${error.row}"][data-line-field="${CSS.escape(error.field)}"]`,workspace)?.focus();else if(error.field==='customerCode')$('.lookup[data-lookup-field="customer"] .lookup-trigger',workspace)?.focus();else if(error.field==='warehouseCode')$('.lookup[data-lookup-field="warehouse"] .lookup-trigger',workspace)?.focus();else $(`[data-field="${CSS.escape(error.field)}"]`,workspace)?.focus();});
$('#transactionAddLine').onclick=()=>{state.transactionLines.push({code:'',name:'',spec:'',available:0,qty:1,price:0,remark:''});markDirty('OMS-ORD-002',true);renderT03(screen('OMS-ORD-002'));setTimeout(()=>$$('.cell-editor',workspace).at(-4)?.focus(),0);};
$('#saveTransaction').onclick=()=>{if(!validateTransaction()){renderT03(screen('OMS-ORD-002'));return;}markDirty('OMS-ORD-002',false);toast('주문을 저장했습니다. 서버 최종 검증을 통과한 것으로 가정한 FE Reference 상태입니다.');};
$('#confirmTransaction').onclick=()=>{const tab=state.tabs.find(x=>x.id==='OMS-ORD-002');if(tab?.dirty){toast('저장하지 않은 변경이 있습니다. 먼저 저장하세요.','error');return;}if(!validateTransaction()){renderT03(screen('OMS-ORD-002'));return;}state.transactionStatus='확정';renderT03(screen('OMS-ORD-002'));toast('주문 상태를 확정으로 전환했습니다. 실제 제품에서는 Domain이 최종 검증합니다.');};
$('#transactionNew').onclick=()=>{const tab=state.tabs.find(x=>x.id==='OMS-ORD-002');if(tab?.dirty){toast('현재 주문의 미저장 변경을 먼저 저장하거나 버리세요.','error');return;}resetTransaction();};
$('#transactionCopy').onclick=()=>{if(state.tabs.find(x=>x.id==='OMS-ORD-002')?.dirty){toast('미저장 변경을 먼저 처리하세요.','error');return;}state.transactionStatus='작성';state.transactionErrors=[];state.transactionHeader={...state.transactionHeader,receiver:'',phone:'',postal:'',address:''};state.transactionLines=state.transactionLines.map(line=>({...line}));markDirty('OMS-ORD-002',true);renderT03(screen('OMS-ORD-002'));toast('주문을 복사했습니다. 수취/배송정보를 새 주문에 맞게 입력하세요.');};
}
function fastName(code){return ({ABC001:'운동화',ABC002:'러닝화',ABC003:'양말',ABC004:'셔츠'})[code]||'';}
function fastErrorCount(){return state.fastRows.reduce((sum,row)=>sum+(row.code&&(!row.price||Number(String(row.price).replaceAll(',',''))<=0)?1:0),0);}
function renderT04(s){
const rows=state.fastRows.map((row,index)=>`<tr><td class="center">${index+1}</td><td><input class="fast-cell" data-row="${index}" data-field="code" value="${esc(row.code)}" aria-label="${index+1}행 품목코드"></td><td>${esc(row.name||fastName(row.code))}</td><td class="num"><input class="fast-cell num ${row.code&&(!row.price||Number(String(row.price).replaceAll(',',''))<=0)?'invalid':''}" data-row="${index}" data-field="price" value="${esc(row.price)}" aria-label="${index+1}행 단가"></td><td><input class="fast-cell" data-row="${index}" data-field="date" value="${esc(row.date)}" aria-label="${index+1}행 적용일"></td><td><input class="fast-cell" data-row="${index}" data-field="remark" value="${esc(row.remark)}" aria-label="${index+1}행 비고"></td></tr>`).join('');
const inputCount=state.fastRows.filter(x=>x.code).length;const errorCount=fastErrorCount();
const body=`<div class="context-bar"><strong>Fast Entry</strong><span>Enter = 다음 입력 셀</span><span>Ctrl+V = 다중 Cell 붙여넣기</span><span>F2 = 품목 Lookup</span><span class="${errorCount?'error':''}">오류 ${errorCount}개</span></div>${grid(['#','품목코드','품목명','단가','적용일','비고'],rows,{caption:'품목단가 빠른 입력'})}<div class="summary-bar"><span>입력 ${inputCount}행</span><span>오류 <strong>${errorCount}</strong></span><span>빈 행 ${state.fastRows.length-inputCount}</span></div>`;
const commands=`<div class="command-group">${btn('저장 F8','primary','fastSave',errorCount>0)}${btn('행추가','','fastAdd')}${btn('행복제','','fastDuplicate')}${btn('Fill Down','','fastFill')}</div><span class="command-separator"></span><div class="command-group">${btn('엑셀 붙여넣기')}${btn('엑셀 ▼')}</div>`;
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT04();
}
function bindT04(){
const cells=$$('.fast-cell',workspace);
cells.forEach((cell,index)=>{
cell.oninput=()=>{const row=state.fastRows[Number(cell.dataset.row)];row[cell.dataset.field]=cell.value;if(cell.dataset.field==='code')row.name=fastName(cell.value.trim());markDirty('ERP-PRICE-001',true);};
cell.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();cells[Math.min(index+1,cells.length-1)]?.focus();}};
cell.onpaste=e=>{const text=e.clipboardData?.getData('text/plain');if(!text||(!text.includes('\t')&&!text.includes('\n')))return;e.preventDefault();const matrix=text.replace(/\r/g,'').split('\n').filter(Boolean).map(line=>line.split('\t'));const startRow=Number(cell.dataset.row);const fieldOrder=['code','price','date','remark'];const startField=Math.max(0,fieldOrder.indexOf(cell.dataset.field));matrix.forEach((values,rowOffset)=>values.forEach((value,colOffset)=>{const target=state.fastRows[startRow+rowOffset];const field=fieldOrder[startField+colOffset];if(target&&field){target[field]=value.trim();if(field==='code')target.name=fastName(target[field]);}}));markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));toast(`${matrix.length}행 붙여넣기를 적용했습니다.`);};
});
$('#fastAdd').onclick=()=>{state.fastRows.push({code:'',name:'',price:'',date:'',remark:''});markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));};
$('#fastDuplicate').onclick=()=>{const last=[...state.fastRows].reverse().find(x=>x.code);if(last){state.fastRows.push({...last,code:'',name:''});markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));}};
$('#fastFill').onclick=()=>{const source=state.fastRows.find(x=>x.date)?.date||'2026-08-11';state.fastRows.forEach(row=>{if(row.code&&!row.date)row.date=source;});markDirty('ERP-PRICE-001',true);renderT04(screen('ERP-PRICE-001'));toast('적용일을 Fill Down 했습니다.');};
$('#fastSave').onclick=()=>{markDirty('ERP-PRICE-001',false);toast('오류 없는 변경 행을 저장했습니다.');};
}
function inventoryVisibleItems(){
const keyword=state.inventorySearch.keyword.trim().toLocaleLowerCase('ko-KR');
return inventoryItems.filter(item=>{
const matchesKeyword=!keyword||`${item.id} ${item.name}`.toLocaleLowerCase('ko-KR').includes(keyword);
const matchesWarehouse=state.inventorySearch.warehouse==='all'||item.locations.some(location=>location[0]===state.inventorySearch.warehouse);
return matchesKeyword&&matchesWarehouse;
});
}
function executeInventorySearch(){
const warehouse=$('#inventoryWarehouse')?.value??state.inventorySearch.warehouse;const keyword=$('#inventoryKeyword')?.value??state.inventorySearch.keyword;
state.inventorySearch={warehouse,keyword:keyword.trim()};state.inventoryDataState='loading';renderT05(screen('ERP-INV-001'));
setTimeout(()=>{state.inventoryDataState='ready';const visible=inventoryVisibleItems();if(!visible.some(item=>item.id===state.inventoryItemId))state.inventoryItemId=visible[0]?.id||'';renderT05(screen('ERP-INV-001'));announce(`재고현황 ${visible.length}개 품목을 조회했습니다.`);},220);
}
function renderT05(s){
const visibleItems=inventoryVisibleItems();let item=visibleItems.find(x=>x.id===state.inventoryItemId)||visibleItems[0]||null;if(item&&state.inventoryItemId!==item.id)state.inventoryItemId=item.id;
const search=`<form class="search-panel" id="inventorySearch"><div class="search-fields"><label class="field-inline"><span>창고</span><select id="inventoryWarehouse" class="control"><option value="all" ${state.inventorySearch.warehouse==='all'?'selected':''}>전체</option><option ${state.inventorySearch.warehouse==='서울센터'?'selected':''}>서울센터</option><option ${state.inventorySearch.warehouse==='부산센터'?'selected':''}>부산센터</option><option ${state.inventorySearch.warehouse==='인천센터'?'selected':''}>인천센터</option></select></label><label class="field-inline"><span>품목</span><input id="inventoryKeyword" class="control wide" value="${esc(state.inventorySearch.keyword)}" placeholder="품목코드/품목명"></label></div><div class="search-actions">${btn('조회','primary','inventorySearchInline')}</div></form>`;
let content='';
if(state.inventoryDataState==='loading')content=dataState('loading','재고 조회 중...','창고와 품목 조건을 유지한 채 재고 Projection을 조회하고 있습니다.');
else if(!item)content=dataState('empty','조회된 재고 품목이 없습니다.','조건을 초기화하고 다시 조회해 보세요.','inventoryReset');
else{
const leftRows=visibleItems.map(x=>`<tr class="${x.id===item.id?'selected-row':''}"><td><button class="row-link inventory-select" data-item="${esc(x.id)}" type="button">${esc(x.id)}</button></td><td>${esc(x.name)}</td><td class="num">${x.stock.toLocaleString('ko-KR')}</td></tr>`).join('');
const rightRows=item.locations.filter(row=>state.inventorySearch.warehouse==='all'||row[0]===state.inventorySearch.warehouse).map(row=>`<tr><td>${esc(row[0])}</td><td>${esc(row[1])}</td><td class="num">${Number(row[2]).toLocaleString('ko-KR')}</td><td class="num">${Number(row[3]).toLocaleString('ko-KR')}</td><td class="num">${Number(row[4]).toLocaleString('ko-KR')}</td></tr>`).join('');
const history=[['16:31','출고할당','','20',item.stock-20,'ORD-260811-1022'],['14:02','입고','100','',item.stock,'RCV-260811-0081'],['11:24','출고','','12',item.stock-100,'ORD-260811-0931']].map(row=>`<tr>${row.map((value,index)=>`<td class="${index>=2&&index<=4?'num':''}">${esc(value)}</td>`).join('')}</tr>`).join('');
content=`<div class="context-bar"><strong>현재 조회</strong><span>품목 <b>${visibleItems.length}</b>개</span><span>창고 ${esc(state.inventorySearch.warehouse==='all'?'전체':state.inventorySearch.warehouse)}</span><button id="inventoryReset" type="button" class="text-action">조건 초기화</button></div><div class="split-layout"><section class="pane"><div class="pane-header"><strong>품목</strong><span>현재고</span></div>${grid(['품목코드','품목명','현재고'],leftRows,{caption:'재고 품목 목록'})}</section><section class="pane"><div class="pane-header"><strong>창고 / Location</strong><span>${esc(item.id)} ${esc(item.name)} · ${item.stock.toLocaleString('ko-KR')}개</span></div>${grid(['창고','Location','현재고','할당','보류'],rightRows||`<tr><td colspan="5" class="center">선택한 창고의 재고 위치가 없습니다.</td></tr>`,{caption:'품목별 창고 로케이션 재고'})}</section></div><section class="pane history-pane"><div class="pane-header"><strong>재고이력</strong><span>현재고 근거 추적 · ${esc(item.id)}</span></div>${grid(['일시','업무','입고','출고','잔량','참조번호'],history,{caption:'재고 이력'})}</section>`;
}
workspace.innerHTML=pageShell(s,`${search}${content}`,`<div class="command-group">${btn('조회 F3','','inventorySearchButton')}</div><span class="command-separator"></span><div class="command-group">${btn('재고이동')}${btn('엑셀 ▼')}</div>`);bindPageTools(s);
$$('.inventory-select',workspace).forEach(button=>button.onclick=()=>{state.inventoryItemId=button.dataset.item;renderT05(s);announce(`${button.dataset.item} 재고 상세를 표시했습니다.`);});
$('#inventorySearch').onsubmit=e=>{e.preventDefault();executeInventorySearch();};$('#inventorySearchButton').onclick=executeInventorySearch;const inventoryInline=$('#inventorySearchInline');if(inventoryInline)inventoryInline.onclick=executeInventorySearch;
const reset=$('#inventoryReset');if(reset)reset.onclick=()=>{state.inventorySearch={warehouse:'all',keyword:''};state.inventoryItemId=inventoryItems[0].id;state.inventoryDataState='ready';renderT05(s);announce('재고 조회조건을 초기화했습니다.');};
}
function queueVisibleRows(){
const keyword=state.queueSearch.keyword.trim().toLocaleLowerCase('ko-KR');const worker=state.queueSearch.worker.trim().toLocaleLowerCase('ko-KR');
return queueRows.filter(row=>{
const quick=state.queueFilter==='all'||state.queueFilter==='exception'&&row.exception||state.queueFilter===row.type;
const workerMatch=!worker||row.worker.toLocaleLowerCase('ko-KR').includes(worker);
const keywordMatch=!keyword||`${row.no} ${row.type} ${row.wave} ${row.status}`.toLocaleLowerCase('ko-KR').includes(keyword);
return quick&&workerMatch&&keywordMatch;
});
}
function executeQueueSearch(){
state.queueSearch={worker:($('#queueWorker')?.value??state.queueSearch.worker).trim(),keyword:($('#queueKeyword')?.value??state.queueSearch.keyword).trim()};state.selectedQueueRows.clear();state.queueActionResult='';state.queueDataState='loading';renderT06(screen('WMS-WORK-001'));setTimeout(()=>{state.queueDataState='ready';renderT06(screen('WMS-WORK-001'));announce(`작업 Queue ${queueVisibleRows().length}건을 조회했습니다.`);},220);
}
function renderT06(s){
const visible=queueVisibleRows();
const selectedCount=state.selectedQueueRows.size;const allSelected=visible.length>0&&visible.every(row=>state.selectedQueueRows.has(row.no));
const rows=visible.map(row=>`<tr class="${state.selectedQueueRows.has(row.no)?'selected-row':''}"><td class="center"><input type="checkbox" data-queue-row="${esc(row.no)}" aria-label="${esc(row.no)} 선택" ${state.selectedQueueRows.has(row.no)?'checked':''}></td><td>${esc(row.no)}</td><td>${esc(row.type)}</td><td>${esc(row.wave)}</td><td>${esc(row.worker)}</td><td class="num">${esc(row.progress)}</td><td>${esc(row.sla)}</td><td class="center ${row.status==='지연'?'error':''}">${esc(row.status)}</td></tr>`).join('');
const table=grid(['선택','작업번호','작업유형','Wave','작업자','진행률','SLA','상태'],rows,{caption:'물류 작업 대기열'}).replace('<th scope="col">선택</th>',`<th scope="col" class="center"><input id="queueSelectAll" type="checkbox" aria-label="현재 표시 작업 전체 선택" ${allSelected?'checked':''}></th>`);
const summary=[['입고','입고대기','218'],['적치','적치대기','43'],['피킹','피킹대기','927'],['검수','검수대기','122'],['exception','예외','12']];
const result=state.queueActionResult?`<div class="action-result" role="status"><strong>최근 처리</strong><span>${esc(state.queueActionResult)}</span><button id="queueResultClear" type="button" class="text-action">닫기</button></div>`:'';
const search=`<form class="search-panel" id="queueSearchForm"><div class="search-fields"><label class="field-inline"><span>작업자</span><input id="queueWorker" class="control medium" value="${esc(state.queueSearch.worker)}" placeholder="작업자명"></label><label class="field-inline"><span>통합검색</span><input id="queueKeyword" class="control wide" value="${esc(state.queueSearch.keyword)}" placeholder="작업번호/Wave/상태"></label></div><div class="search-actions">${btn('조회','primary','queueSearchInline')}</div></form>`;
let queueContent='';if(state.queueDataState==='loading')queueContent=dataState('loading','작업 Queue 조회 중...','현재 필터와 검색조건을 유지하고 최신 작업 상태를 조회합니다.');else if(!visible.length)queueContent=dataState('empty','처리할 작업이 없습니다.','검색조건 또는 예외 필터를 초기화해 보세요.','queueClearSearch');else queueContent=`<div class="context-bar"><strong>지금 처리할 업무</strong><span>표시 ${visible.length}건</span><span>선택 <b>${selectedCount}</b>건</span><span>지연 ${visible.filter(row=>row.status==='지연').length}건</span><button id="queueReset" type="button" class="text-action">빠른필터 해제</button></div>${selectedCount?`<div class="bulk-bar"><strong>${selectedCount}건 선택</strong>${btn('작업자 배정','primary','queueAssign')}${btn('Wave 생성','','queueWave')}</div>`:''}${result}${table}`;
const body=`${search}<div class="queue-summary" role="group" aria-label="작업 유형 빠른 필터">${summary.map(([key,label,count])=>`<button type="button" data-queue-filter="${esc(key)}" class="${state.queueFilter===key?'active':''} ${key==='exception'?'danger':''}" aria-pressed="${state.queueFilter===key}"><span>${esc(label)}</span><b>${esc(count)}</b></button>`).join('')}</div>${queueContent}`;
const commands=`<div class="command-group">${btn('조회 F3','','queueSearch')}</div><span class="command-separator"></span><div class="command-group">${btn('작업자 배정','primary','queueAssignTop',selectedCount===0)}${btn('Wave 생성','','queueWaveTop',selectedCount===0)}${btn('예외만 보기','','queueException')}</div>`;
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT06();
}
function bindT06(){
$$('[data-queue-filter]',workspace).forEach(button=>button.onclick=()=>{state.queueFilter=button.dataset.queueFilter;state.selectedQueueRows.clear();state.queueActionResult='';state.queueDataState='ready';renderT06(screen('WMS-WORK-001'));});
const reset=$('#queueReset');if(reset)reset.onclick=()=>{state.queueFilter='all';state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};$('#queueException').onclick=()=>{state.queueFilter='exception';state.selectedQueueRows.clear();state.queueDataState='ready';renderT06(screen('WMS-WORK-001'));};$('#queueSearch').onclick=executeQueueSearch;const inlineSearch=$('#queueSearchInline');if(inlineSearch)inlineSearch.onclick=executeQueueSearch;const form=$('#queueSearchForm');if(form)form.onsubmit=e=>{e.preventDefault();executeQueueSearch();};
const clearSearch=$('#queueClearSearch');if(clearSearch)clearSearch.onclick=()=>{state.queueFilter='all';state.queueSearch={worker:'',keyword:''};state.queueDataState='ready';state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};
$$('[data-queue-row]',workspace).forEach(box=>box.onchange=()=>{box.checked?state.selectedQueueRows.add(box.dataset.queueRow):state.selectedQueueRows.delete(box.dataset.queueRow);renderT06(screen('WMS-WORK-001'));});
const selectAll=$('#queueSelectAll');if(selectAll)selectAll.onchange=()=>{queueVisibleRows().forEach(row=>selectAll.checked?state.selectedQueueRows.add(row.no):state.selectedQueueRows.delete(row.no));renderT06(screen('WMS-WORK-001'));};
const assign=()=>{if(!state.selectedQueueRows.size)return;for(const row of queueRows){if(state.selectedQueueRows.has(row.no))row.worker='홍길동';}state.queueActionResult=`${state.selectedQueueRows.size}건을 홍길동 작업자에게 배정했습니다.`;state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};
const wave=()=>{if(!state.selectedQueueRows.size)return;const waveNo='W260811-NEW';let changed=0;for(const row of queueRows){if(state.selectedQueueRows.has(row.no)&&row.wave==='-'){row.wave=waveNo;changed++;}}state.queueActionResult=changed?`${changed}건에 Wave ${waveNo}를 생성했습니다.`:'선택 작업에는 새 Wave를 생성할 대상이 없습니다.';state.selectedQueueRows.clear();renderT06(screen('WMS-WORK-001'));};
for(const id of ['queueAssign','queueAssignTop']){const button=$('#'+id);if(button)button.onclick=assign;}for(const id of ['queueWave','queueWaveTop']){const button=$('#'+id);if(button)button.onclick=wave;}const clear=$('#queueResultClear');if(clear)clear.onclick=()=>{state.queueActionResult='';renderT06(screen('WMS-WORK-001'));};
}
function reconcileVisibleRows(){
const keyword=state.reconcileSearch.keyword.trim().toLocaleLowerCase('ko-KR');return reconcileRows.filter(row=>(!state.mismatchOnly||row.diff!==0)&&(!keyword||`${row.no} ${row.reason} ${row.status}`.toLocaleLowerCase('ko-KR').includes(keyword)));
}
function executeReconcileSearch(){
state.reconcileSearch={date:($('#reconcileDate')?.value??state.reconcileSearch.date).trim(),keyword:($('#reconcileKeyword')?.value??state.reconcileSearch.keyword).trim()};state.selectedReconcileRows.clear();state.reconcileActionResult='';state.reconcileDataState='loading';renderT07(screen('COMMON-REC-001'));setTimeout(()=>{state.reconcileDataState='ready';renderT07(screen('COMMON-REC-001'));announce(`대사 결과 ${reconcileVisibleRows().length}건을 표시했습니다.`);},220);
}
function renderT07(s){
const visible=reconcileVisibleRows();const selectedCount=state.selectedReconcileRows.size;const allSelected=visible.length>0&&visible.every(row=>state.selectedReconcileRows.has(row.no));
const rows=visible.map(row=>`<tr class="${state.selectedReconcileRows.has(row.no)?'selected-row':''}"><td class="center"><input type="checkbox" data-reconcile-row="${esc(row.no)}" aria-label="${esc(row.no)} 선택" ${state.selectedReconcileRows.has(row.no)?'checked':''}></td><td>${esc(row.no)}</td><td class="num">${row.expected}</td><td class="num">${row.actual}</td><td class="num ${row.diff!==0?'error':''}">${row.diff}</td><td>${esc(row.reason)}</td><td>${esc(row.status)}</td></tr>`).join('');
const table=grid(['선택','주문번호','OMS','WMS','차이','원인','처리상태'],rows,{caption:'OMS WMS 재고 출고 대사'}).replace('<th scope="col">선택</th>',`<th scope="col" class="center"><input id="reconcileSelectAll" type="checkbox" aria-label="현재 표시 대사 전체 선택" ${allSelected?'checked':''}></th>`);
const result=state.reconcileActionResult?`<div class="action-result" role="status"><strong>최근 처리</strong><span>${esc(state.reconcileActionResult)}</span><button id="reconcileResultClear" type="button" class="text-action">닫기</button></div>`:'';
const search=`<form class="search-panel" id="reconcileSearch"><div class="search-fields"><label class="field-inline"><span>기준일</span><input id="reconcileDate" class="control" type="date" value="${esc(state.reconcileSearch.date)}"></label><label class="field-inline"><span>통합검색</span><input id="reconcileKeyword" class="control wide" value="${esc(state.reconcileSearch.keyword)}" placeholder="주문번호/원인/상태"></label><label class="field-inline checkbox-inline"><input id="mismatchOnly" type="checkbox" ${state.mismatchOnly?'checked':''}><span>불일치만 표시</span></label></div><div class="search-actions">${btn('조회','primary','reconcileSearchInline')}</div></form>`;
let resultContent='';if(state.reconcileDataState==='loading')resultContent=dataState('loading','대사 결과 조회 중...','Expected/Actual/Difference Projection을 다시 조회하고 있습니다.');else if(!visible.length)resultContent=dataState('empty','조건에 맞는 대사 건이 없습니다.','불일치 필터 또는 검색어를 초기화해 보세요.','reconcileReset');else resultContent=`${selectedCount?`<div class="bulk-bar"><strong>${selectedCount}건 선택</strong>${btn('원인 재조회','','reconcileReason')}${btn('재처리','primary','reconcileRun')}${btn('처리사유 등록','','reconcileReasonNote')}</div>`:''}${result}${table}`;
const body=`${search}<div class="reconcile-summary"><span>전체 <strong>12,482</strong></span><span>정상 <strong>12,439</strong></span><span class="mismatch">불일치 <strong>43</strong></span><span>표시 <strong>${visible.length}</strong></span><span>선택 <strong>${selectedCount}</strong></span></div>${resultContent}`;
const commands=`<div class="command-group">${btn('조회 F3','','reconcileSearchButton')}</div><span class="command-separator"></span><div class="command-group">${btn('재처리','primary','reconcileRunTop',selectedCount===0)}${btn('엑셀 ▼')}</div>`;
workspace.innerHTML=pageShell(s,body,commands);bindPageTools(s);bindT07();
}
function bindT07(){
const mismatch=$('#mismatchOnly');if(mismatch)mismatch.onchange=e=>{state.mismatchOnly=e.target.checked;state.selectedReconcileRows.clear();state.reconcileDataState='ready';renderT07(screen('COMMON-REC-001'));};const form=$('#reconcileSearch');if(form)form.onsubmit=e=>{e.preventDefault();executeReconcileSearch();};const reconcileInline=$('#reconcileSearchInline');if(reconcileInline)reconcileInline.onclick=executeReconcileSearch;$('#reconcileSearchButton').onclick=executeReconcileSearch;
const reset=$('#reconcileReset');if(reset)reset.onclick=()=>{state.mismatchOnly=false;state.reconcileSearch={date:'2026-08-11',keyword:''};state.selectedReconcileRows.clear();state.reconcileDataState='ready';renderT07(screen('COMMON-REC-001'));};
$$('[data-reconcile-row]',workspace).forEach(box=>box.onchange=()=>{box.checked?state.selectedReconcileRows.add(box.dataset.reconcileRow):state.selectedReconcileRows.delete(box.dataset.reconcileRow);renderT07(screen('COMMON-REC-001'));});
const all=$('#reconcileSelectAll');if(all)all.onchange=()=>{reconcileVisibleRows().forEach(row=>all.checked?state.selectedReconcileRows.add(row.no):state.selectedReconcileRows.delete(row.no));renderT07(screen('COMMON-REC-001'));};
const rerun=()=>{if(!state.selectedReconcileRows.size)return;const count=state.selectedReconcileRows.size;for(const row of reconcileRows){if(state.selectedReconcileRows.has(row.no)){row.actual=row.expected;row.diff=0;row.reason='재처리 완료';row.status='해결';}}state.reconcileActionResult=`${count}건을 재처리하여 현재 Reference 데이터 기준 차이를 0으로 맞췄습니다.`;state.selectedReconcileRows.clear();renderT07(screen('COMMON-REC-001'));};
for(const id of ['reconcileRun','reconcileRunTop']){const button=$('#'+id);if(button)button.onclick=rerun;}const reason=$('#reconcileReason');if(reason)reason.onclick=()=>{state.reconcileActionResult=`${state.selectedReconcileRows.size}건의 원인 Projection을 다시 조회했습니다.`;renderT07(screen('COMMON-REC-001'));};const note=$('#reconcileReasonNote');if(note)note.onclick=()=>{state.reconcileActionResult=`${state.selectedReconcileRows.size}건에 처리사유가 필요합니다. 실제 제품에서는 사유 입력 Drawer를 사용합니다.`;renderT07(screen('COMMON-REC-001'));};const clear=$('#reconcileResultClear');if(clear)clear.onclick=()=>{state.reconcileActionResult='';renderT07(screen('COMMON-REC-001'));};
}
function applyImportFile(file){
if(!file)return false;const name=String(file.name||'').trim();const size=Number(file.size||0);const validExt=/\.xlsx?$/i.test(name);const max=20*1024*1024;
if(!validExt){toast('Excel .xlsx 또는 .xls 파일을 선택하세요.','error');return false}if(size>max){toast('파일 크기는 20MB 이하여야 합니다.','error');return false}
state.importFileName=name;state.importFileSize=size;state.importFileType=file.type||'';state.importJobStatus='idle';state.importProgress=0;state.importProcessed=0;renderT08(screen('OMS-ORD-003'));announce(`${name} 파일을 선택했습니다.`);return true;
}
function renderT08(s){
const step=state.importStep;const steps=['파일','매핑','검증','미리보기','반영','결과'];
let content='';
if(step===1)content=`<input id="importFileInput" class="sr-only" type="file" accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"><div class="drop-zone" id="dropZone" tabindex="0" role="button" aria-controls="importFileInput"><div><strong>${state.importFileName?esc(state.importFileName):'Excel 파일을 선택하세요'}</strong><p>${state.importFileName?`${state.importFileSize?`${(state.importFileSize/1024/1024).toFixed(2)}MB · `:''}파일 선택 완료 · 다음 단계에서 필드 매핑을 확인합니다.`:'파일 선택은 필수 접근 경로이며 Drag & Drop은 편의 기능입니다. .xlsx/.xls · 최대 20MB'}</p>${btn(state.importFileName?'파일 다시 선택':'파일 선택','primary','fileSelect')} ${btn('업로드 양식 다운로드')}</div></div>`;
if(step===2)content=`<div class="mapping-table"><div class="mapping-head"><strong>Excel Column</strong><strong>System Field</strong><strong>매핑 근거</strong></div>${[['주문번호','주문번호','정확 일치'],['상품코드','품목코드','Alias 일치'],['수량','주문수량','Alias 일치'],['구매자','주문자명','Alias 일치'],['업체코드','거래처코드','사용자 확인 필요']].map(row=>`<div class="mapping-row"><span>${esc(row[0])}</span><span>→ ${esc(row[1])}</span><span>${esc(row[2])}</span></div>`).join('')}</div>`;
if(step===3)content=`<div class="validation-cards"><div class="validation-card"><span>전체</span><b>18,230</b></div><div class="validation-card"><span>정상</span><b>17,894</b></div><div class="validation-card error"><span>오류</span><b>336</b></div></div>${grid(['행','필드','값','오류'],`<tr><td>8</td><td>품목코드</td><td>ABC99</td><td class="error">존재하지 않는 품목</td></tr><tr><td>12</td><td>수량</td><td>-1</td><td class="error">수량은 0보다 커야 합니다.</td></tr>`,{caption:'Excel 검증 오류'})}`;
if(step===4)content=`<section class="commit-panel"><div class="pane-header"><strong>반영 미리보기</strong><span>오류 336건 제외</span></div><div class="commit-body"><p><strong>17,894건</strong>을 반영할 수 있습니다.</p><dl><div><dt>신규</dt><dd>17,201</dd></div><div><dt>수정</dt><dd>693</dd></div><div><dt>제외 오류</dt><dd>336</dd></div></dl><p class="warning-copy">실제 제품은 Staging 데이터를 Domain 검증한 뒤 Commit합니다.</p></div></section>`;
if(step===5){const running=state.importJobStatus==='running';const completed=state.importJobStatus==='completed';content=`<section class="commit-panel"><div class="pane-header"><strong>Background 반영 작업</strong><span>${completed?'완료':running?'처리 중':'대기'}</span></div><div class="commit-body"><div class="job-progress"><div class="job-progress-track"><span style="width:${state.importProgress}%"></span></div><strong>${state.importProgress}%</strong><p>${state.importProcessed.toLocaleString('ko-KR')} / 17,894 처리</p></div><p>다른 화면으로 이동해도 작업은 계속됩니다. 홈의 진행 작업에서 다시 들어올 수 있습니다.</p>${!running&&!completed?btn('17,894건 반영','primary','importCommit'):''}${completed?btn('결과 보기','primary','importResult'):''}</div></section>`;}
if(step===6)content=`<section class="commit-panel"><div class="pane-header"><strong>반영 결과</strong><span>완료</span></div><div class="commit-body"><div class="validation-cards"><div class="validation-card"><span>성공</span><b>17,894</b></div><div class="validation-card error"><span>오류</span><b>336</b></div></div><p>오류 데이터는 Domain DB에 반영하지 않았습니다.</p><div class="inline-actions">${btn('오류 336건 다운로드')}${btn('새 업로드','primary','importReset')}</div></div></section>`;
const nav=`<div class="import-steps">${steps.map((label,index)=>`<div class="import-step ${index+1===step?'active':''} ${index+1<step?'done':''}"><b>${index+1}</b><span>${label}</span></div>`).join('')}</div>`;
const buttons=step<5?`<div class="wizard-actions">${step>1?btn('이전','','importBack'):''}${step<4?btn('다음','primary','importNext',step===1&&!state.importFileName):step===4?btn('반영 단계','primary','importNext'):''}</div>`:'';
workspace.innerHTML=pageShell(s,`${nav}${content}${buttons}`,`<div class="command-group">${btn('양식 다운로드')}</div><span class="command-separator"></span><div class="command-group">${btn('최근 업로드 결과')}</div>`);bindPageTools(s);bindT08();
}
function startImportJob(){
if(importTimer)clearInterval(importTimer);state.importStep=5;state.importJobStatus='running';state.importProgress=0;state.importProcessed=0;renderT08(screen('OMS-ORD-003'));
importTimer=setInterval(()=>{const increment=state.importProgress<60?11:state.importProgress<90?7:3;state.importProgress=Math.min(100,state.importProgress+increment);state.importProcessed=Math.floor(17894*state.importProgress/100);if(state.activeId==='OMS-ORD-003'&&state.importStep===5)renderT08(screen('OMS-ORD-003'));if(state.importProgress>=100){clearInterval(importTimer);importTimer=null;state.importJobStatus='completed';state.importProcessed=17894;if(state.activeId==='OMS-ORD-003'&&state.importStep===5)renderT08(screen('OMS-ORD-003'));toast('Excel 주문 Import가 완료되었습니다.');}},180);
}
function bindT08(){
const trigger=$('#fileSelect');const input=$('#importFileInput');if(trigger&&input)trigger.onclick=()=>input.click();if(input)input.onchange=()=>{const file=input.files?.[0];if(file)applyImportFile(file);};
const drop=$('#dropZone');if(drop){drop.ondragover=e=>{e.preventDefault();drop.classList.add('dragging')};drop.ondragleave=()=>drop.classList.remove('dragging');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('dragging');applyImportFile(e.dataTransfer?.files?.[0]);};drop.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();input?.click();}};}
const next=$('#importNext');if(next)next.onclick=()=>{if(state.importStep===1&&!state.importFileName)return;state.importStep=Math.min(5,state.importStep+1);renderT08(screen('OMS-ORD-003'));};
const back=$('#importBack');if(back)back.onclick=()=>{state.importStep=Math.max(1,state.importStep-1);renderT08(screen('OMS-ORD-003'));};
const commit=$('#importCommit');if(commit)commit.onclick=startImportJob;const result=$('#importResult');if(result)result.onclick=()=>{state.importStep=6;renderT08(screen('OMS-ORD-003'));};
const reset=$('#importReset');if(reset)reset.onclick=()=>{state.importStep=1;state.importFileName='';state.importFileSize=0;state.importFileType='';state.importJobStatus='idle';state.importProgress=0;state.importProcessed=0;renderT08(screen('OMS-ORD-003'));};
}
function renderT09(s){
const remain=Math.max(0,state.pickingRequired-state.pickingQty);const completed=remain===0;
const network=state.networkStatus==='online'?['●','온라인 · 동기화 완료','online']:state.networkStatus==='unstable'?['▲','연결 불안정 · 서버 확인 필요','unstable']:['○','오프라인 · 작업 반영 중지','offline'];
const errorTitle=state.scanError?.startsWith('NETWORK:')?'네트워크를 확인하세요.':'다른 상품입니다.';const errorCopy=(state.scanError||'').replace(/^NETWORK:/,'');
workspace.innerHTML=`<div class="mobile-stage"><main class="wms-phone" data-screen-id="${esc(s.id)}"><header class="wms-header"><div><h1>출고 피킹</h1><strong>23 / 138</strong></div><span class="network ${network[2]}">${network[0]} ${network[1]}</span></header><section class="wms-content"><span class="wms-label">LOCATION</span><div class="scan-location">A-03-02</div><p>위치 확인 완료 · 상품을 스캔하세요.</p><div id="scanBox" class="scan-box ${state.scanError?'scan-error':''}" tabindex="0" role="textbox" aria-label="바코드 스캔 입력" aria-describedby="scanHint"><strong>${state.scanBuffer?esc(state.scanBuffer):'SCAN READY'}</strong><small id="scanHint">스캐너 입력 후 Enter · 기대 바코드 8801234567890</small></div><section class="wms-item"><strong>ABC 운동화</strong><p>BLACK / 270</p></section><div class="qty-grid"><div><span>필요</span><b>${state.pickingRequired}</b></div><div><span>피킹</span><b>${state.pickingQty}</b></div><div><span>남음</span><b>${remain}</b></div></div>${completed?'<div class="success-panel"><strong>상품 완료</strong><span>다음 품목으로 자동 진행할 수 있습니다.</span></div>':''}${state.scanError?`<div class="error-panel" role="alert"><strong>${esc(errorTitle)}</strong><span>${esc(errorCopy)}</span></div>`:''}</section><footer class="wms-actions">${btn('+1 피킹','primary','pickOne',completed||state.networkStatus!=='online')}${btn('수량입력','','pickQty',state.networkStatus!=='online')}${btn('재고부족 신고','danger full','stockIssue')}</footer></main></div>`;
bindT09();
}
function applyScan(value){
const normalized=value.trim();state.scanBuffer='';if(!normalized)return;const now=Date.now();
if(normalized===state.lastScanValue&&now-state.lastScanAt<700){toast('중복 스캔을 감지해 무시했습니다. 수량은 변경되지 않았습니다.','error');setTimeout(()=>$('#scanBox')?.focus(),0);return;}
state.lastScanValue=normalized;state.lastScanAt=now;
if(state.networkStatus!=='online'){state.scanError=`NETWORK:${state.networkStatus==='unstable'?'연결이 불안정하여 서버 검증을 완료하지 못했습니다. 수량은 반영하지 않았습니다.':'오프라인에서는 서버 검증이 필요한 피킹 수량을 반영하지 않습니다.'}`;renderT09(screen('WMS-PICK-001'));setTimeout(()=>$('#scanBox')?.focus(),0);return;}
if(normalized!=='8801234567890'){state.scanError=`스캔 ${normalized} · 필요 ABC001 / 8801234567890`;renderT09(screen('WMS-PICK-001'));setTimeout(()=>$('#scanBox')?.focus(),0);return;}
state.scanError='';if(state.pickingQty<state.pickingRequired)state.pickingQty++;renderT09(screen('WMS-PICK-001'));toast(state.pickingQty===state.pickingRequired?'상품 피킹 완료.':'정상 상품을 1개 피킹했습니다.');setTimeout(()=>$('#scanBox')?.focus(),0);
}
function bindT09(){
const scan=$('#scanBox');if(scan){scan.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyScan(state.scanBuffer);return;}if(e.key==='Backspace'){state.scanBuffer=state.scanBuffer.slice(0,-1);}else if(e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){state.scanBuffer+=e.key;}const strong=$('strong',scan);if(strong)strong.textContent=state.scanBuffer||'SCAN READY';};}
$('#pickOne').onclick=()=>{if(state.pickingQty<state.pickingRequired)state.pickingQty++;state.scanError='';renderT09(screen('WMS-PICK-001'));if(state.pickingQty===state.pickingRequired)toast('상품 피킹 완료. 다음 상품으로 진행합니다.');};
$('#pickQty').onclick=()=>{$('#quantityInput').value=String(state.pickingQty);$('#quantityInput').max=String(state.pickingRequired);quantityDialog.showModal();setTimeout(()=>$('#quantityInput').focus(),0);};
$('#stockIssue').onclick=()=>{exceptionDialog.showModal();setTimeout(()=>$('input[name="exceptionReason"]',exceptionDialog)?.focus(),0);};
}
function renderActive(){ const s=screen(state.activeId);app.dataset.mode=s.template==='T09'?'wms-mobile':'desktop';if(s.id==='HOME')return renderHome();({T01:renderT01,T02:renderT02,T03:renderT03,T04:renderT04,T05:renderT05,T06:renderT06,T07:renderT07,T08:renderT08,T09:renderT09}[s.template]||renderHome)(s); }
function renderAll(){renderNavigation();renderTabs();renderActive();}
function openSearch(){closeSystemPanel(false);state.searchIndex=0;searchInput.value='';searchInput.setAttribute('aria-expanded','true');renderSearchResults();if(!searchDialog.open)searchDialog.showModal();setTimeout(()=>searchInput.focus(),0);}
function searchMatches(){const q=searchInput.value.trim().toLowerCase();return screens.filter(x=>x.id!=='HOME').filter(x=>!q||[x.id,x.title,x.module,x.group,...x.keywords].join(' ').toLowerCase().includes(q));}
function renderSearchResults(){
const list=searchMatches();if(state.searchIndex>=list.length)state.searchIndex=Math.max(0,list.length-1);searchInput.setAttribute('aria-activedescendant',list[state.searchIndex]?`menu-result-${list[state.searchIndex].id}`:'');
searchResults.innerHTML=list.length?list.map((item,index)=>`<button id="menu-result-${esc(item.id)}" type="button" class="search-result ${index===state.searchIndex?'active':''}" data-screen="${esc(item.id)}" role="option" aria-selected="${index===state.searchIndex}"><span class="code">${esc(item.id)}</span><span class="copy"><b>${esc(item.title)}</b><small>${esc(item.group)} · ${esc(item.template)}</small></span><span class="module">${esc(labelModule(item.module))}</span></button>`).join(''):'<div class="search-empty">검색 결과가 없습니다.</div>';
$$('.search-result',searchResults).forEach((button,index)=>{button.onmousemove=()=>{state.searchIndex=index;$$('.search-result',searchResults).forEach((x,i)=>x.classList.toggle('active',i===index));};button.onclick=()=>{searchDialog.close();openScreen(button.dataset.screen);};});
}
searchInput.oninput=()=>{state.searchIndex=0;renderSearchResults();};
searchInput.onkeydown=e=>{const list=searchMatches();if(e.key==='ArrowDown'){e.preventDefault();state.searchIndex=Math.min(list.length-1,state.searchIndex+1);renderSearchResults();$('.search-result.active',searchResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='ArrowUp'){e.preventDefault();state.searchIndex=Math.max(0,state.searchIndex-1);renderSearchResults();$('.search-result.active',searchResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='Enter'&&list[state.searchIndex]){e.preventDefault();searchDialog.close();openScreen(list[state.searchIndex].id);}};
$('#homeButton').onclick=()=>openScreen('HOME');$('#sideHomeButton').onclick=()=>openScreen('HOME');$('#globalSearchButton').onclick=openSearch;$('#sideSearchButton').onclick=openSearch;$('#searchCloseButton').onclick=()=>searchDialog.close();
searchDialog.addEventListener('close',()=>searchInput.setAttribute('aria-expanded','false'));
$('#lookupCloseButton').onclick=()=>lookupDialog.close();$('#lookupSearchForm').onsubmit=e=>{e.preventDefault();state.lookupIndex=0;renderLookupResults();};lookupSearchInput.oninput=()=>{state.lookupIndex=0;renderLookupResults();};
lookupSearchInput.onkeydown=e=>{const list=lookupItems();if(e.key==='ArrowDown'){e.preventDefault();state.lookupIndex=Math.min(Math.max(0,list.length-1),state.lookupIndex+1);renderLookupResults();$('.lookup-result.active',lookupResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='ArrowUp'){e.preventDefault();state.lookupIndex=Math.max(0,state.lookupIndex-1);renderLookupResults();$('.lookup-result.active',lookupResults)?.scrollIntoView({block:'nearest'});}else if(e.key==='Enter'&&list[state.lookupIndex]){e.preventDefault();selectLookup();}};$('#lookupSelectButton').onclick=selectLookup;lookupDialog.addEventListener('close',()=>{if(document.activeElement===document.body)lookupRestoreFocus?.focus();});
$('#collapseButton').onclick=()=>{state.collapsed=!state.collapsed;persist();renderNavigation();};
$('#densityButton').onclick=()=>{state.density=state.density==='compact'?'comfortable':'compact';persist();renderNavigation();toast(`화면 밀도를 ${state.density==='compact'?'Compact':'Comfortable'}로 변경했습니다.`);};
$('#themeButton').onclick=()=>{state.theme=state.theme==='dark'?'light':'dark';persist();renderNavigation();toast(`KBX Business ${state.theme==='dark'?'Dark':'Light'} 테마를 적용했습니다.`);};
$('#moduleSelect').onchange=e=>{state.module=e.target.value;renderNavigation();if(state.activeId==='HOME')renderHome();};
$('#drawerClose').onclick=closeDrawer;drawerBackdrop.onclick=closeDrawer;$('#systemPanelClose').onclick=()=>closeSystemPanel();$$('[data-panel]').forEach(button=>button.onclick=()=>showSystemPanel(button.dataset.panel));
drawer.onkeydown=e=>{if(e.key==='Escape'){e.preventDefault();closeDrawer();return;}if(e.key!=='Tab')return;const focusables=$$('button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),a[href]',drawer);if(!focusables.length)return;const first=focusables[0],last=focusables[focusables.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}};
function clearUnsavedPending(){state.pendingCloseId=null;state.pendingNavigationId=null;state.pendingNavigationOptions=null;state.pendingMasterId=null;state.pendingUnsavedMode=null;}
function continuePendingNavigation(message){
const target=state.pendingNavigationId;const options=state.pendingNavigationOptions||{};clearUnsavedPending();unsavedDialog.close();
if(target){openScreen(target,{...options,force:true});toast(message);}
}
function trySaveDirtyScreen(id){
if(id==='ERP-MST-ITEM-001'){if(!validateMasterDraft())return false;return saveMaster();}
if(id==='OMS-ORD-002'){if(!validateTransaction())return false;markDirty(id,false);toast('주문을 저장했습니다.');return true;}
if(id==='ERP-PRICE-001'){if(fastErrorCount()>0)return false;markDirty(id,false);toast('오류 없는 변경 행을 저장했습니다.');return true;}
markDirty(id,false);return true;
}
$('#unsavedStay').onclick=()=>{clearUnsavedPending();};
$('#unsavedDiscard').onclick=()=>{
if(state.pendingUnsavedMode==='master-select'){const target=state.pendingMasterId;markDirty('ERP-MST-ITEM-001',false);clearUnsavedPending();unsavedDialog.close();if(target)performMasterTarget(target);toast('저장하지 않은 품목 변경을 버리고 전환했습니다.');return;}
if(state.pendingUnsavedMode==='navigate'){markDirty(state.activeId,false);continuePendingNavigation('저장하지 않은 변경을 버리고 이동했습니다.');return;}
const id=state.pendingCloseId;clearUnsavedPending();unsavedDialog.close();if(id){markDirty(id,false);closeTabNow(id);toast('저장하지 않은 변경을 버리고 화면을 닫았습니다.');}
};
$('#unsavedSave').onclick=()=>{
if(state.pendingUnsavedMode==='master-select'){const target=state.pendingMasterId;if(!validateMasterDraft()){clearUnsavedPending();unsavedDialog.close();renderT02(screen('ERP-MST-ITEM-001'));setTimeout(()=>$('[data-master-error]',workspace)?.focus(),0);return;}saveMaster();clearUnsavedPending();unsavedDialog.close();if(target)performMasterTarget(target);return;}
if(state.pendingUnsavedMode==='navigate'){const activeId=state.activeId;if(!trySaveDirtyScreen(activeId)){const mode=state.pendingUnsavedMode;clearUnsavedPending();unsavedDialog.close();if(activeId==='ERP-MST-ITEM-001')renderT02(screen(activeId));if(activeId==='OMS-ORD-002')renderT03(screen(activeId));if(activeId==='ERP-PRICE-001')renderT04(screen(activeId));toast('입력 오류가 있어 저장 후 이동할 수 없습니다.','error');return;}continuePendingNavigation('변경사항을 저장하고 이동했습니다.');return;}
const id=state.pendingCloseId;if(id&&!trySaveDirtyScreen(id)){clearUnsavedPending();unsavedDialog.close();toast('입력 오류가 있어 저장 후 닫을 수 없습니다.','error');return;}clearUnsavedPending();unsavedDialog.close();if(id){closeTabNow(id);toast('변경사항을 저장하고 화면을 닫았습니다.');}
};
$('#quantityApply').onclick=()=>{const value=Number($('#quantityInput').value);if(!Number.isInteger(value)||value<0||value>state.pickingRequired){$('#quantityHelp').textContent=`0 ~ ${state.pickingRequired} 사이 정수를 입력하세요.`;$('#quantityInput').focus();return;}state.pickingQty=value;state.scanError='';quantityDialog.close();renderT09(screen('WMS-PICK-001'));toast('피킹 수량을 변경했습니다.');};
$('#exceptionApply').onclick=()=>{const reason=$('input[name="exceptionReason"]:checked',exceptionDialog)?.value||'기타';exceptionDialog.close();toast(`재고부족 사유 '${reason}'를 등록했습니다.`,'error');};
document.addEventListener('keydown',e=>{
if(e.defaultPrevented)return;
const modalOpen=[searchDialog,lookupDialog,unsavedDialog,quantityDialog,exceptionDialog].some(dialog=>dialog.open);
if(modalOpen)return;
if(e.key==='Escape'&&!systemPanel.hidden){e.preventDefault();closeSystemPanel();return;}
if(e.key==='Escape'&&drawer.classList.contains('open')){e.preventDefault();closeDrawer();return;}
if(drawer.classList.contains('open'))return;
if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();openSearch();return;}
if(e.key==='F2'&&['T02','T03'].includes(screen(state.activeId).template)){const root=document.activeElement?.closest?.('.lookup')||$('.lookup',workspace);if(root){e.preventDefault();openLookup(root);return;}}
if(e.key==='F3'&&state.activeId!=='HOME'){e.preventDefault();const searchButton=state.activeId==='OMS-ORD-001'?$('#commandSearch'):state.activeId==='ERP-MST-ITEM-001'?$('#masterSearchTop'):state.activeId==='ERP-INV-001'?$('#inventorySearchButton'):state.activeId==='WMS-WORK-001'?$('#queueSearch'):state.activeId==='COMMON-REC-001'?$('#reconcileSearchButton'):null;if(searchButton)searchButton.click();else toast(`${screen(state.activeId).title} 조회를 실행했습니다.`);}
if(e.key==='F8'&&['T02','T03','T04'].includes(screen(state.activeId).template)){e.preventDefault();const saveButton=state.activeId==='ERP-MST-ITEM-001'?$('#masterSave'):state.activeId==='OMS-ORD-002'?$('#saveTransaction'):state.activeId==='ERP-PRICE-001'?$('#fastSave'):null;if(saveButton&&!saveButton.disabled)saveButton.click();}
});
window.addEventListener('hashchange',()=>{const found=screens.find(x=>x.path===location.hash);if(!found||found.id===state.activeId)return;const opened=openScreen(found.id,{updateHash:false});if(!opened){state.pendingNavigationOptions={...(state.pendingNavigationOptions||{}),updateHash:true};history.replaceState(null,'',screen(state.activeId).path);}});
window.addEventListener('beforeunload',event=>{if(state.tabs.some(tab=>tab.dirty)){event.preventDefault();event.returnValue='';}});
unsavedDialog.addEventListener('close',()=>{if(state.pendingUnsavedMode)clearUnsavedPending();});
restore();restoreWorkspaceSession();
const params=new URLSearchParams(location.search);const demoState=params.get('state');if(['ready','loading','empty','error'].includes(demoState))state.orderDataState=demoState;const network=params.get('network');if(['online','unstable','offline'].includes(network))state.networkStatus=network;const theme=params.get('theme');if(['light','dark'].includes(theme))state.theme=theme;
const byHash=screens.find(x=>x.path===location.hash);if(byHash)state.activeId=byHash.id;
if(state.activeId!=='HOME'&&!state.tabs.some(t=>t.id===state.activeId))state.tabs.push({id:state.activeId,title:screen(state.activeId).title,pinned:false,dirty:false,resumed:false});persistWorkspaceSession();
renderAll();
})();
@@ -0,0 +1,20 @@
{
"viewport": "1440x900",
"method": "Headless Chromium interaction harness using the same index/css/js with resources inlined because loopback/file navigation is blocked by the execution sandbox.",
"runtimeErrors": 0,
"checks": {
"homeRendered": true,
"ctrlKSearchOpenedT01": true,
"t01QuickFilterChangedRows": true,
"t01SelectionEnabledBulkAction": true,
"drawerEscapeRecovery": true,
"dirtyTabCloseGuard": true,
"t05MasterDetailChanged": true,
"t06ExceptionFilter": true,
"t07MismatchToggle": true,
"t08FileGateAndStep2": true,
"t09WrongBarcodeError": true,
"t09CorrectBarcodeIncrement": true,
"t09QuantityDialog": true
}
}
@@ -0,0 +1,162 @@
{
"version": "v39",
"passed": 31,
"total": 31,
"results": [
{
"name": "home stock intent opens T01",
"ok": true,
"detail": ""
},
{
"name": "home stock intent applies filter",
"ok": true,
"detail": ""
},
{
"name": "stock filter narrows visible rows",
"ok": true,
"detail": "1"
},
{
"name": "T01 Space selects focused row",
"ok": true,
"detail": ""
},
{
"name": "T01 Enter opens detail drawer",
"ok": true,
"detail": ""
},
{
"name": "drawer makes background inert",
"ok": true,
"detail": ""
},
{
"name": "drawer Esc restores background",
"ok": true,
"detail": ""
},
{
"name": "dirty navigation opens guard",
"ok": true,
"detail": ""
},
{
"name": "dirty navigation keeps T03 until decision",
"ok": true,
"detail": ""
},
{
"name": "discard continues pending navigation",
"ok": true,
"detail": ""
},
{
"name": "T02 F2 opens lookup dialog",
"ok": true,
"detail": ""
},
{
"name": "lookup Enter selects warehouse",
"ok": true,
"detail": ""
},
{
"name": "lookup selection advances focus",
"ok": true,
"detail": "barcode"
},
{
"name": "help opens actual drawer content",
"ok": true,
"detail": ""
},
{
"name": "suggestion form submits and closes",
"ok": true,
"detail": ""
},
{
"name": "T08 opens after save",
"ok": true,
"detail": ""
},
{
"name": "T08 reaches preview step",
"ok": true,
"detail": ""
},
{
"name": "T08 background job progresses",
"ok": true,
"detail": "22"
},
{
"name": "T08 can leave while job runs",
"ok": true,
"detail": ""
},
{
"name": "T08 return restores progress",
"ok": true,
"detail": "(22, 55)"
},
{
"name": "T08 job completes",
"ok": true,
"detail": ""
},
{
"name": "WMS valid scan increments once",
"ok": true,
"detail": "5"
},
{
"name": "WMS duplicate scan does not increment",
"ok": true,
"detail": "(5, 5)"
},
{
"name": "runtime console/page errors",
"ok": true,
"detail": "[]"
},
{
"name": "1280x720 no document horizontal overflow",
"ok": true,
"detail": "[1280, 1280]"
},
{
"name": "1280x720 grid remains in local scroll container",
"ok": true,
"detail": ""
},
{
"name": "1280 runtime errors",
"ok": true,
"detail": "[]"
},
{
"name": "narrow/zoom-pressure shell avoids document overflow",
"ok": true,
"detail": "[720, 720]"
},
{
"name": "narrow shell collapses side nav",
"ok": true,
"detail": "56px"
},
{
"name": "narrow runtime errors",
"ok": true,
"detail": "[]"
},
{
"name": "WMS 390x844 uses full mobile viewport",
"ok": true,
"detail": "mode=wms-mobile, header=none, width=390px, scroll=[390, 390], errors=[]"
}
]
}
@@ -0,0 +1,254 @@
{
"version": "v40",
"passed": 49,
"total": 49,
"results": [
{
"name": "home renders",
"pass": true,
"detail": ""
},
{
"name": "home actionable attention 4",
"pass": true,
"detail": ""
},
{
"name": "T01 channel+keyword filters actual rows",
"pass": true,
"detail": "rows=1"
},
{
"name": "T01 query values preserved after loading render",
"pass": true,
"detail": ""
},
{
"name": "T01 selection enables ship",
"pass": true,
"detail": ""
},
{
"name": "T01 reset clears actual criteria",
"pass": true,
"detail": ""
},
{
"name": "T02 F8 uses validation not dirty bypass",
"pass": true,
"detail": ""
},
{
"name": "T02 lookup dialog opens",
"pass": true,
"detail": ""
},
{
"name": "T02 lookup selected warehouse",
"pass": true,
"detail": ""
},
{
"name": "T02 new master save adds list row",
"pass": true,
"detail": ""
},
{
"name": "T02 save clears dirty indicator",
"pass": true,
"detail": ""
},
{
"name": "T02 master selection dirty guard",
"pass": true,
"detail": ""
},
{
"name": "T02 discard then select target",
"pass": true,
"detail": ""
},
{
"name": "T03 F8 blocks invalid line",
"pass": true,
"detail": ""
},
{
"name": "T03 valid save clears dirty",
"pass": true,
"detail": ""
},
{
"name": "T03 explicit state transition",
"pass": true,
"detail": ""
},
{
"name": "T03 confirmed disables confirm",
"pass": true,
"detail": ""
},
{
"name": "T06 selection enables assign",
"pass": true,
"detail": ""
},
{
"name": "T06 assignment changes row state",
"pass": true,
"detail": ""
},
{
"name": "T06 action result visible",
"pass": true,
"detail": ""
},
{
"name": "T07 reprocess resolves selected mismatch",
"pass": true,
"detail": "2->1"
},
{
"name": "T07 result feedback persists",
"pass": true,
"detail": ""
},
{
"name": "browser hash navigation dirty guard opens",
"pass": true,
"detail": ""
},
{
"name": "browser hash rolled back while pending",
"pass": true,
"detail": "#items"
},
{
"name": "discard completes pending route",
"pass": true,
"detail": ""
},
{
"name": "T08 background job running",
"pass": true,
"detail": ""
},
{
"name": "T08 can leave job",
"pass": true,
"detail": ""
},
{
"name": "T09 mobile hides desktop shell",
"pass": true,
"detail": ""
},
{
"name": "T09 validated scan increments",
"pass": true,
"detail": ""
},
{
"name": "runtime console/page errors zero",
"pass": true,
"detail": ""
},
{
"name": "Ctrl+K opens menu search",
"pass": true,
"detail": ""
},
{
"name": "menu search Enter opens target",
"pass": true,
"detail": ""
},
{
"name": "side nav collapse state",
"pass": true,
"detail": ""
},
{
"name": "density toggle applies comfortable",
"pass": true,
"detail": ""
},
{
"name": "utility drawer opens",
"pass": true,
"detail": ""
},
{
"name": "drawer makes shell inert",
"pass": true,
"detail": ""
},
{
"name": "drawer Esc restores shell",
"pass": true,
"detail": ""
},
{
"name": "T04 invalid price disables save",
"pass": true,
"detail": ""
},
{
"name": "T04 corrected price restores save",
"pass": true,
"detail": ""
},
{
"name": "T04 save clears dirty before navigation",
"pass": true,
"detail": ""
},
{
"name": "T05 master detail context changes",
"pass": true,
"detail": ""
},
{
"name": "T08 job progresses while away",
"pass": true,
"detail": "11->33"
},
{
"name": "T08 job completes asynchronously",
"pass": true,
"detail": ""
},
{
"name": "T08 result step reachable",
"pass": true,
"detail": ""
},
{
"name": "T09 duplicate scan does not double increment",
"pass": true,
"detail": "5->5"
},
{
"name": "T09 wrong barcode recovery visible",
"pass": true,
"detail": ""
},
{
"name": "T09 390 full viewport width",
"pass": true,
"detail": ""
},
{
"name": "1280x720 document horizontal overflow none",
"pass": true,
"detail": "0"
},
{
"name": "supplement runtime errors zero",
"pass": true,
"detail": ""
}
],
"runtimeErrors": [],
"note": "Production DOM/CSS/JS tested in inline-asset browser harness because container browser navigation is administratively blocked."
}
@@ -0,0 +1,89 @@
{
"version": "v43",
"scope": "inline-asset browser QA for FE Reference Lab; production CSP remains statically validated",
"passed": 16,
"total": 16,
"checks": [
{
"name": "initial-light",
"pass": true,
"detail": "light"
},
{
"name": "toggle-dark",
"pass": true,
"detail": "dark"
},
{
"name": "dark-text-recomputed",
"pass": true,
"detail": "rgb(229, 231, 235)"
},
{
"name": "theme-aria",
"pass": true,
"detail": "밝은 테마로 변경"
},
{
"name": "T01-dark-render",
"pass": true,
"detail": "OMS-ORD-001:T01"
},
{
"name": "T02-dark-render",
"pass": true,
"detail": "ERP-MST-ITEM-001:T02"
},
{
"name": "T03-dark-render",
"pass": true,
"detail": "OMS-ORD-002:T03"
},
{
"name": "T04-dark-render",
"pass": true,
"detail": "ERP-PRICE-001:T04"
},
{
"name": "T05-dark-render",
"pass": true,
"detail": "ERP-INV-001:T05"
},
{
"name": "T06-dark-render",
"pass": true,
"detail": "WMS-WORK-001:T06"
},
{
"name": "T07-dark-render",
"pass": true,
"detail": "COMMON-REC-001:T07"
},
{
"name": "T08-dark-render",
"pass": true,
"detail": "OMS-ORD-003:T08"
},
{
"name": "T09-dark-render",
"pass": true,
"detail": "WMS-PICK-001:wms-mobile:wms-mobile"
},
{
"name": "1280-no-document-overflow",
"pass": true,
"detail": "False"
},
{
"name": "toggle-light-back",
"pass": true,
"detail": "light"
},
{
"name": "runtime-errors-zero",
"pass": true,
"detail": ""
}
],
"pageErrors": []
}
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<title>KBX 업무 플랫폼</title>
</head>
<body>
<noscript>KBX 업무 화면을 사용하려면 JavaScript가 필요합니다.</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,35 @@
{
"name": "@kbx/web",
"version": "0.45.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"dev:demo": "vite --mode demo --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"build:demo": "vue-tsc --noEmit && vite build --mode demo",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@kbx/contracts": "workspace:*",
"@kbx/ui": "workspace:*",
"@microsoft/signalr": "^10.0.0",
"@primeuix/themes": "^1.2.3",
"@tanstack/vue-query": "^5.85.5",
"ag-grid-community": "^34.1.2",
"ag-grid-vue3": "^34.1.2",
"axios": "^1.11.0",
"pinia": "^3.0.3",
"primevue": "^4.3.7",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"zod": "^4.0.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^7.0.0",
"typescript": "^5.9.2",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5"
}
}
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { kbxPermissionCatalog } from '@kbx/contracts'
import KbxAppFrame from './shell/KbxAppFrame.vue'
const demoMode = import.meta.env.VITE_KBX_DEMO_MODE === 'true'
const permissions = computed(() => demoMode ? kbxPermissionCatalog.map(permission => permission.id) : [])
</script>
<template>
<KbxAppFrame
product-name="KBX"
:granted-permissions="permissions"
:preference-scope="demoMode ? 'demo-tenant:qa-user' : 'anonymous:secure-default'"
app-version="v51-fe-presentation-workbench"
:user-role="demoMode ? 'QA' : 'anonymous'"
/>
</template>
@@ -0,0 +1,217 @@
import type { AxiosAdapter, AxiosRequestConfig, AxiosResponse } from 'axios'
import { kbxHttp } from '../http/kbxHttpClient'
const now = () => new Date().toISOString()
const clone = <T>(value: T): T => structuredClone(value)
const uid = () => crypto.randomUUID()
const customers = [
{ id:'11111111-1111-4111-8111-111111111111', code:'10001', displayName:'대한상사', secondaryText:'서울 · 정상', status:'active', metadata:{ businessNumber:'123-45-67890', representative:'김대표' } },
{ id:'22222222-2222-4222-8222-222222222222', code:'10002', displayName:'한빛유통', secondaryText:'경기 · 정상', status:'active', metadata:{ businessNumber:'234-56-78901', representative:'이대표' } },
{ id:'33333333-3333-4333-8333-333333333333', code:'10003', displayName:'새봄물류', secondaryText:'인천 · 정상', status:'active', metadata:{ businessNumber:'345-67-89012', representative:'박대표' } },
]
const warehouses = [
{ id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', code:'WH01', displayName:'서울물류센터', secondaryText:'수도권', status:'active' },
{ id:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', code:'WH02', displayName:'인천물류센터', secondaryText:'수도권', status:'active' },
{ id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc', code:'WH03', displayName:'부산물류센터', secondaryText:'영남권', status:'active' },
]
const items = [
{ id:'10000000-0000-4000-8000-000000000001', code:'ABC001', name:'운동화 BLACK 270', displayName:'운동화 BLACK 270', categoryName:'신발', specification:'BLACK / 270', unit:'EA', barcode:'8801234567001', defaultWarehouseId:warehouses[0].id, defaultWarehouseName:warehouses[0].displayName, lotManaged:false, expiryManaged:false, active:true, version:3 },
{ id:'10000000-0000-4000-8000-000000000002', code:'ABC002', name:'운동화 WHITE 265', displayName:'운동화 WHITE 265', categoryName:'신발', specification:'WHITE / 265', unit:'EA', barcode:'8801234567002', defaultWarehouseId:warehouses[0].id, defaultWarehouseName:warehouses[0].displayName, lotManaged:false, expiryManaged:false, active:true, version:2 },
{ id:'10000000-0000-4000-8000-000000000003', code:'DEF001', name:'기능성 양말', displayName:'기능성 양말', categoryName:'의류', specification:'FREE / BLACK', unit:'EA', barcode:'8801234567003', defaultWarehouseId:warehouses[1].id, defaultWarehouseName:warehouses[1].displayName, lotManaged:false, expiryManaged:false, active:true, version:5 },
{ id:'10000000-0000-4000-8000-000000000004', code:'FOOD01', name:'에너지바 40g', displayName:'에너지바 40g', categoryName:'식품', specification:'40g', unit:'EA', barcode:'8801234567004', defaultWarehouseId:warehouses[1].id, defaultWarehouseName:warehouses[1].displayName, lotManaged:true, expiryManaged:true, active:true, version:1 },
]
const orderDetails = new Map<string, any>([
['20000000-0000-4000-8000-000000000001', {
orderId:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', version:4, status:'출고대기', orderDate:'2026-08-11', customerId:customers[0].id, warehouseId:warehouses[0].id,
receiverName:'홍길동', phone:'010-1234-5678', postalCode:'06236', address1:'서울특별시 강남구 테헤란로 1', address2:'101호',
lines:[{id:'line-1',clientId:'line-1',itemId:items[0].id,itemCode:'ABC001',itemName:items[0].name,orderQty:2,unitPrice:89000,amount:178000,remark:''}],
}],
['20000000-0000-4000-8000-000000000002', {
orderId:'20000000-0000-4000-8000-000000000002', orderNo:'ORD-260811-0002', version:2, status:'신규', orderDate:'2026-08-11', customerId:customers[1].id, warehouseId:warehouses[0].id,
receiverName:'김하나', phone:'010-2345-6789', postalCode:'04524', address1:'서울특별시 중구 세종대로 10', address2:'',
lines:[{id:'line-2',clientId:'line-2',itemId:items[2].id,itemCode:'DEF001',itemName:items[2].name,orderQty:5,unitPrice:12000,amount:60000,remark:'선물포장'}],
}],
['20000000-0000-4000-8000-000000000003', {
orderId:'20000000-0000-4000-8000-000000000003', orderNo:'ORD-260811-0003', version:6, status:'출고대기', orderDate:'2026-08-11', customerId:customers[2].id, warehouseId:warehouses[1].id,
receiverName:'박현장', phone:'010-3456-7890', postalCode:'22382', address1:'인천광역시 중구 공항동로 1', address2:'물류동',
lines:[{id:'line-3',clientId:'line-3',itemId:items[1].id,itemCode:'ABC002',itemName:items[1].name,orderQty:1,unitPrice:92000,amount:92000,remark:''}],
}],
])
let orderRows = [
{ id:'20000000-0000-4000-8000-000000000001', orderNo:'ORD-260811-0001', channelName:'쿠팡', orderedAt:'2026-08-11T09:15:00+09:00', customerName:'홍길동', itemSummary:'운동화 BLACK 270', totalQty:2, amount:178000, allocationStatus:'부족', shipmentStatus:'출고대기', exceptionCount:1, status:'READY' },
{ id:'20000000-0000-4000-8000-000000000002', orderNo:'ORD-260811-0002', channelName:'스마트스토어', orderedAt:'2026-08-11T09:38:00+09:00', customerName:'김하나', itemSummary:'기능성 양말', totalQty:5, amount:60000, allocationStatus:'정상', shipmentStatus:'신규', exceptionCount:0, status:'NEW' },
{ id:'20000000-0000-4000-8000-000000000003', orderNo:'ORD-260811-0003', channelName:'자사몰', orderedAt:'2026-08-11T10:02:00+09:00', customerName:'박현장', itemSummary:'운동화 WHITE 265', totalQty:1, amount:92000, allocationStatus:'정상', shipmentStatus:'출고대기', exceptionCount:0, status:'READY' },
{ id:'20000000-0000-4000-8000-000000000004', orderNo:'ORD-260811-0004', channelName:'쿠팡', orderedAt:'2026-08-11T10:17:00+09:00', customerName:'이소라', itemSummary:'에너지바 40g', totalQty:12, amount:36000, allocationStatus:'정상', shipmentStatus:'보류', exceptionCount:1, status:'HOLD' },
{ id:'20000000-0000-4000-8000-000000000005', orderNo:'ORD-260811-0005', channelName:'스마트스토어', orderedAt:'2026-08-11T11:05:00+09:00', customerName:'정우진', itemSummary:'기능성 양말 외 1종', totalQty:4, amount:84000, allocationStatus:'정상', shipmentStatus:'출고완료', exceptionCount:0, status:'SHIPPED' },
]
const auditByEntity = new Map<string, any[]>()
function audit(id:string, action:string, changes:any[] = []) {
const list = auditByEntity.get(id) ?? []
list.unshift({ id:uid(), occurredAt:now(), actor:{type:'user',displayName:'QA 사용자'}, action, changes })
auditByEntity.set(id, list)
}
for (const id of orderDetails.keys()) audit(id, '주문 조회용 데모 데이터 생성')
for (const item of items) audit(item.id, '품목 기준정보 생성')
let notifications:any[] = [
{ id:'noti-1', severity:'warning', title:'재고부족 주문 7건', message:'출고 전 확인이 필요합니다.', createdAt:now(), screenId:'OMS-ORD-001', action:{id:'open',label:'주문 보기',route:'/oms/orders?exceptionOnly=true'} },
{ id:'noti-2', severity:'error', title:'OMS ↔ WMS 대사 불일치 3건', message:'원인 확인 후 재처리하세요.', createdAt:now(), screenId:'COMMON-REC-001', action:{id:'open',label:'대사 보기',route:'/operations/reconcile'} },
]
let operations:any[] = [
{ id:'job-1', type:'excel-import', title:'주문 Excel Import', sourceScreenId:'OMS-ORD-003', status:'running', requestedAt:now(), startedAt:now(), processed:13421, total:17894, succeeded:13218, failed:203, resultMessage:'75% 처리 중' },
]
const workItems = [
{ id:'op-1', module:'OMS', category:'재고', code:'STOCK_SHORTAGE', title:'주문 재고 부족', detail:'ABC001 가용재고 부족', severity:'warning', status:'open', owner:'', occurredAt:now(), screenId:'OMS-ORD-001', entityId:orderRows[0].id },
{ id:'op-2', module:'WMS', category:'연계', code:'SHIPMENT_SYNC', title:'출고 연계 지연', detail:'WMS 전송 재시도 대기', severity:'error', status:'open', owner:'', occurredAt:now(), screenId:'COMMON-OPS-001', entityId:'sync-2' },
]
const reconcileItems = [
{ id:'rec-1', reconcileType:'OMS_WMS_SHIPMENT', referenceNo:'ORD-260811-0001', expected:2, actual:1, difference:-1, reason:'피킹부족', status:'mismatch', resolutionStatus:'미처리' },
{ id:'rec-2', reconcileType:'OMS_WMS_SHIPMENT', referenceNo:'ORD-260811-0003', expected:1, actual:1, difference:0, reason:'', status:'matched', resolutionStatus:'정상' },
]
const pickingTask:any = {
taskId:'30000000-0000-4000-8000-000000000001', taskNo:'PICK-260811-001', status:'ready', stage:'ready', version:1,
currentLine:{ lineId:'pick-line-1', lineNo:23, locationCode:'A-03-02', itemId:items[0].id, itemCode:items[0].code, itemName:items[0].name, itemOption:items[0].specification, barcode:items[0].barcode, requiredQty:6, pickedQty:4, remainingQty:2 },
completedLines:22, totalLines:138, completedQty:71, totalQty:427, message:'작업을 시작하세요.',
}
const imports = new Map<string, any>()
function ok(config: AxiosRequestConfig, data: unknown, status = 200): AxiosResponse {
return { data, status, statusText: status === 204 ? 'No Content' : 'OK', headers:{}, config:config as any, request:{} }
}
function text(v:unknown){ return String(v ?? '').trim().toLowerCase() }
function body(config:AxiosRequestConfig):any {
if (typeof config.data === 'string') { try { return JSON.parse(config.data) } catch { return config.data } }
return config.data ?? {}
}
function params(config:AxiosRequestConfig):Record<string,any>{ return (config.params ?? {}) as Record<string,any> }
function path(config:AxiosRequestConfig){ return String(config.url ?? '').split('?')[0] }
function findLookup(list:any[], codeOrId:string){ return list.find(x=>x.id===codeOrId||x.code.toLowerCase()===codeOrId.toLowerCase()) ?? null }
function lookupResponse(list:any[], q:any){
const keyword=text(q.query)
const filtered=keyword?list.filter(x=>`${x.code} ${x.displayName} ${x.secondaryText??''}`.toLowerCase().includes(keyword)):list
return { items:clone(filtered.slice(0,Number(q.pageSize??20))), totalCount:filtered.length }
}
const demoAdapter:AxiosAdapter = async config => {
await new Promise(resolve => setTimeout(resolve, 90))
const method=String(config.method??'get').toUpperCase(); const url=path(config); const q=params(config); const data=body(config)
if(method==='GET'&&url==='/api/kbx/runtime/notice') return ok(config,null)
if(method==='GET'&&url==='/api/kbx/runtime/operations') return ok(config,clone(operations))
if(method==='GET'&&url==='/api/kbx/runtime/notifications') return ok(config,clone(notifications))
if(method==='POST'&&/^\/api\/kbx\/runtime\/notifications\/[^/]+\/read$/.test(url)){const id=url.split('/').at(-2)!;const n=notifications.find(x=>x.id===id);if(n)n.readAt=now();return ok(config,undefined,204)}
if(method==='POST'&&url==='/api/kbx/ux/events') return ok(config,undefined,204)
if(method==='POST'&&url==='/api/common/suggestions') return ok(config,{id:uid(),receivedAt:now()})
if(method==='POST'&&url==='/api/common/ai/ask') return ok(config,{answer:'현재 화면의 예외와 선택 상태를 기준으로 확인할 항목을 정리했습니다.',actions:[]})
if(method==='GET'&&url==='/api/oms/orders'){
let rows=[...orderRows]; const keyword=text(q.keyword)
if(q.status) rows=rows.filter(x=>x.status===q.status)
if(String(q.exceptionOnly)==='true'||q.exceptionOnly===true) rows=rows.filter(x=>x.exceptionCount>0)
if(keyword) rows=rows.filter(x=>`${x.orderNo} ${x.customerName} ${x.itemSummary} ${x.channelName}`.toLowerCase().includes(keyword))
const counters={all:orderRows.length,new:orderRows.filter(x=>x.status==='NEW').length,readyToShip:orderRows.filter(x=>x.status==='READY').length,exceptions:orderRows.filter(x=>x.exceptionCount>0).length}
const pageSize=Number(q.pageSize??100); const page=Math.max(Number(q.page??1),1); const slice=rows.slice((page-1)*pageSize,page*pageSize)
return ok(config,{items:clone(slice),totalCount:rows.length,totalQty:rows.reduce((s,x)=>s+x.totalQty,0),totalAmount:rows.reduce((s,x)=>s+x.amount,0),counters})
}
if(method==='GET'&&/^\/api\/oms\/orders\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const existing=orderDetails.get(id);if(existing)return ok(config,clone(existing));const row=orderRows.find(x=>x.id===id);if(row)return ok(config,{orderId:id,orderNo:row.orderNo,version:1,status:row.shipmentStatus,orderDate:'2026-08-11',customerId:customers[0].id,warehouseId:warehouses[0].id,receiverName:row.customerName,phone:'010-0000-0000',address1:'서울특별시',lines:[]});return ok(config,null)}
if(method==='GET'&&/^\/api\/oms\/orders\/[^/]+\/audit$/.test(url)){const id=url.split('/').at(-2)!;return ok(config,clone(auditByEntity.get(id)??[]))}
if(method==='POST'&&url==='/api/oms/orders/ship'){
const ids:Array<string>=data.mode==='ids'?(data.ids??[]):orderRows.filter(row=>row.status==='READY').map(row=>row.id)
let accepted=0; for(const id of ids){const row=orderRows.find(x=>x.id===id);if(row&&row.status==='READY'){row.shipmentStatus='출고지시';accepted++}}
return ok(config,{requested:ids.length,accepted,rejected:ids.length-accepted})
}
if(method==='POST'&&url==='/api/oms/orders/register'){
const id=uid();const orderNo=`ORD-260811-${String(orderRows.length+1).padStart(4,'0')}`;const detail={...data,orderId:id,orderNo,version:1,status:'작성',lines:(data.lines??[]).map((line:any,index:number)=>{const item=items.find(x=>x.id===line.itemId);return{id:`${id}-${index+1}`,clientId:line.clientId,itemId:line.itemId,itemCode:item?.code??'',itemName:item?.name??'',orderQty:Number(line.orderQty),unitPrice:Number(line.unitPrice),amount:Number(line.orderQty)*Number(line.unitPrice),remark:line.remark??''}})};orderDetails.set(id,detail);orderRows.unshift({id,orderNo,channelName:'직접주문',orderedAt:now(),customerName:data.receiverName,itemSummary:detail.lines[0]?.itemName??'상품 없음',totalQty:detail.lines.reduce((s:number,x:any)=>s+x.orderQty,0),amount:detail.lines.reduce((s:number,x:any)=>s+x.amount,0),allocationStatus:'미확인',shipmentStatus:'작성',exceptionCount:0,status:'NEW'});audit(id,'주문 등록');return ok(config,{orderId:id,orderNo,version:1,status:'작성'})
}
if(method==='PUT'&&/^\/api\/oms\/orders\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const existing=orderDetails.get(id);const version=Number(existing?.version??0)+1;const next={...(existing??{}),...data,orderId:id,orderNo:existing?.orderNo??`ORD-${id.slice(0,8)}`,version,status:existing?.status??'작성',lines:(data.lines??existing?.lines??[]).map((line:any,index:number)=>{const item=items.find(x=>x.id===line.itemId);return{...line,id:line.id??`${id}-${index+1}`,itemCode:item?.code??line.itemCode??'',itemName:item?.name??line.itemName??'',amount:Number(line.orderQty)*Number(line.unitPrice)}})};orderDetails.set(id,next);audit(id,'주문 수정');return ok(config,{orderId:id,orderNo:next.orderNo,version,status:next.status})}
if(method==='POST'&&/^\/api\/oms\/orders\/[^/]+\/confirm$/.test(url)){const id=url.split('/').at(-2)!;const existing=orderDetails.get(id);if(existing){existing.version=Number(existing.version)+1;existing.status='확정';audit(id,'주문 확정');return ok(config,{orderId:id,orderNo:existing.orderNo,version:existing.version,status:'확정'})}return ok(config,{orderId:id,orderNo:'',version:1,status:'확정'})}
if(url.startsWith('/api/lookups/customers')){
if(method==='GET'&&url==='/api/lookups/customers')return ok(config,lookupResponse(customers,q))
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(customers,key)))
}
if(url.startsWith('/api/lookups/warehouses')){
if(method==='GET'&&url==='/api/lookups/warehouses')return ok(config,lookupResponse(warehouses,q))
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(warehouses,key)))
}
if(url.startsWith('/api/lookups/items')){
const lookupItems=items.map(x=>({id:x.id,code:x.code,displayName:x.name,secondaryText:`${x.specification} · ${x.unit}`,status:x.active?'active':'disabled',metadata:{availableQty:x.code==='ABC001'?8:120}}))
if(method==='GET'&&url==='/api/lookups/items')return ok(config,lookupResponse(lookupItems,q))
const key=decodeURIComponent(url.split('/').at(-1)!);return ok(config,clone(findLookup(lookupItems,key)))
}
if(method==='GET'&&url==='/api/erp/items'){const keyword=text(q.keyword);const found=keyword?items.filter(x=>`${x.code} ${x.name}`.toLowerCase().includes(keyword)):items;return ok(config,{items:clone(found),totalCount:found.length})}
if(method==='GET'&&/^\/api\/erp\/items\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;return ok(config,clone(items.find(x=>x.id===id)??null))}
if(method==='GET'&&/^\/api\/erp\/items\/[^/]+\/audit$/.test(url)){const id=url.split('/').at(-2)!;return ok(config,clone(auditByEntity.get(id)??[]))}
if(method==='POST'&&url==='/api/erp/items'){const id=uid();const created={id,version:1,active:true,defaultWarehouseName:warehouses.find(x=>x.id===data.defaultWarehouseId)?.displayName??'',...data};items.push(created);audit(id,'품목 등록');return ok(config,{id,version:1,status:'사용'})}
if(method==='PUT'&&/^\/api\/erp\/items\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;const target=items.find(x=>x.id===id);if(target){Object.assign(target,data,{version:Number(target.version)+1,defaultWarehouseName:warehouses.find(x=>x.id===data.defaultWarehouseId)?.displayName??target.defaultWarehouseName});audit(id,'품목 수정');return ok(config,{id,version:target.version,status:target.active?'사용':'사용중지'})}return ok(config,{id,version:1,status:'사용'})}
if(method==='POST'&&/^\/api\/erp\/items\/[^/]+\/deactivate$/.test(url)){const id=url.split('/').at(-2)!;const target=items.find(x=>x.id===id);if(target){target.active=false;target.version=Number(target.version)+1;audit(id,'품목 사용중지');return ok(config,{id,version:target.version,status:'사용중지'})}return ok(config,{id,version:1,status:'사용중지'})}
if(method==='GET'&&url==='/api/erp/inventory'){
const inv=items.map((x,index)=>({itemId:x.id,itemCode:x.code,itemName:x.name,specification:x.specification,unit:x.unit,totalQty:[1280,721,312,96][index]??0,availableQty:[830,650,290,80][index]??0,allocatedQty:[430,61,12,8][index]??0,holdQty:[20,10,10,8][index]??0}))
const keyword=text(q.keyword);const found=keyword?inv.filter(x=>`${x.itemCode} ${x.itemName}`.toLowerCase().includes(keyword)):inv;return ok(config,{items:found,totalCount:found.length})
}
if(method==='GET'&&/^\/api\/erp\/inventory\/[^/]+\/locations$/.test(url)){return ok(config,{items:[{warehouseCode:'WH01',warehouseName:'서울물류센터',locationCode:'A-01-01',onHandQty:300,availableQty:260},{warehouseCode:'WH01',warehouseName:'서울물류센터',locationCode:'A-01-02',onHandQty:530,availableQty:500}]})}
if(method==='GET'&&/^\/api\/erp\/inventory\/[^/]+\/history$/.test(url)){return ok(config,{items:[{occurredAt:'2026-08-11T10:21:00+09:00',businessType:'출고',inQty:0,outQty:2,balanceQty:830,referenceNo:'ORD-260811-0001'},{occurredAt:'2026-08-11T08:40:00+09:00',businessType:'입고',inQty:100,outQty:0,balanceQty:832,referenceNo:'GR-260811-001'}]})}
if(method==='POST'&&url==='/api/erp/item-prices/bulk'){return ok(config,{requested:data.rows?.length??0,saved:data.rows?.length??0,created:data.rows?.length??0,updated:0})}
if(method==='GET'&&url==='/api/operations/work-items'){return ok(config,{items:clone(workItems),totalCount:workItems.length,counters:{all:workItems.length,open:workItems.filter(x=>x.status==='open').length,critical:workItems.filter(x=>x.severity==='error').length}})}
if(method==='POST'&&url==='/api/operations/work-items/claim'){for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item)item.owner='QA 사용자'}return ok(config,{updated:(data.ids??[]).length})}
if(method==='POST'&&url==='/api/operations/work-items/resolve'){for(const id of data.ids??[]){const item=workItems.find(x=>x.id===id);if(item)item.status='resolved'}return ok(config,{updated:(data.ids??[]).length})}
if(method==='POST'&&/^\/api\/operations\/work-items\/[^/]+\/retry$/.test(url))return ok(config,{accepted:true})
if(method==='GET'&&url==='/api/reconcile/items'){return ok(config,{items:clone(reconcileItems),totalCount:reconcileItems.length,mismatchCount:reconcileItems.filter(x=>x.difference!==0).length})}
if(method==='POST'&&url==='/api/reconcile/items/create-exceptions')return ok(config,{created:(data.ids??[]).length})
if(method==='GET'&&url==='/api/oms/claims')return ok(config,{items:[{id:'claim-1',claimNo:'CLM-260811-01',orderNo:'ORD-260811-0001',type:'RETURN',reason:'사이즈 교환',status:'접수',customerName:'홍길동',requestedAt:now(),version:1}],totalCount:1})
if(method==='POST'&&/^\/api\/oms\/claims\/[^/]+\/(approve|hold|start|complete)$/.test(url)){const transition=url.split('/').at(-1)!;return ok(config,{id:url.split('/').at(-2),status:{approve:'승인',hold:'보류',start:'처리중',complete:'완료'}[transition],version:2})}
if(method==='GET'&&/^\/api\/wms\/picking\/tasks\/[^/]+$/.test(url))return ok(config,clone(pickingTask))
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/start$/.test(url)){pickingTask.status='picking';pickingTask.stage='await-location';pickingTask.message='A-03-02 위치를 스캔하세요.';pickingTask.version++;return ok(config,clone(pickingTask))}
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/scan$/.test(url)){
const raw=String(data.normalizedValue??data.rawValue??data.barcode??'')
if(pickingTask.stage==='await-location'){
const accepted=raw===pickingTask.currentLine?.locationCode
if(accepted){pickingTask.stage='await-item';pickingTask.message='상품을 스캔하세요.';pickingTask.version++}
return ok(config,{accepted,duplicate:false,task:clone(pickingTask),feedback:accepted?'success':'error',message:accepted?'위치를 확인했습니다.':`잘못된 위치입니다. ${pickingTask.currentLine?.locationCode}로 이동하세요.`})
}
const expected=pickingTask.currentLine?.barcode
if(raw===expected&&pickingTask.currentLine){pickingTask.currentLine.pickedQty++;pickingTask.currentLine.remainingQty=Math.max(pickingTask.currentLine.requiredQty-pickingTask.currentLine.pickedQty,0);pickingTask.completedQty++;pickingTask.version++;pickingTask.message=pickingTask.currentLine.remainingQty===0?'현재 상품 피킹을 완료했습니다.':'수량을 반영했습니다.';return ok(config,{accepted:true,duplicate:false,task:clone(pickingTask),feedback:'success',message:pickingTask.message})}
return ok(config,{accepted:false,duplicate:false,task:clone(pickingTask),feedback:'error',message:'다른 상품입니다.'})
}
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/quantity$/.test(url)){if(pickingTask.currentLine){const before=pickingTask.currentLine.pickedQty;pickingTask.currentLine.pickedQty=Number(data.pickedQty??0);pickingTask.currentLine.remainingQty=Math.max(pickingTask.currentLine.requiredQty-pickingTask.currentLine.pickedQty,0);pickingTask.completedQty+=pickingTask.currentLine.pickedQty-before;pickingTask.version++;pickingTask.message='피킹 수량을 변경했습니다.'}return ok(config,{accepted:true,duplicate:false,task:clone(pickingTask),feedback:'success',message:pickingTask.message})}
if(method==='POST'&&/^\/api\/wms\/picking\/tasks\/[^/]+\/exceptions$/.test(url)){pickingTask.status='exception';pickingTask.version++;return ok(config,clone(pickingTask))}
if(method==='POST'&&url==='/api/imports/sessions'){
const id=uid();const session={id,importType:'oms.orders.v1',screenId:'OMS-ORD-003',fileName:'demo-orders.xlsx',status:'mapping-required',totalRows:128,validRows:0,invalidRows:0,warningRows:0,createdRows:0,updatedRows:0,progressPercent:10,mapping:[],sourceColumns:['주문번호','상품코드','수량','구매자'],createdAt:now()};imports.set(id,session);operations=[{id:`import:${id}`,type:'excel-import',title:'주문 Excel Import',sourceScreenId:'OMS-ORD-003',status:'running',requestedAt:now(),startedAt:now(),processed:0,total:128,succeeded:0,failed:0},...operations];return ok(config,clone(session))
}
if(method==='GET'&&/^\/api\/imports\/sessions\/[^/]+$/.test(url)){const id=url.split('/').at(-1)!;return ok(config,clone(imports.get(id)??null))}
if(method==='PUT'&&/^\/api\/imports\/sessions\/[^/]+\/mapping$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){s.mapping=data.mappings??[];s.status='uploaded';s.progressPercent=20}return ok(config,clone(s))}
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/validate$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){Object.assign(s,{status:'validated',validRows:125,invalidRows:3,warningRows:2,progressPercent:60,errors:[{rowNumber:8,field:'itemCode',sourceColumn:'상품코드',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다.',severity:'error'}]})}return ok(config,clone(s))}
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/commit$/.test(url)){const id=url.split('/').at(-2)!;const s=imports.get(id);if(s){Object.assign(s,{status:'partially-completed',createdRows:120,updatedRows:5,progressPercent:100,completedAt:now()});const op=operations.find(x=>x.id===`import:${id}`);if(op)Object.assign(op,{status:'partially-completed',processed:128,succeeded:125,failed:3,completedAt:now(),resultMessage:'125건 반영 / 3건 오류'})}return ok(config,clone(s))}
if(method==='POST'&&/^\/api\/imports\/sessions\/[^/]+\/saved-mappings$/.test(url))return ok(config,undefined,204)
if(method==='GET'&&/^\/api\/imports\/oms.orders.v1\/template$/.test(url))return ok(config,new Blob(['KBX demo workbook'],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}))
if(method==='GET'&&/^\/api\/imports\/sessions\/[^/]+\/errors.xlsx$/.test(url))return ok(config,new Blob(['KBX demo errors'],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}))
if(method==='GET'&&url==='/api/kbx/external-data/status')return ok(config,{items:[{datasetId:'krx.daily',name:'KRX 일별 시세',provider:'KRX OPEN API',status:'healthy',lastSucceededAt:now(),freshness:{observedAt:now(),source:'KRX'}},{datasetId:'dart.corp',name:'DART 기업정보',provider:'OPENDART',status:'healthy',lastSucceededAt:now(),freshness:{observedAt:now(),source:'DART'}}]})
if(method==='POST'&&/^\/api\/kbx\/external-data\/[^/]+\/refresh$/.test(url))return ok(config,{accepted:true})
if(method==='GET'&&url==='/api/kbx/ux/metrics')return ok(config,{summary:{taskCompletionRate:0.96,manualInterventionRate:0.0318,validationFailureRate:0.012},screens:[]})
if(method==='GET'&&url==='/api/kbx/experiments')return ok(config,{items:[]})
if(method==='GET'&&url==='/api/kbx/experiments/assignments')return ok(config,{assignments:[]})
// Frontend demo mode never fabricates a successful write for an unknown operation.
// Returning a visible unsupported response makes missing FE/API coverage explicit during QA.
console.warn(`[KBX demo API] unsupported ${method} ${url}`)
return ok(config,{ type:'system', code:'DEMO_OPERATION_UNSUPPORTED', title:'이 화면의 Demo API가 아직 준비되지 않았습니다.', detail:`${method} ${url}` },501)
}
export function installFrontendDemoApi() {
kbxHttp.defaults.adapter = demoAdapter
}
@@ -0,0 +1,33 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { createRouter, createWebHistory } from 'vue-router'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import App from './App.vue'
import { appRoutes } from './router/appRoutes'
import { installKbx } from './app/installKbx'
import { installFrontendDemoApi } from './http/demo/installFrontendDemoApi'
import './styles/app.css'
if (import.meta.env.VITE_KBX_DEMO_MODE === 'true') installFrontendDemoApi()
const router = createRouter({
history: createWebHistory(),
routes: appRoutes,
scrollBehavior: () => ({ top: 0 }),
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(VueQueryPlugin, { queryClient })
app.use(PrimeVue, { theme: { preset: Aura, options: { darkModeSelector: '[data-kbx-theme=\"dark\"]' } }, ripple: false })
installKbx(app)
app.mount('#app')
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
import { useQuery } from '@tanstack/vue-query'
import {
KbxDataGrid,
@@ -19,6 +20,8 @@ const applied = ref<OperationsFilter>({ status: 'open', page: 1, pageSize: 200 }
const selectedRows = ref<KbxWorkItem[]>([])
const detail = ref<KbxWorkItem | null>(null)
const quickCode = ref<string | null>(null)
const problem=ref<KbxProblem|null>(null)
let retryAction:(()=>Promise<void>)|null=null
const pageContext=computed(()=>({label:'예외 업무 Queue',hint:'정상 건은 제외하고 해결이 필요한 업무만 표시합니다.',metrics:[{key:'total',label:'대상',value:query.data.value?.totalCount??0},{key:'selected',label:'선택',value:selectedRows.value.length}]}))
const query = useQuery({
@@ -29,6 +32,7 @@ const query = useQuery({
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
async function search() {
problem.value=null; retryAction=null
selectedRows.value = []
applied.value = {
module: asString(searchModel.value.module),
@@ -50,21 +54,30 @@ async function selectQuickFilter(code: string | null) {
await search()
}
async function runProblemAware(action:()=>Promise<void>){
problem.value=null; retryAction=null
try{await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return}throw error}
}
async function executeCommand(id: string) {
const ids = selectedRows.value.map(x => x.id)
if (id === 'search') return search()
if (id === 'claim' && ids.length) { await operationsApi.claim(ids); return query.refetch() }
if (id === 'claim' && ids.length) return runProblemAware(async()=>{await operationsApi.claim(ids);selectedRows.value=[];await query.refetch()})
}
async function executeAction(action: KbxWorkItemAction) {
if (!detail.value) return
if (action.kind === 'claim') await operationsApi.claim([detail.value.id])
if (action.kind === 'resolve') { const item=detail.value; await operationsApi.resolve([item.id]); kbxTelemetry.track('exception.resolved',{screenId:operationsQueueScreen.id,screenVersion:operationsQueueScreen.version,durationMs:Math.max(0,item.ageMinutes*60000),attributes:{exceptionType:item.code,resolutionType:'manual'}}) }
if (action.kind === 'retry') await operationsApi.retry(detail.value.id)
if (action.kind === 'navigate') window.dispatchEvent(new CustomEvent('kbx:navigate-source', { detail: detail.value }))
detail.value = null
await query.refetch()
const item=detail.value
if(action.kind==='navigate'){window.dispatchEvent(new CustomEvent('kbx:navigate-source',{detail:item}));detail.value=null;return}
await runProblemAware(async()=>{
if (action.kind === 'claim') await operationsApi.claim([item.id])
if (action.kind === 'resolve') { await operationsApi.resolve([item.id]); kbxTelemetry.track('exception.resolved',{screenId:operationsQueueScreen.id,screenVersion:operationsQueueScreen.version,durationMs:Math.max(0,item.ageMinutes*60000),attributes:{exceptionType:item.code,resolutionType:'manual'}}) }
if (action.kind === 'retry') await operationsApi.retry(item.id)
detail.value = null
await query.refetch()
})
}
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
function dismissProblem(){problem.value=null;retryAction=null}
</script>
<template>
@@ -74,7 +87,10 @@ async function executeAction(action: KbxWorkItemAction) {
:context="pageContext"
:content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.totalCount===0?'empty':'ready'"
:refreshing="query.isFetching.value&&Boolean(query.data.value)"
:problem="problem"
@command="executeCommand"
@retry-problem="retryProblem"
@dismiss-problem="dismissProblem"
>
<template #search>
<KbxSearchPanel v-model="searchModel" :fields="operationsSearchFields" @search="search" />
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
import { useQuery } from '@tanstack/vue-query'
import { KbxDataGrid, KbxReconcilePage, KbxSearchPanel, useKbxPageShortcuts, type KbxReconcileItem } from '@kbx/ui'
import { reconcileApi, type ReconcileFilter } from './reconcileApi'
@@ -8,12 +9,15 @@ import { reconcileColumns, reconcileScreen, reconcileSearchFields } from './reco
const searchModel = ref<Record<string, unknown>>({ status: 'mismatch' })
const applied = ref<ReconcileFilter>({ status: 'mismatch', page: 1, pageSize: 200 })
const selectedRows = ref<KbxReconcileItem[]>([])
const problem=ref<KbxProblem|null>(null)
let retryAction:(()=>Promise<void>)|null=null
const query = useQuery({ queryKey: computed(() => ['reconcile', applied.value]), queryFn: () => reconcileApi.search(applied.value) })
const pageContext=computed(()=>({label:'대사 결과',hint:'원천값을 직접 수정하지 않고 불일치는 해결 업무로 전환합니다.',metrics:[{key:'mismatch',label:'불일치',value:query.data.value?.summary.mismatchCount??0,tone:'danger' as const,emphasis:true},{key:'selected',label:'선택',value:selectedRows.value.length}]}))
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
function stringOrNull(value: unknown) { return value == null || value === '' ? null : String(value) }
async function search() {
problem.value=null; retryAction=null
selectedRows.value = []
applied.value = {
reconcileType: stringOrNull(searchModel.value.reconcileType),
@@ -24,17 +28,20 @@ async function search() {
}
await query.refetch()
}
async function runProblemAware(action:()=>Promise<void>){problem.value=null;retryAction=null;try{await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return}throw error}}
async function command(id: string) {
if (id === 'search') return search()
if (id === 'createException' && selectedRows.value.length) {
await reconcileApi.createExceptions(selectedRows.value.map(x => x.id))
return query.refetch()
const ids=selectedRows.value.map(x=>x.id)
return runProblemAware(async()=>{await reconcileApi.createExceptions(ids);selectedRows.value=[];await query.refetch()})
}
}
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
function dismissProblem(){problem.value=null;retryAction=null}
</script>
<template>
<KbxReconcilePage :screen="reconcileScreen" :summary="query.data.value?.summary" :selection-count="selectedRows.length" :context="pageContext" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.summary.totalCount===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
<KbxReconcilePage :screen="reconcileScreen" :summary="query.data.value?.summary" :selection-count="selectedRows.length" :context="pageContext" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.summary.totalCount===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" :problem="problem" comparison-title="OMS WMS 대사" comparison-description="Expected · Actual · Difference · Reason · Resolution 순서로 불일치 근거를 확인합니다." :comparison-count="query.data.value?.summary.totalCount ?? 0" @command="command" @retry-problem="retryProblem" @dismiss-problem="dismissProblem">
<template #search><KbxSearchPanel v-model="searchModel" :fields="reconcileSearchFields" @search="search" /></template>
<template #content>
<KbxDataGrid

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