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
@@ -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,10 @@
import '@kbx/ui/tokens.css'
import type { App } from 'vue'
import { kbxLookupRegistryKey } from '@kbx/ui'
import { lookupRegistry } from '../lookups/lookupRegistry'
import { installScreenRegistry } from '../registry/screens'
export function installKbx(app: App) {
app.provide(kbxLookupRegistryKey, lookupRegistry)
installScreenRegistry()
}
@@ -0,0 +1,6 @@
import * as signalR from '@microsoft/signalr'
export interface KbxExperimentChangedEvent { experimentId:string; screenId:string; changedAt:string }
export function createExperimentRolloutConnection(url='/hubs/kbx-experiments'){
const hub=new signalR.HubConnectionBuilder().withUrl(url).withAutomaticReconnect().build()
return {start:()=>hub.state===signalR.HubConnectionState.Disconnected?hub.start():Promise.resolve(),stop:()=>hub.stop(),onChanged:(callback:(event:KbxExperimentChangedEvent)=>void)=>hub.on('ExperimentChanged',callback)}
}
@@ -0,0 +1,22 @@
import { ref, onMounted, onUnmounted } from 'vue'
import type { KbxApiOperationId, KbxExperimentAssignmentsResponse, KbxExperimentId } from '@kbx/contracts'
import { kbxApi } from '../http/generated/kbxApiClient'
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
import { createExperimentRolloutConnection } from './createExperimentRolloutConnection'
const screenCache=new Map<string,Promise<KbxExperimentAssignmentsResponse>>()
const listeners=new Map<string,Set<()=>void>>()
let realtimeStarted=false
const realtime=createExperimentRolloutConnection()
realtime.onChanged(event=>{screenCache.delete(event.screenId);for(const refresh of listeners.get(event.screenId)??[])void refresh()})
async function ensureRealtime(){if(realtimeStarted)return;realtimeStarted=true;try{await realtime.start()}catch{realtimeStarted=false}}
function assignments(screenId:string,force=false){if(force)screenCache.delete(screenId);if(!screenCache.has(screenId))screenCache.set(screenId,kbxApi.request<KbxExperimentAssignmentsResponse>('common.experiments.assignments' as KbxApiOperationId,{query:{screenId}}));return screenCache.get(screenId)!}
export function useKbxExperiment(screenId:string,screenVersion:string,experimentId:KbxExperimentId,surface:string){
const variant=ref('control'),enrolled=ref(false);let lastExposure=''
const refresh=async()=>{
try{const result=await assignments(screenId,true);const assignment=result.assignments.find(x=>x.experimentId===experimentId);if(!assignment||!assignment.enrolled){enrolled.value=false;variant.value='control';lastExposure='';kbxTelemetry.clearExperiment(screenId);return}enrolled.value=true;variant.value=assignment.variant;kbxTelemetry.bindExperiment(screenId,assignment.experimentId,assignment.variant);const exposure=`${assignment.experimentId}:${assignment.variant}`;if(lastExposure!==exposure){lastExposure=exposure;kbxTelemetry.track('experiment.exposed',{screenId,screenVersion,attributes:{surface}})}}catch{/* experiment infrastructure must not block business UI */}
}
onMounted(async()=>{const set=listeners.get(screenId)??new Set<()=>void>();set.add(refresh);listeners.set(screenId,set);await ensureRealtime();await refresh()})
onUnmounted(()=>{listeners.get(screenId)?.delete(refresh);kbxTelemetry.clearExperiment(screenId)})
return {variant,enrolled,is:(value:string)=>variant.value===value,refresh}
}
@@ -0,0 +1,78 @@
import type { KbxHelpContent } from '@kbx/contracts'
export const helpRegistry: Record<string, KbxHelpContent> = {
'OMS-ORD-001': {
key: 'OMS-ORD-001', title: '주문관리', purpose: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
steps: ['조회조건을 입력합니다.', 'F3 또는 조회를 실행합니다.', '처리할 주문을 선택한 뒤 업무 명령을 실행합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
},
'OMS-ORD-002': {
key: 'OMS-ORD-002', title: '주문등록', purpose: '거래처 주문을 Header/Detail 방식으로 등록합니다.',
steps: ['거래처와 출고창고를 선택합니다.', '품목코드 또는 F2로 상품을 입력합니다.', '수량·단가를 확인하고 F8로 저장합니다.'],
shortcuts: [{ key: 'F2', description: '코드 조회' }, { key: 'F8', description: '저장' }],
},
'ERP-MST-ITEM-001': {
key: 'ERP-MST-ITEM-001', title: '품목관리', purpose: '품목 기준정보와 물류 속성을 관리합니다.',
steps: ['왼쪽 목록에서 품목을 선택하거나 신규를 누릅니다.', '기준정보와 물류정보를 입력합니다.', 'F8로 저장합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }, { key: 'F8', description: '저장' }, { key: 'F2', description: '창고 조회' }],
cautions: ['사용된 품목은 삭제보다 사용중지를 우선합니다.'],
},
'ERP-INV-001': {
key: 'ERP-INV-001', title: '재고현황', purpose: '품목별 현재고에서 창고·로케이션 근거와 재고이력까지 같은 조회 맥락에서 확인합니다.',
steps: ['조회조건을 입력하고 F3을 누릅니다.', '품목을 선택하면 오른쪽 위치재고와 아래 재고이력이 함께 바뀝니다.', '현재고 또는 가용수량을 눌러 산정 근거를 확인합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
cautions: ['화면 수량은 조회 Projection이며 실제 변경·확정 시 서버 재고 정책이 다시 검증합니다.'],
},
'ERP-PRICE-001': {
key: 'ERP-PRICE-001', title: '품목 단가 일괄등록', purpose: '품목별 적용일과 단가를 Excel 방식으로 빠르게 입력하고 일괄 저장합니다.',
steps: ['품목코드를 입력하거나 F2로 품목을 선택합니다.', '적용일과 단가를 입력하고 필요하면 Excel 영역을 붙여넣습니다.', '오류가 없으면 F8로 입력 건을 한 번에 저장합니다.'],
shortcuts: [{ key: 'F2', description: '품목 조회' }, { key: 'F8', description: '저장' }],
cautions: ['같은 품목과 적용일을 요청 안에서 중복 입력할 수 없습니다.', '서버가 품목 존재·권한·단가 범위를 다시 검증하고 저장 이력을 남깁니다.'],
},
'WMS-PICK-001': {
key: 'WMS-PICK-001', title: '출고 피킹', purpose: '위치와 상품을 스캔하여 출고 피킹을 수행합니다.',
cautions: ['서버 성공 응답 전에 같은 상품을 다시 스캔하지 마세요. 네트워크 오류는 동일 Idempotency Key로 자동 재시도합니다.'],
},
'OMS-ORD-003': {
key: 'OMS-ORD-003', title: '주문 Excel 업로드', purpose: '주문 데이터를 공통 매핑·검증 절차로 대량 등록하거나 수정합니다.',
steps: ['업로드 양식 또는 기존 Excel 파일을 선택합니다.', '자동 매핑 결과를 확인하고 필요한 열만 수정합니다.', '검증 결과에서 오류행을 확인한 뒤 정상 데이터만 반영합니다.'],
cautions: ['검증되지 않은 데이터는 반영할 수 없습니다.', '기존 주문은 수정 가능한 업무상태에서만 업데이트됩니다.'],
},
'COMMON-OPS-001': {
key: 'COMMON-OPS-001', title: '업무 예외 센터', purpose: '정상 업무가 아니라 사람이 판단하거나 복구해야 할 예외만 모아 처리합니다.',
steps: ['미처리 예외를 조회합니다.', '중요도와 경과시간을 기준으로 우선순위를 확인합니다.', '원 업무 보기 또는 등록된 안전한 재처리 기능으로 해결합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
cautions: ['예외 센터는 원천 업무 데이터의 Source of Truth가 아닙니다.', '원천 조건이 해결되지 않은 예외를 단순 숨김 처리하지 않습니다.'],
},
'COMMON-REC-001': {
key: 'COMMON-REC-001', title: '업무 데이터 대사', purpose: 'OMS·WMS·ERP 사이의 기대값과 실제값 차이를 시스템이 계산해 보여줍니다.',
steps: ['대사유형과 결과를 선택합니다.', '불일치 건의 기대값·실제값·원인을 확인합니다.', '조사가 필요한 건만 예외 센터에 등록합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
cautions: ['대사 화면에서 원천 데이터를 직접 수정하지 않습니다.'],
},
'OMS-CLM-001': {
key:'OMS-CLM-001', title:'반품·클레임 관리', purpose:'반품·교환·취소 요청을 기존 주문 맥락에서 조회하고 처리합니다.',
steps:['F3으로 미처리 요청을 조회합니다.','주문번호와 사유를 확인합니다.','허용된 상태전이만 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['완료된 클레임은 원 주문·물류 이력과 함께 추적합니다.'],
},
'ERP-PUR-001': {
key:'ERP-PUR-001', title:'구매등록', purpose:'거래처 구매를 Header/Detail 방식으로 입력하고 입고 흐름과 연결합니다.',
steps:['거래처와 입고창고를 선택합니다.','품목·수량·단가·납기일을 입력합니다.','F8 저장 후 구매확정합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['확정 후에는 입고 실적과 연결되므로 직접 수정 범위를 제한합니다.'],
},
'ERP-INV-MOVE-001': {
key:'ERP-INV-MOVE-001', title:'재고이동', purpose:'창고 간 재고이동을 등록하고 이동중·입고완료 상태를 추적합니다.',
steps:['출발·도착창고를 선택합니다.','품목과 이동수량을 입력합니다.','이동확정 후 WMS/입고 흐름에서 완료합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['출발창고와 도착창고는 같을 수 없고 가용재고보다 많이 이동할 수 없습니다.'],
},
'WMS-REC-001': { key:'WMS-REC-001', title:'입고 검수', purpose:'입고예정/ASN과 실물을 스캔해 수량·품목을 검수합니다.', cautions:['예정과 다른 상품·수량은 자동 확정하지 않고 예외로 남깁니다.'] },
'WMS-PUT-001': { key:'WMS-PUT-001', title:'입고 적치', purpose:'검수 완료 상품을 추천 로케이션에 적치합니다.', cautions:['추천 위치와 다른 위치는 정상 흐름에서 자동 승인하지 않습니다.'] },
'WMS-COUNT-001': { key:'WMS-COUNT-001', title:'재고실사', purpose:'위치·상품을 스캔해 실물수량을 기록하고 장부수량 차이를 예외로 보냅니다.', cautions:['실사 차이는 현장에서 장부수량을 직접 덮어쓰지 않습니다.'] },
'WMS-WORK-001': { key:'WMS-WORK-001', title:'물류 작업', purpose:'입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회하고 시작합니다.', steps:['F3으로 내 작업을 조회합니다.','SLA·진행률·예외를 확인합니다.','작업을 선택해 작업 시작을 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['현장 실행은 각 PDA 작업화면의 서버 검증과 Idempotency 규칙을 따릅니다.'] },
'COMMON-EXP-001': { key:'COMMON-EXP-001', title:'UX 실험·점진배포', purpose:'되돌릴 수 있는 UX 변경을 제한된 사용자에게 점진 배포하고 기존 UX 지표와 Guardrail을 비교합니다.', steps:['Draft 상태에서 변경 범위와 기준 지표를 검토합니다.','소규모 Rollout으로 시작하고 표본·P95·오류율을 확인합니다.','Guardrail 위반 시 즉시 롤백하고 원인을 기록합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['권한·민감정보·Domain Rule·Validation·Idempotency·WMS Scan 규칙은 실험 대상이 아닙니다.','운영 Threshold는 통계적 유의성을 의미하지 않습니다.'] },
'COMMON-UX-001': { key:'COMMON-UX-001', title:'UX 품질 지표', purpose:'Task 시간·의미 조작수·Manual Intervention Rate·검증/Import 실패율·예외 해결시간·AI 제안 수용률을 확인합니다.', steps:['기간을 선택하고 F3으로 조회합니다.','표본 수와 P50/P95를 함께 확인합니다.','지표 변화는 위험도·업무오류·현장 VOC와 같이 판단합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['DOM 전체 클릭이나 고객·주문 원문 데이터를 수집하지 않습니다.','Manual Intervention Rate를 낮추기 위해 위험 업무를 무리하게 자동화하지 않습니다.'] },
'COMMON-DATA-001': { key:'COMMON-DATA-001', title:'외부 데이터 상태', purpose:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.', steps:['F3으로 현재 상태를 조회합니다.','Stale/Expired 데이터셋의 최근 수신시각과 정책을 확인합니다.','허용된 데이터셋만 재수집합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['외부 데이터는 Domain 업무원장이 아닙니다.','KRX 승인 서비스는 서비스별 명시적 신선도 정책이 없으면 범용 TTL을 추정하지 않습니다.'] },
'COMMON-DS-001': { key:'COMMON-DS-001', title:'KBX 컴포넌트 카탈로그', purpose:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 독립적으로 재현합니다.', steps:['밀도를 선택합니다.','컴포넌트의 기본·오류·비활성 상태를 비교합니다.','Visual/Keyboard Regression 기준으로 사용합니다.'], cautions:['업무 사용자를 위한 메뉴가 아니라 KBX 개발·검증 화면입니다.'] },
}
@@ -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,38 @@
import { kbxApiCatalog, type KbxApiOperationDefinition, type KbxApiOperationId, type KbxApiRequestOptions } from '@kbx/contracts'
import { kbxHttp } from '../kbxHttpClient'
function buildPath(template: string, values: Record<string, string | number> = {}) {
return template.replace(/\{([^}:]+)(?::[^}]+)?\}/g, (_, name: string) => {
const key = Object.keys(values).find(x => x.toLowerCase() === name.toLowerCase())
if (!key) throw new Error(`API path parameter '${name}' is required.`)
return encodeURIComponent(String(values[key]))
})
}
export async function requestKbxApi<TResponse = unknown, TBody = unknown>(
operationId: KbxApiOperationId,
options: KbxApiRequestOptions<TBody> = {},
): Promise<TResponse> {
const operation: KbxApiOperationDefinition = kbxApiCatalog[operationId]
if (operation.idempotency === 'required' && !options.idempotencyKey) {
throw new Error(`Operation '${operationId}' requires a stable idempotency key.`)
}
const headers: Record<string, string> = { ...(options.headers ?? {}) }
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
const response = await kbxHttp.request<TResponse>({
method: operation.method,
url: buildPath(operation.path, options.path),
params: options.query,
data: options.body,
headers,
responseType: options.responseType ?? operation.responseType ?? 'json',
validateStatus: status => operation.successStatuses.some(value => value === status),
})
return response.data
}
export const kbxApi = {
request: requestKbxApi,
}
@@ -0,0 +1,63 @@
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
import { isKbxProblem, kbxUnexpectedProblem, type KbxProblem, type KbxSystemProblem } from '@kbx/contracts'
export const kbxHttp = axios.create({
timeout: 30_000,
headers: { 'X-KBX-Client': 'web' },
})
kbxHttp.interceptors.request.use(config => {
config.headers['X-Correlation-Id'] ??= crypto.randomUUID()
return config
})
kbxHttp.interceptors.response.use(
response => response,
(error: AxiosError) => {
const correlationId = String(error.config?.headers?.['X-Correlation-Id'] ?? crypto.randomUUID())
if (!error.response) {
const problem: KbxSystemProblem = {
type: 'system',
code: 'NETWORK_UNAVAILABLE',
title: '서버와 연결할 수 없습니다.',
detail: '입력한 내용은 유지됩니다. 네트워크 상태를 확인한 후 다시 시도하세요.',
correlationId,
retryable: true,
}
return Promise.reject(problem)
}
const data = error.response.data
if (isKbxProblem(data)) {
const problem: KbxProblem = {
...data,
correlationId: data.correlationId ?? correlationId,
}
return Promise.reject(problem)
}
if (error.response.status === 403) {
return Promise.reject({
type: 'permission', code: 'PERMISSION_DENIED', title: '이 작업을 수행할 권한이 없습니다.', correlationId,
} satisfies KbxProblem)
}
if (error.response.status === 404) {
return Promise.reject({
type: 'not-found', code: 'RESOURCE_NOT_FOUND', title: '요청한 데이터를 찾을 수 없습니다.', correlationId,
} satisfies KbxProblem)
}
return Promise.reject(kbxUnexpectedProblem(correlationId, `HTTP ${error.response.status}`, error.response.status >= 500))
},
)
export function withIdempotency(config: AxiosRequestConfig = {}, key = crypto.randomUUID()): AxiosRequestConfig {
return {
...config,
headers: {
...(config.headers ?? {}),
'Idempotency-Key': key,
},
}
}
@@ -0,0 +1,29 @@
import { ref } from 'vue'
import type { KbxConflictSnapshot, KbxProblem, KbxSystemProblem } from '@kbx/contracts'
export function useKbxProblemHandler() {
const conflict = ref<KbxConflictSnapshot | null>(null)
const systemProblem = ref<KbxSystemProblem | null>(null)
function handle(problem: KbxProblem) {
if (problem.type === 'conflict') {
conflict.value = {
code: problem.code,
title: problem.title,
currentVersion: problem.currentVersion,
correlationId: problem.correlationId,
}
return
}
if (problem.type === 'system') {
systemProblem.value = problem
}
}
function clear() {
conflict.value = null
systemProblem.value = null
}
return { conflict, systemProblem, handle, clear }
}
@@ -0,0 +1,22 @@
import * as signalR from '@microsoft/signalr'
import type { KbxImportProgressEvent } from '@kbx/contracts'
import type { KbxImportProgressConnection } from '@kbx/ui'
export function createImportProgressConnection(url = '/hubs/import-progress'): KbxImportProgressConnection {
const hub = new signalR.HubConnectionBuilder()
.withUrl(url)
.withAutomaticReconnect()
.build()
return {
start: () => hub.state === signalR.HubConnectionState.Disconnected ? hub.start() : Promise.resolve(),
stop: () => hub.stop(),
subscribe: sessionId => hub.invoke('Subscribe', sessionId),
unsubscribe: sessionId => hub.state === signalR.HubConnectionState.Connected
? hub.invoke('Unsubscribe', sessionId)
: Promise.resolve(),
onProgress(callback) {
hub.on('ImportProgress', (event: KbxImportProgressEvent) => callback(event))
},
}
}
@@ -0,0 +1,33 @@
import { isKbxProblem, type KbxLookupProvider } from '@kbx/contracts'
import { kbxApi } from '../http/generated/kbxApiClient'
const lookupOperations = {
customer: { search: 'lookup.customers.search', byId: 'lookup.customers.resolveById', byCode: 'lookup.customers.resolveByCode' },
item: { search: 'lookup.items.search', byId: 'lookup.items.resolveById', byCode: 'lookup.items.resolveByCode' },
warehouse: { search: 'lookup.warehouses.search', byId: 'lookup.warehouses.resolveById', byCode: 'lookup.warehouses.resolveByCode' },
} as const
type LookupEntity = keyof typeof lookupOperations
function provider(entity: LookupEntity): KbxLookupProvider<string> {
const ops = lookupOperations[entity]
return {
search(request) {
return kbxApi.request(ops.search, { query: request })
},
async resolveById(id) {
try { return await kbxApi.request(ops.byId, { path: { id } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
async resolveByCode(code) {
try { return await kbxApi.request(ops.byCode, { path: { code } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
} as KbxLookupProvider<string>
}
export const lookupRegistry = {
customer: provider('customer'),
item: provider('item'),
warehouse: provider('warehouse'),
}
@@ -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')
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
KbxAuditTrail, KbxBulkActionBar, KbxButton, KbxCommandBar, KbxDateField, KbxDateRange,
KbxInput, KbxFormGrid, KbxFormSection, KbxFormSpan, KbxMoneyField, KbxNumberField, KbxPageHeader, KbxProposalPanel, KbxQuantityField,
KbxSelect, KbxStatus, KbxToast, KbxDataState, KbxTabs, KbxDataGrid, KbxSearchPanel, kbxComponentCatalog, kbxTemplateManifest,
} from '@kbx/ui'
import type { KbxAiProposal, KbxAuditEntry, KbxCommandDefinition, KbxDensity, KbxGridColumn, KbxSearchField, KbxValidationError } from '@kbx/contracts'
const density=ref<KbxDensity>((new URLSearchParams(location.search).get('density') as KbxDensity) || 'compact')
const text=ref('대한상사')
const number=ref<number|null>(1200)
const money=ref<number|null>(1250000)
const qty=ref<number|null>(10)
const date=ref('2026-08-08')
const from=ref('2026-08-01')
const to=ref('2026-08-08')
const status=ref('READY')
const tab=ref('basic')
const search=ref<Record<string,unknown>>({keyword:'',status:null,mismatchOnly:false})
const rememberSearch=ref(true)
const searchFields:KbxSearchField[]=[
{key:'keyword',label:'통합검색',type:'text',width:'lg',placeholder:'주문번호/주문자'},
{key:'status',label:'상태',type:'select',options:[{value:'READY',label:'출고대기'},{value:'ERROR',label:'오류'}]},
{key:'mismatchOnly',label:'오류만 보기',type:'checkbox',primary:false,defaultValue:false},
]
type DemoRow={id:string;orderNo:string;customerName:string;orderQty:number;amount:number;status:string}
const gridRows=ref<DemoRow[]>([
{id:'1',orderNo:'TEST-ORD-001',customerName:'대한상사',orderQty:10,amount:128000,status:'출고대기'},
{id:'2',orderNo:'TEST-ORD-002',customerName:'서울유통',orderQty:8,amount:96000,status:'재고부족'},
{id:'3',orderNo:'TEST-ORD-003',customerName:'부산물류',orderQty:4,amount:52000,status:'신규'},
])
const gridColumns:KbxGridColumn<DemoRow>[]=[
{field:'orderNo',header:'주문번호',type:'link',width:150,pinned:'left'},
{field:'customerName',header:'거래처',width:160},
{field:'orderQty',header:'수량',type:'quantity',width:100,editable:true},
{field:'amount',header:'금액',type:'money',width:130},
{field:'status',header:'상태',type:'status',width:110},
]
const gridErrors:KbxValidationError[]=[{rowKey:'2',field:'orderQty',code:'INSUFFICIENT_STOCK',message:'출고 가능 수량은 6개입니다.'}]
const fastRows=ref<DemoRow[]>([
{id:'F1',orderNo:'TEST-FAST-001',customerName:'대한상사',orderQty:1,amount:12000,status:'작성'},
{id:'F2',orderNo:'TEST-FAST-002',customerName:'대한상사',orderQty:2,amount:24000,status:'작성'},
{id:'F3',orderNo:'TEST-FAST-003',customerName:'대한상사',orderQty:3,amount:36000,status:'작성'},
])
let fastSeq=4
function addFastRow(){fastRows.value.push({id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`,customerName:'',orderQty:1,amount:0,status:'작성'})}
function duplicateFastRows(rows:DemoRow[]){for(const row of rows)fastRows.value.push({...row,id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`})}
const commands:KbxCommandDefinition[]=[
{id:'search',label:'조회',group:'query',shortcut:'F3'},
{id:'save',label:'저장',group:'edit',shortcut:'F8',variant:'primary'},
{id:'ship',label:'출고지시',group:'workflow',requiresSelection:true},
]
const proposal:KbxAiProposal={id:'catalog-proposal',type:'warehouse-change',title:'출고창고 변경 제안',explanation:'가용재고가 있는 창고를 제안한 예시입니다.',capability:'draft',confidence:.87,proposedChanges:[{field:'warehouseId',label:'출고창고',before:'서울센터',after:'인천센터'}],evidence:[{label:'재고 Read Model',sourceType:'domain'}]}
const audit:KbxAuditEntry[]=[{id:'1',occurredAt:'2026-08-08 15:32',actor:{type:'user',displayName:'홍길동'},action:'수량 변경',changes:[{field:'qty',label:'출고수량',before:10,after:8}],reason:'고객 요청'}]
const groupCounts=computed(()=>Object.fromEntries([...new Set(kbxComponentCatalog.map(x=>x.group))].map(g=>[g,kbxComponentCatalog.filter(x=>x.group===g).length])))
</script>
<template>
<section class="catalog" :data-kbx-density="density">
<KbxPageHeader title="KBX 컴포넌트 카탈로그" breadcrumb="COMMON > KBX" description="Default / Changed / Warning / AI Suggested / Readonly / Disabled / Error / Loading / Empty / Keyboard 상태를 독립적으로 재현합니다." />
<nav class="density" aria-label="밀도 선택"><strong>Density</strong><button v-for="d in ['compact','comfortable','touch']" :key="d" type="button" :aria-pressed="density===d" @click="density=d as KbxDensity">{{d}}</button></nav>
<section class="catalog__summary" aria-label="카탈로그 요약"><span v-for="(count,group) in groupCounts" :key="group"><strong>{{group}}</strong> {{count}}</span></section>
<section class="catalog__block"><h2>입력</h2><div class="catalog__grid"><div><h3>KbxInput 상태</h3><KbxInput v-model="text" label="거래처명" required/><KbxInput model-value="변경한 값" label="Changed" state="changed" help-text="저장 전 변경값입니다."/><KbxInput model-value="확인 필요" label="Warning" state="warning" warning="업무 규칙을 다시 확인하세요."/><KbxInput model-value="인천센터" label="AI Suggested" state="ai-suggested" help-text="AI 추천값이며 아직 반영되지 않았습니다."/><KbxInput model-value="" label="오류" error="거래처명을 입력하세요."/><KbxInput model-value="읽기전용" label="Readonly" readonly/></div><div><h3>Number / Money / Quantity</h3><KbxNumberField v-model="number" label="일반 숫자" state="changed"/><KbxMoneyField v-model="money" label="단가"/><KbxQuantityField v-model="qty" label="출고수량" :available-quantity="8"/></div><div><h3>Date / Range / Select</h3><KbxDateField v-model="date" label="주문일"/><KbxDateRange v-model:from="from" v-model:to="to" label="주문기간"/><KbxSelect v-model="status" label="상태" :options="[{value:'READY',label:'준비'},{value:'DONE',label:'완료'}]"/><KbxTabs v-model="tab" :items="[{key:'basic',label:'기본'},{key:'audit',label:'변경이력',badge:3}]"/></div></div></section>
<section class="catalog__block"><h2>Form Composition</h2><p class="catalog__hint">T02/T03 Desktop Form은 KbxFormSection + KbxFormGrid의 2-column을 기본으로 하고 주소·비고처럼 관계상 넓게 보여야 하는 항목만 명시적으로 full span 합니다.</p><KbxFormSection title="기본정보" description="Label 폭과 행/열 간격은 Design Token에서 통제합니다."><KbxFormGrid><KbxInput v-model="text" label="품목명"/><KbxDateField v-model="date" label="적용일"/><KbxFormSpan span="full"><KbxInput model-value="서울특별시 강남구 ..." label="주소" readonly/></KbxFormSpan></KbxFormGrid></KbxFormSection></section>
<section class="catalog__block"><h2>Search / Grid Runtime</h2><KbxSearchPanel v-model="search" v-model:remember-checked="rememberSearch" :fields="searchFields" remember saved-search/><div class="catalog__grid-demo"><KbxDataGrid :rows="gridRows" :columns="gridColumns" row-key="id" selection="multiple" editable :errors="gridErrors" :changed-cells="[{rowKey:'1',field:'orderQty'}]" :total-count="82415" allow-all-filtered-selection personalization exportable :summary="[{key:'count',label:'조회',kind:'count'},{key:'amount',label:'금액 합계',kind:'sum',field:'amount'}]"/></div></section>
<section class="catalog__block"><h2>Fast Entry Grid</h2><p class="catalog__hint"> 추가/복제, 선택 기준 Fill Down, Excel 다중 붙여넣기 정규화, 오류 탐색을 동일 Grid 계약으로 재현합니다.</p><div class="catalog__grid-demo"><KbxDataGrid :rows="fastRows" :columns="gridColumns" row-key="id" selection="multiple" editable :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="addFastRow" @row-duplicate-requested="duplicateFastRows"/></div></section>
<section class="catalog__block"><h2>Loading / Empty / Error</h2><div class="catalog__grid"><KbxDataState state="loading"/><KbxDataState state="empty" title="조회된 주문이 없습니다." detail="조회조건을 변경해 보세요."/><KbxDataState state="error" detail="네트워크 연결을 확인한 후 다시 시도하세요." action-label="다시 조회"/></div></section>
<section class="catalog__block"><h2>Command / 상태</h2><KbxCommandBar :commands="commands" :selection-count="0"/><KbxBulkActionBar :selection-count="17" :actions="[{id:'ship',label:'출고지시',group:'workflow'},{id:'hold',label:'보류',group:'workflow'}]"/><div class="status-row"><KbxStatus label="작성" semantic="draft"/><KbxStatus label="진행" semantic="processing"/><KbxStatus label="완료" semantic="completed"/><KbxStatus label="재고부족" semantic="warning"/><KbxStatus label="오류" semantic="error"/></div><KbxToast message="저장했습니다."/><KbxToast message="일부 항목을 확인하세요." kind="warning"/></section>
<section class="catalog__block"><h2>AI / Audit</h2><div class="catalog__grid"><KbxProposalPanel :proposal="proposal"/><KbxAuditTrail :entries="audit"/></div></section>
<section class="catalog__block"><h2>T01~T09 Template Contract</h2><p class="catalog__hint">Screen Type을 선택하면 필수 Surface·Core Component·Keyboard·완료 점검까지 함께 결정됩니다. 화면별 임의 Layout은 예외입니다.</p><table><thead><tr><th>Type</th><th>Component</th><th>필수 Surface</th><th>Core Components</th><th>완료 점검</th><th>Reference</th></tr></thead><tbody><tr v-for="template in kbxTemplateManifest" :key="template.code"><td><strong>{{template.code}}</strong><br><small>{{template.type}}</small></td><td>{{template.component}}<br><small>{{template.keyboard.join(' · ')}}</small></td><td>{{template.requiredSurfaces.join(' · ')}}</td><td>{{template.coreComponents.join(' · ')}}</td><td>{{template.completionChecks.join(' · ')}}</td><td>{{template.referenceScreens.join(', ')}}</td></tr></tbody></table></section>
<section class="catalog__block"><h2>검증 계약</h2><table><thead><tr><th>Component</th><th>Group</th><th>States</th><th>Keyboard</th><th>Focus</th></tr></thead><tbody><tr v-for="entry in kbxComponentCatalog" :key="entry.component"><td>{{entry.component}}</td><td>{{entry.group}}</td><td>{{entry.scenarios.map(x=>x.label).join(', ')}}</td><td>{{entry.accessibility.keyboard?'✓':'-'}}</td><td>{{entry.accessibility.focusVisible?'✓':'-'}}</td></tr></tbody></table></section>
</section>
</template>
<style scoped>
.catalog{display:grid;gap:var(--kbx-space-4);padding:var(--kbx-space-4);background:var(--kbx-color-surface);color:var(--kbx-color-text)}.density,.catalog__summary,.status-row{display:flex;align-items:center;gap:var(--kbx-space-2);flex-wrap:wrap}.density button{height:var(--kbx-control-height);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm);padding:0 10px}.density button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}.catalog__summary span{padding:6px 8px;background:var(--kbx-color-surface-muted);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}.catalog__block{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-3);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}h2,h3{margin:0}.catalog__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--kbx-space-4)}.catalog__grid>div{display:grid;gap:var(--kbx-space-2)}.catalog__grid-demo{height:360px}.catalog__hint{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}th,td{padding:7px 8px;border-bottom:1px solid var(--kbx-color-border);text-align:left}th{background:var(--kbx-color-surface-muted);font-weight:600}
</style>
@@ -0,0 +1,13 @@
import { defineKbxScreen } from '@kbx/ui'
export const designSystemCatalogScreen = defineKbxScreen({
id:'COMMON-DS-001',
version:'1.0.0',
module:'COMMON',
type:'list', templateCode:'T01',
title:'KBX 컴포넌트 카탈로그',
description:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 재현합니다.',
permissions:['kbx.design.read'],
helpKey:'COMMON-DS-001',
telemetry:{enabled:true},
})
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxListPage, KbxDataGrid, KbxDialog, KbxInput, KbxNumberField, KbxSelect, KbxButton } from '@kbx/ui'
import type { KbxExperimentOverviewRow, KbxGridColumn } from '@kbx/contracts'
import { experimentsScreen } from './experiments.definition'
import { experimentApi } from './experimentApi'
const selected=ref<KbxExperimentOverviewRow[]>([])
const query=useQuery({queryKey:['kbx','experiments'],queryFn:experimentApi.overview})
const rows=computed(()=>query.data.value?.items??[])
const columns:KbxGridColumn<KbxExperimentOverviewRow>[]=[
{field:'experimentId',header:'실험',width:280},{field:'screenId',header:'화면',width:140},{field:'state',header:'상태',type:'status',width:110},{field:'rolloutPercent',header:'배포 %',type:'integer',width:90},{field:'decision',header:'판정',width:150},{field:'updatedAt',header:'변경일시',type:'datetime',width:170},
]
const dialogOpen=ref(false),mode=ref<'rollout'|'rollback'>('rollout'),reason=ref(''),rolloutPercent=ref<number|null>(0),rolloutState=ref<string|null>('running'),error=ref('')
function openManage(next:'rollout'|'rollback'){const item=selected.value[0];if(!item)return;mode.value=next;reason.value='';error.value='';rolloutPercent.value=item.rolloutPercent;rolloutState.value=item.state==='paused'?'paused':'running';dialogOpen.value=true}
async function apply(){const item=selected.value[0];if(!item)return;if(!reason.value.trim()){error.value='변경 사유를 입력하세요.';return}if(mode.value==='rollout'){const percent=rolloutPercent.value??0;if(percent<0||percent>100){error.value='배포 비율은 0~100 사이여야 합니다.';return}await experimentApi.rollout(item.experimentId,{rolloutPercent:percent,state:(rolloutState.value==='paused'?'paused':'running'),reason:reason.value.trim()})}else await experimentApi.rollback(item.experimentId,{reason:reason.value.trim()});dialogOpen.value=false;selected.value=[];await query.refetch()}
async function command(id:string){if(id==='search')await query.refetch();if(id==='rollout')openManage('rollout');if(id==='rollback')openManage('rollback')}
</script>
<template><KbxListPage :screen="experimentsScreen" :selection-count="selected.length" @command="command">
<template #quick-filter><div class="notice"><strong>안전 범위</strong> Layout·정보강조·기본필터·Navigation·Copy만 실험합니다. 권한·민감정보·Domain Rule·Validation·WMS Scan Rule은 실험하지 않습니다.</div></template>
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="experimentId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
<template #summary>판정은 운영 Threshold 기반입니다. 통계적 유의성을 자동 주장하지 않으며, Guardrail 위반은 즉시 롤백 대상으로 봅니다.</template>
</KbxListPage>
<KbxDialog :open="dialogOpen" :title="mode==='rollback'?'UX 변경 즉시 롤백':'점진배포 설정'" size="md" @update:open="dialogOpen=$event">
<div class="dialog-fields">
<template v-if="mode==='rollout'">
<KbxNumberField v-model="rolloutPercent" label="배포 비율" suffix="%" :min="0" :max="100" />
<KbxSelect v-model="rolloutState" label="상태" :options="[{value:'running',label:'실행'},{value:'paused',label:'일시중지'}]" />
</template>
<KbxInput v-model="reason" label="변경 사유" required :maxlength="500" :error="error" placeholder="지표·장애·VOC 변경 근거를 입력하세요." @enter="apply" />
<p v-if="mode==='rollback'">롤백하면 Kill Switch가 활성화되고 모든 사용자는 즉시 Control UX로 돌아갑니다. 기존 Assignment와 Audit 이력은 유지됩니다.</p>
</div>
<template #footer><KbxButton label="취소" @click="dialogOpen=false"/><KbxButton :label="mode==='rollback'?'즉시 롤백':'적용'" :variant="mode==='rollback'?'danger':'primary'" @click="apply"/></template>
</KbxDialog>
</template>
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.dialog-fields{display:grid;gap:var(--kbx-space-3)}.dialog-fields p{color:var(--kbx-color-text-muted)}</style>
@@ -0,0 +1,7 @@
import type { KbxExperimentOverviewResponse, KbxExperimentRolloutRequest, KbxExperimentRollbackRequest } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export const experimentApi={
overview:()=>kbxApi.request<KbxExperimentOverviewResponse>('common.experiments.overview'),
rollout:(experimentId:string,body:KbxExperimentRolloutRequest)=>kbxApi.request('common.experiments.rollout',{path:{experimentId},body}),
rollback:(experimentId:string,body:KbxExperimentRollbackRequest)=>kbxApi.request('common.experiments.rollback',{path:{experimentId},body}),
}
@@ -0,0 +1,7 @@
import { defineKbxScreen } from '@kbx/ui'
export const experimentsScreen=defineKbxScreen({
id:'COMMON-EXP-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 실험·점진배포',
description:'안전한 UX 변경만 점진 배포하고 가드레일 지표와 즉시 롤백 상태를 확인합니다.',
permissions:['common.experiment.read'],helpKey:'COMMON-EXP-001',telemetry:{enabled:true},
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'},{id:'rollout',label:'배포 설정',group:'workflow',permission:'common.experiment.manage',requiresSelection:true,minSelection:1,maxSelection:1},{id:'rollback',label:'즉시 롤백',group:'workflow',variant:'danger',permission:'common.experiment.manage',requiresSelection:true,minSelection:1,maxSelection:1}],
})
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxListPage, KbxDataGrid } from '@kbx/ui'
import type { KbxExternalDataStatusRow, KbxGridColumn } from '@kbx/contracts'
import { externalDataScreen } from './external-data.definition'
import { externalDataApi } from './externalDataApi'
const selected=ref<KbxExternalDataStatusRow[]>([])
const query=useQuery({queryKey:['kbx','external-data','status'],queryFn:externalDataApi.status})
const rows=computed(()=>query.data.value?.items??[])
const columns:KbxGridColumn<KbxExternalDataStatusRow>[]=[
{field:'sourceLabel',header:'출처',width:110,pinned:'left'},
{field:'datasetId',header:'데이터셋',width:320},
{field:'state',header:'신선도',type:'status',width:130},
{field:'cacheEntries',header:'캐시',type:'integer',width:90},
{field:'staleEntries',header:'Stale',type:'integer',width:90},
{field:'unavailableEntries',header:'사용불가',type:'integer',width:100},
{field:'lastReceivedAt',header:'최근 수신',type:'datetime',width:170},
{field:'oldestFreshUntil',header:'최초 만료',type:'datetime',width:170},
]
async function command(id:string){if(id==='search')await query.refetch();if(id==='refresh'){const row=selected.value[0];if(!row)return;await externalDataApi.refresh(row.datasetId);selected.value=[];await query.refetch()}}
</script>
<template><KbxListPage :screen="externalDataScreen" :selection-count="selected.length" @command="command">
<template #quick-filter><div class="notice"><strong>외부 데이터는 업무 원장이 아닙니다.</strong> 화면에는 정규화된 Projection과 출처·신선도를 함께 표시하고, 만료된 데이터를 최신값처럼 사용하지 않습니다.</div></template>
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="datasetId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
<template #summary>KRX의 승인 서비스별 TTL은 별도 정책 없이는 재수집할 없습니다. OPENDART/KIS의 캐시 시간은 KBX 운영정책이며 공급자 공식 호출한도와 구분합니다.</template>
</KbxListPage></template>
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
@@ -0,0 +1,10 @@
import { defineKbxScreen } from '@kbx/ui'
export const externalDataScreen=defineKbxScreen({
id:'COMMON-DATA-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'외부 데이터 상태',
description:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.',
permissions:['common.external-data.read'],helpKey:'COMMON-DATA-001',telemetry:{enabled:true},
commands:[
{id:'search',label:'조회',group:'query',shortcut:'F3'},
{id:'refresh',label:'재수집',group:'workflow',permission:'common.external-data.refresh',requiresSelection:true,minSelection:1,maxSelection:1},
],
})
@@ -0,0 +1,6 @@
import type { KbxExternalDataStatusResponse } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export const externalDataApi={
status:()=>kbxApi.request<KbxExternalDataStatusResponse>('common.externalData.status'),
refresh:(datasetId:string)=>kbxApi.request('common.externalData.refresh',{path:{datasetId}}),
}
@@ -0,0 +1 @@
<template><div aria-hidden="true" /></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { KbxRouteNotFound } from '@kbx/ui'
import { useRouter } from 'vue-router'
import { useKbxWorkspaceStore } from '../../../shell/workspaceStore'
const router=useRouter();const store=useKbxWorkspaceStore()
function home(){void router.replace('/home')}
</script>
<template><KbxRouteNotFound @home="home" @menu-search="store.menuSearchOpen=true"/></template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
import { useQuery } from '@tanstack/vue-query'
import {
KbxDataGrid,
KbxExceptionCenter,
KbxSearchPanel,
KbxWorkQueuePage,
useKbxPageShortcuts,
type KbxWorkItem,
type KbxWorkItemAction,
} from '@kbx/ui'
import { operationsApi, type OperationsFilter } from './operationsApi'
import { kbxTelemetry } from '../../../telemetry/kbxTelemetryClient'
import { operationsColumns, operationsQueueScreen, operationsSearchFields } from './operations.definition'
const searchModel = ref<Record<string, unknown>>({ status: 'open' })
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({
queryKey: computed(() => ['operations', 'work-items', applied.value]),
queryFn: () => operationsApi.search(applied.value),
})
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
async function search() {
problem.value=null; retryAction=null
selectedRows.value = []
applied.value = {
module: asString(searchModel.value.module),
severity: asString(searchModel.value.severity),
status: asString(searchModel.value.status),
owner: asString(searchModel.value.owner),
keyword: asString(searchModel.value.keyword),
code: quickCode.value,
page: 1,
pageSize: 200,
}
await query.refetch()
}
function asString(value: unknown) { return value == null || value === '' ? null : String(value) }
async function selectQuickFilter(code: string | null) {
quickCode.value = code
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) return runProblemAware(async()=>{await operationsApi.claim(ids);selectedRows.value=[];await query.refetch()})
}
async function executeAction(action: KbxWorkItemAction) {
if (!detail.value) return
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>
<KbxWorkQueuePage
:screen="operationsQueueScreen"
:selection-count="selectedRows.length"
: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" />
</template>
<template #queue-summary>
<span class="queue-hint">정상 건은 표시하지 않습니다. 중요도와 경과시간이 높은 예외부터 처리합니다.</span>
</template>
<template #content>
<KbxExceptionCenter
:counters="query.data.value?.counters ?? []"
:active-key="quickCode"
:selected-item="detail"
@filter="selectQuickFilter"
@close-detail="detail = null"
@action="executeAction"
>
<KbxDataGrid
:rows="query.data.value?.items ?? []"
:columns="operationsColumns"
row-key="id"
selection="multiple"
:loading="query.isFetching.value"
@selection-changed="selectedRows = $event"
@row-double-clicked="detail = $event"
/>
</KbxExceptionCenter>
</template>
<template #footer>
전체 {{ query.data.value?.totalCount ?? 0 }} · 선택 {{ selectedRows.length }}
</template>
</KbxWorkQueuePage>
</template>
@@ -0,0 +1,46 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkItem } from '@kbx/ui'
export const operationsQueueScreen = defineKbxScreen({
id: 'COMMON-OPS-001',
version: '1.0.0',
module: 'COMMON',
type: 'queue', templateCode:'T06',
title: '업무 예외 센터',
description: '정상 건이 아니라 사람이 판단하거나 복구해야 할 업무만 모아 처리합니다.',
permissions: ['common.operations.read'],
helpKey: 'COMMON-OPS-001',
telemetry: { enabled: true },
commands: [
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
{ id: 'claim', label: '내가 처리', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.claim' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
})
export const operationsSearchFields: KbxSearchField[] = [
{ key: 'module', label: '모듈', type: 'select', primary: true, options: [
{ value: 'OMS', label: 'OMS' }, { value: 'WMS', label: 'WMS' }, { value: 'ERP', label: 'ERP' },
] },
{ key: 'severity', label: '심각도', type: 'select', primary: true, options: [
{ value: 'critical', label: '긴급' }, { value: 'warning', label: '주의' }, { value: 'info', label: '정보' },
] },
{ key: 'status', label: '상태', type: 'select', primary: true, options: [
{ value: 'open', label: '미처리' }, { value: 'claimed', label: '처리중' }, { value: 'resolved', label: '해결' },
] },
{ key: 'owner', label: '담당', type: 'select', primary: false, options: [
{ value: 'mine', label: '내 업무' }, { value: 'unassigned', label: '미지정' },
] },
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 작업번호, 오류명' },
]
export const operationsColumns: KbxGridColumn<KbxWorkItem>[] = [
{ field: 'severity', header: '중요도', type: 'status', width: 82, pinned: 'left' },
{ field: 'sourceModule', header: '모듈', width: 76 },
{ field: 'title', header: '확인할 업무', width: 250, pinned: 'left' },
{ field: 'referenceNo', header: '대상번호', type: 'code', width: 150 },
{ field: 'detail', header: '원인/안내', width: 300 },
{ field: 'ownerName', header: '담당자', width: 100 },
{ field: 'occurredAt', header: '발생시각', type: 'datetime', width: 160 },
{ field: 'ageMinutes', header: '경과(분)', type: 'integer', width: 90 },
{ field: 'status', header: '처리상태', type: 'status', width: 100 },
]
@@ -0,0 +1,28 @@
import type { KbxWorkQueueResult } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface OperationsFilter {
module?: string | null
severity?: string | null
status?: string | null
owner?: string | null
code?: string | null
keyword?: string | null
page: number
pageSize: number
}
export const operationsApi = {
search(filter: OperationsFilter) {
return kbxApi.request<KbxWorkQueueResult>('common.operations.search', { query: filter })
},
claim(ids: string[]) {
return kbxApi.request<unknown, { ids: string[] }>('common.operations.claim', { body: { ids } })
},
resolve(ids: string[], reason = '사용자 확인 완료') {
return kbxApi.request<unknown, { ids: string[]; reason: string }>('common.operations.resolve', { body: { ids, reason } })
},
retry(id: string) {
return kbxApi.request<unknown>('common.operations.retry', { path: { id } })
},
}
@@ -0,0 +1,3 @@
export const operationsRoutes = [
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
]
@@ -0,0 +1,58 @@
<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'
import { reconcileColumns, reconcileScreen, reconcileSearchFields } from './reconcile.definition'
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),
status: stringOrNull(searchModel.value.status),
keyword: stringOrNull(searchModel.value.keyword),
page: 1,
pageSize: 200,
}
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) {
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)" :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
:rows="query.data.value?.items ?? []"
:columns="reconcileColumns"
row-key="id"
selection="multiple"
:loading="query.isFetching.value"
@selection-changed="selectedRows = $event"
/>
</template>
<template #footer>불일치 건은 원천 데이터를 직접 수정하지 않고 예외 업무로 전환해 담당자가 원인을 확인합니다.</template>
</KbxReconcilePage>
</template>
@@ -0,0 +1,42 @@
import { defineKbxScreen, type KbxGridColumn, type KbxReconcileItem, type KbxSearchField } from '@kbx/ui'
export const reconcileScreen = defineKbxScreen({
id: 'COMMON-REC-001',
version: '1.0.0',
module: 'COMMON',
type: 'reconcile', templateCode:'T07',
title: '업무 데이터 대사',
description: '원천·대상 시스템의 기대값과 실제값을 비교하고 불일치 원인을 추적합니다.',
permissions: ['common.reconcile.read'],
helpKey: 'COMMON-REC-001',
telemetry: { enabled: true },
commands: [
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
{ id: 'createException', label: '예외 등록', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.create' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
})
export const reconcileSearchFields: KbxSearchField[] = [
{ key: 'reconcileType', label: '대사유형', type: 'select', primary: true, options: [
{ value: 'OMS_WMS_OUTBOUND_QTY', label: 'OMS↔WMS 출고수량' },
{ value: 'ORDER_PICK_QTY', label: '주문↔피킹수량' },
{ value: 'INVENTORY_SNAPSHOT', label: '재고 스냅샷' },
] },
{ key: 'status', label: '결과', type: 'select', primary: true, options: [
{ value: 'mismatch', label: '불일치' }, { value: 'pending', label: '확인중' }, { value: 'matched', label: '정상' }, { value: 'resolved', label: '해결' },
] },
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 품목, 참조번호' },
]
export const reconcileColumns: KbxGridColumn<KbxReconcileItem>[] = [
{ field: 'referenceNo', header: '참조번호', type: 'code', width: 160, pinned: 'left' },
{ field: 'sourceLabel', header: '기준', width: 130 },
{ field: 'expectedValue', header: '기대값', width: 130 },
{ field: 'targetLabel', header: '비교대상', width: 130 },
{ field: 'actualValue', header: '실제값', width: 130 },
{ field: 'differenceValue', header: '차이', width: 110 },
{ field: 'reasonText', header: '원인', width: 280 },
{ field: 'status', header: '상태', type: 'status', width: 100 },
{ field: 'occurredAt', header: '확인시각', type: 'datetime', width: 160 },
]
@@ -0,0 +1,19 @@
import type { KbxReconcileResult } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface ReconcileFilter {
reconcileType?: string | null
status?: string | null
keyword?: string | null
page: number
pageSize: number
}
export const reconcileApi = {
search(filter: ReconcileFilter) {
return kbxApi.request<KbxReconcileResult>('common.reconcile.search', { query: filter })
},
createExceptions(ids: string[]) {
return kbxApi.request<unknown, { ids: string[] }>('common.reconcile.createExceptions', { body: { ids } })
},
}
@@ -0,0 +1,3 @@
export const reconcileRoutes = [
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
]
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxListPage, KbxSearchPanel, KbxDataGrid } from '@kbx/ui'
import type { KbxGridColumn, KbxSearchField, KbxUxMetricRow } from '@kbx/contracts'
import { uxMetricsScreen } from './ux-metrics.definition'
import { uxMetricsApi } from './uxMetricsApi'
const today=new Date();const fromDate=new Date(today);fromDate.setDate(today.getDate()-6)
const iso=(d:Date)=>d.toISOString().slice(0,10)
const search=reactive({from:iso(fromDate),to:iso(today),screenId:''})
const fields:KbxSearchField[]=[{key:'from',label:'시작일',type:'date',primary:true},{key:'to',label:'종료일',type:'date',primary:true},{key:'screenId',label:'화면코드',type:'text',primary:true,width:'md'}]
const query=useQuery({queryKey:['kbx','ux-metrics',search],queryFn:()=>uxMetricsApi.get({...search,screenId:search.screenId||undefined}),enabled:false})
const columns:KbxGridColumn<KbxUxMetricRow>[]=[
{field:'label',header:'지표',width:240},{field:'value',header:'현재값',type:'decimal',width:140},{field:'unit',header:'단위',width:100},{field:'sampleCount',header:'표본',type:'integer',width:110},{field:'p50',header:'P50',type:'decimal',width:110},{field:'p95',header:'P95',type:'decimal',width:110},
]
async function command(id:string){if(id==='search')await query.refetch()}
</script>
<template>
<KbxListPage :screen="uxMetricsScreen" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.metrics.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="search" :fields="fields" @search="query.refetch()" /></template>
<template #quick-filter>
<div class="notice"><strong>해석 기준</strong> DOM 전체 클릭이나 검색어 원문을 수집하지 않습니다. 낮은 수치 자체보다 업무 위험·오류 감소와 함께 판단합니다.</div>
</template>
<template #content><KbxDataGrid :rows="query.data.value?.metrics ?? []" :columns="columns" row-key="metricKey" :loading="query.isFetching.value" /></template>
<template #summary>{{ search.from }} ~ {{ search.to }} · Manual Intervention Rate를 자동화 개선의 핵심 추세로 봅니다.</template>
</KbxListPage>
</template>
<style scoped>.notice{padding:8px 12px;border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:13px}</style>
@@ -0,0 +1,7 @@
import { defineKbxScreen } from '@kbx/ui'
export const uxMetricsScreen=defineKbxScreen({
id:'COMMON-UX-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 품질 지표',
description:'KBX 의미 이벤트와 업무 결과를 이용해 수작업 개입·업무시간·오류율을 검토합니다.',
permissions:['common.ux.read'],helpKey:'COMMON-UX-001',telemetry:{enabled:true},
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'}],
})
@@ -0,0 +1,5 @@
import type { KbxUxMetricsResponse } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export const uxMetricsApi={
get:(query:{from:string;to:string;screenId?:string})=>kbxApi.request<KbxUxMetricsResponse>('common.uxTelemetry.metrics',{query}),
}
@@ -0,0 +1,9 @@
<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}종`}]" header-title="이동 정보" header-description="출발/도착 창고와 이동일을 먼저 확인합니다." detail-title="이동 품목" :detail-count="lines.length" detail-description="가용재고와 이동수량을 비교하며 입력합니다." @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>
@@ -0,0 +1,8 @@
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
export interface InventoryMoveLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number; moveQty:number; lotNo:string; remark:string }
export const inventoryMoveScreen=defineKbxScreen({ id:'ERP-INV-MOVE-001',version:'1.0.0',module:'ERP',type:'transaction', templateCode:'T03',title:'재고이동',helpKey:'ERP-INV-MOVE-001',permissions:['erp.inventory.move.read'],telemetry:{enabled:true}, description:'창고 간 재고이동을 등록하고 출고·입고 상태를 추적합니다.', commands:[
{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.inventory.move.write'},{id:'confirm',label:'이동확정',group:'workflow',variant:'primary',permission:'erp.inventory.move.confirm'},{id:'excel',label:'엑셀',group:'output'}] })
export const inventoryMoveColumns:KbxGridColumn<InventoryMoveLine>[]=[
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'},{field:'itemName',header:'품목명',width:200},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'moveQty',header:'이동수량',type:'quantity',width:100,editable:true},{field:'lotNo',header:'LOT',type:'code',width:120,editable:true},{field:'remark',header:'비고',width:220,editable:true}]
export const inventoryMoveWorkflow:KbxWorkflowDefinition={id:'erp.inventory-move',version:'1.0.0',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'CONFIRMED',label:'확정',semantic:'pending'},{value:'IN_TRANSIT',label:'이동중',semantic:'processing'},{value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true},{value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}],transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'이동확정',permission:'erp.inventory.move.confirm',confirm:true},{id:'ship',from:['CONFIRMED'],to:'IN_TRANSIT',label:'출고',permission:'erp.inventory.move.ship'},{id:'receive',from:['IN_TRANSIT'],to:'RECEIVED',label:'입고완료',permission:'erp.inventory.move.receive',confirm:true}]}
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
]
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
import { inventoryApi } from './inventoryApi'
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
const appliedKeyword=ref('')
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
const selectedItemId=ref<string|null>(null)
const selectedItem=ref<InventoryItemRow|null>(null)
const breakdownOpen=ref(false)
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
async function search(){
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
}
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
function command(id:string){if(id==='search')return search()}
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
const summaryItems=computed(()=>[
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
])
const drawerSummary=computed(()=>selectedItem.value?[
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
]:[])
</script>
<template>
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
<template #master>
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
</template>
<template #detail>
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
</template>
<template #bottom>
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
</template>
</KbxMasterDetailPage>
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
<div v-if="selectedItem" class="inventory-breakdown">
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
<KbxSummaryBar :items="drawerSummary" />
<dl><dt>현재고</dt><dd>물리적으로 기록된 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 있는 수량</dd></dl>
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
</div>
</KbxDrawer>
</template>
<style scoped>
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>
@@ -0,0 +1,49 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
export interface InventoryItemRow {
itemId: string; itemCode: string; itemName: string; specification: string
totalQty: number; availableQty: number; allocatedQty: number; holdQty: number
}
export interface InventoryLocationRow {
key: string; warehouseName: string; locationCode: string
onHandQty: number; allocatedQty: number; availableQty: number; holdQty: number
}
export interface InventoryHistoryRow {
entryId: string; occurredAt: string; businessType: string; referenceNo: string
warehouseName: string; locationCode: string; inboundQty: number; outboundQty: number; balanceQty: number; actor: string
}
export const inventoryScreen = defineKbxScreen({
id: 'ERP-INV-001', version: '1.1.0', module: 'ERP', type: 'master-detail', templateCode:'T05', title: '재고현황',
description: '품목 현재고에서 창고·로케이션 근거와 재고이력까지 조회 Context를 유지해 탐색합니다.',
helpKey: 'ERP-INV-001', permissions: ['erp.inventory.read'], telemetry: { enabled: true },
commands: [{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' }, { id: 'excel', label: '엑셀', group: 'output' }],
})
export const inventoryItemColumns: KbxGridColumn<InventoryItemRow>[] = [
{ field: 'itemCode', header: '품목코드', type: 'code', width: 120, pinned: 'left' },
{ field: 'itemName', header: '품목명', width: 190 },
{ field: 'specification', header: '규격', width: 140 },
{ field: 'totalQty', header: '현재고', type: 'quantity', width: 100, drilldown:true },
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100, drilldown:true },
]
export const inventoryLocationColumns: KbxGridColumn<InventoryLocationRow>[] = [
{ field: 'warehouseName', header: '창고', width: 150 },
{ field: 'locationCode', header: '로케이션', type: 'code', width: 120 },
{ field: 'onHandQty', header: '현재고', type: 'quantity', width: 100 },
{ field: 'allocatedQty', header: '할당', type: 'quantity', width: 90 },
{ field: 'holdQty', header: '보류', type: 'quantity', width: 90 },
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100 },
]
export const inventoryHistoryColumns: KbxGridColumn<InventoryHistoryRow>[] = [
{ field:'occurredAt', header:'일시', type:'datetime', width:165 },
{ field:'businessType', header:'업무', width:110 },
{ field:'referenceNo', header:'참조번호', type:'code', width:150 },
{ field:'warehouseName', header:'창고', width:130 },
{ field:'locationCode', header:'로케이션', type:'code', width:110 },
{ field:'inboundQty', header:'입고', type:'quantity', width:90 },
{ field:'outboundQty', header:'출고', type:'quantity', width:90 },
{ field:'balanceQty', header:'잔량', type:'quantity', width:100 },
{ field:'actor', header:'처리자', width:110 },
]
export const inventorySearchFields: KbxSearchField[] = [{ key: 'keyword', label: '품목', type: 'text', width: 'lg', placeholder: '품목코드/품목명' }]
@@ -0,0 +1,8 @@
import { kbxApi } from '../../../http/generated/kbxApiClient'
import type { InventoryHistoryRow, InventoryItemRow, InventoryLocationRow } from './inventory.definition'
export interface InventorySearchResponse { items: InventoryItemRow[]; totalCount: number }
export const inventoryApi = {
search(keyword = '') { return kbxApi.request<InventorySearchResponse>('erp.inventory.search', { query: { keyword, page: 1, pageSize: 200 } }) },
locations(itemId: string) { return kbxApi.request<{ items: InventoryLocationRow[] }>('erp.inventory.locations', { path: { itemId } }) },
history(itemId: string) { return kbxApi.request<{ items: InventoryHistoryRow[] }>('erp.inventory.history', { path: { itemId }, query:{ pageSize:100 } }) },
}
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxLookupItem } from '@kbx/contracts'
import { KbxDataGrid, KbxFastEntryPage, KbxLookupDialog, useKbxPageShortcuts } from '@kbx/ui'
import { itemPriceColumns, itemPriceScreen, type ItemPriceRow } from './item-price.definition'
import { useItemPriceFastEntry } from './useItemPriceFastEntry'
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
const vm=useItemPriceFastEntry()
useKbxWorkspaceBinding(vm.dirty, async()=>await vm.save())
const lookupOpen=ref(false); const activeRow=ref<ItemPriceRow|null>(null)
function openLookup(event:{row:ItemPriceRow;entity:string}){ if(event.entity!=='item')return;activeRow.value=event.row;lookupOpen.value=true }
function selectItem(item:KbxLookupItem<string>){ if(activeRow.value)vm.applyItemLookup(activeRow.value,item) }
const pageContext=computed(()=>({label:'품목 단가 입력',hint:'저장 전 오류를 모두 해소한 뒤 F8로 일괄 저장합니다.',metrics:[{key:'rows',label:'입력',value:vm.enteredCount.value},{key:'errors',label:'오류',value:vm.errors.value.length,tone:vm.errors.value.length?'danger' as const:'default' as const,emphasis:vm.errors.value.length>0}]}))
const summary=computed(()=>[
{key:'rows',label:'입력',value:`${vm.enteredCount.value.toLocaleString('ko-KR')}`},
{key:'errors',label:'오류',value:`${vm.errors.value.length.toLocaleString('ko-KR')}`},
...(vm.lastResult.value?[{key:'saved',label:'최근 저장',value:`${vm.lastResult.value.saved.toLocaleString('ko-KR')}`,emphasis:true}]:[]),
])
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
async function command(id:string){ if(id==='save')await vm.save(); if(id==='new')vm.clear() }
</script>
<template>
<KbxFastEntryPage :screen="itemPriceScreen" :selection-count="0" :dirty="vm.dirty.value" :context="pageContext" :errors="vm.errors.value" :summary-items="summary" :problem="vm.problem.value" breadcrumb="ERP > 기준정보" @command="command" @retry-problem="vm.retryProblem" @dismiss-problem="vm.dismissProblem">
<template #guide>
<span><kbd>Enter</kbd> 다음 </span><span><kbd>F2</kbd> 품목 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span> 선택 아래 채우기/복제</span>
</template>
<template #notice>
<div v-if="vm.lastResult.value" class="price-save-result" role="status">저장 {{vm.lastResult.value.saved.toLocaleString('ko-KR')}} · 신규 {{vm.lastResult.value.created.toLocaleString('ko-KR')}} · 수정 {{vm.lastResult.value.updated.toLocaleString('ko-KR')}}</div>
</template>
<template #content>
<KbxDataGrid
:rows="vm.rows.value" :columns="itemPriceColumns" row-key="clientId" selection="multiple"
:errors="vm.errors.value" editable clipboard
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
@row-add-requested="vm.addRow" @row-duplicate-requested="vm.duplicateRows" @cell-changed="vm.onCellChanged" @lookup-requested="openLookup"
/>
</template>
</KbxFastEntryPage>
<KbxLookupDialog v-model:visible="lookupOpen" entity="item" title="품목" @select="selectItem" />
</template>
<style scoped>
.price-save-result{min-height:var(--kbx-control-sm);display:flex;align-items:center;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);color:var(--kbx-color-success-text);font-size:var(--kbx-font-sm)}
</style>
@@ -0,0 +1,36 @@
import { defineKbxScreen, type KbxGridColumn } from '@kbx/ui'
export interface ItemPriceRow {
clientId: string
itemId: string | null
itemCode: string
itemName: string
effectiveDate: string
unitPrice: number
remark: string
}
export const itemPriceScreen = defineKbxScreen({
id: 'ERP-PRICE-001',
version: '1.0.0',
module: 'ERP',
type: 'fast-entry', templateCode:'T04',
title: '품목 단가 일괄등록',
description: '품목별 적용일과 표준단가를 Excel처럼 연속 입력하고 한 번에 저장합니다.',
helpKey: 'ERP-PRICE-001',
permissions: ['erp.item.price.read', 'erp.item.read'],
commands: [
{ id: 'new', label: '신규', group: 'edit' },
{ id: 'save', label: '저장', group: 'edit', variant: 'primary', shortcut: 'F8', permission: 'erp.item.price.write' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
telemetry: { enabled: true },
})
export const itemPriceColumns: KbxGridColumn<ItemPriceRow>[] = [
{ field:'itemCode', header:'품목코드', type:'lookup', width:140, pinned:'left', editable:true, lookup:{entity:'item'} },
{ field:'itemName', header:'품목명', width:220 },
{ field:'effectiveDate', header:'적용일', type:'date', width:120, editable:true },
{ field:'unitPrice', header:'단가', type:'money', width:130, editable:true },
{ field:'remark', header:'비고', width:260, editable:true },
]
@@ -0,0 +1,25 @@
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface SaveItemPriceRow {
clientId: string
itemId: string
effectiveDate: string
unitPrice: number
remark?: string
}
export interface SaveItemPricesRequest { rows: SaveItemPriceRow[] }
export interface SaveItemPricesResponse { requested: number; saved: number; created: number; updated: number }
export interface ItemLookupResult { id: string; code: string; displayName: string; status?: string }
export function toKbxProblem(error: unknown): KbxProblem | null { return isKbxProblem(error) ? error : null }
export const itemPriceApi = {
async resolveItemByCode(code: string) {
try { return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode', { path:{ code } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
save(request: SaveItemPricesRequest, idempotencyKey: string) {
return kbxApi.request<SaveItemPricesResponse, SaveItemPricesRequest>('erp.itemPrices.bulkSave', { body:request, idempotencyKey })
},
}
@@ -0,0 +1,69 @@
import { computed, ref } from 'vue'
import { useMutation } from '@tanstack/vue-query'
import type { KbxLookupItem, KbxProblem, KbxValidationError } from '@kbx/contracts'
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
import { itemPriceApi, toKbxProblem } from './itemPriceApi'
import type { ItemPriceRow } from './item-price.definition'
function localDate() {
const d=new Date(); const m=String(d.getMonth()+1).padStart(2,'0'); const day=String(d.getDate()).padStart(2,'0')
return `${d.getFullYear()}-${m}-${day}`
}
function newRow():ItemPriceRow { return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', effectiveDate:localDate(), unitPrice:0, remark:'' } }
export function useItemPriceFastEntry() {
const rows=ref<ItemPriceRow[]>(Array.from({length:8},()=>newRow()))
const validation=useKbxValidation()
const { dirty, touch, markSaved, reset:resetDirty }=useKbxDirtyState()
const mutation=useMutation({ mutationFn:({request,key}:{request:{rows:Array<{clientId:string;itemId:string;effectiveDate:string;unitPrice:number;remark?:string}>};key:string})=>itemPriceApi.save(request,key) })
const lastResult=ref<{requested:number;saved:number;created:number;updated:number}|null>(null)
const problem=ref<KbxProblem|null>(null)
let failedAction:'save'|null=null
let idempotencyKey=crypto.randomUUID()
const enteredRows=computed(()=>rows.value.filter(x=>x.itemId||x.itemCode.trim()||Number(x.unitPrice)!==0||x.remark.trim()))
function invalidateCommandKey(){ idempotencyKey=crypto.randomUUID() }
function addRow(){ rows.value.push(newRow()); validation.clear(); touch(); invalidateCommandKey() }
function duplicateRows(source:ItemPriceRow[]){ rows.value.push(...source.map(x=>({...x,clientId:crypto.randomUUID()}))); touch(); invalidateCommandKey() }
function clear(){ rows.value=Array.from({length:8},()=>newRow()); validation.clear(); lastResult.value=null; problem.value=null; failedAction=null; resetDirty(); invalidateCommandKey() }
function localErrors():KbxValidationError[] {
const errors:KbxValidationError[]=[]
const active=enteredRows.value
if(!active.length) return [{code:'PRICE_ROWS_REQUIRED',message:'저장할 단가를 한 건 이상 입력하세요.'}]
const duplicate=new Map<string,string>()
for(const row of active){
if(!row.itemId) errors.push({rowKey:row.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
if(!/^\d{4}-\d{2}-\d{2}$/.test(row.effectiveDate)) errors.push({rowKey:row.clientId,field:'effectiveDate',code:'EFFECTIVE_DATE_REQUIRED',message:'적용일을 YYYY-MM-DD 형식으로 입력하세요.'})
if(!Number.isFinite(Number(row.unitPrice))||Number(row.unitPrice)<0) errors.push({rowKey:row.clientId,field:'unitPrice',code:'PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
if(row.itemId){ const key=`${row.itemId}|${row.effectiveDate}`; if(duplicate.has(key)){ errors.push({rowKey:row.clientId,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}); const first=duplicate.get(key)!; if(!errors.some(x=>x.rowKey===first&&x.code==='DUPLICATE_ITEM_DATE')) errors.push({rowKey:first,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}) } else duplicate.set(key,row.clientId) }
}
return errors
}
async function onCellChanged(event:{row:ItemPriceRow;field:keyof ItemPriceRow;newValue:unknown}){
if(event.field==='itemCode'){
const code=String(event.newValue??'').trim()
if(!code) Object.assign(event.row,{itemId:null,itemName:''})
else {
const item=await itemPriceApi.resolveItemByCode(code)
if(!item){ Object.assign(event.row,{itemId:null,itemName:''}); validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'}) }
else { Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(event.row.clientId,'itemCode') }
}
}
lastResult.value=null; touch(); invalidateCommandKey()
}
function applyItemLookup(row:ItemPriceRow,item:KbxLookupItem<string>){ Object.assign(row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(row.clientId,'itemCode'); touch(); invalidateCommandKey() }
async function save(){
validation.clear(); problem.value=null; failedAction=null; const errors=localErrors(); if(errors.length){validation.setErrors(errors);return false}
const active=enteredRows.value
try{
const result=await mutation.mutateAsync({key:idempotencyKey,request:{rows:active.map(x=>({clientId:x.clientId,itemId:x.itemId!,effectiveDate:x.effectiveDate,unitPrice:Number(x.unitPrice),remark:x.remark||undefined}))}})
lastResult.value=result; markSaved(); idempotencyKey=crypto.randomUUID(); return true
}catch(error){ const issue=toKbxProblem(error); if(issue?.type==='validation'){validation.applyProblem(issue);return false} if(issue){problem.value=issue;failedAction='save';return false} throw error }
}
async function retryProblem(){const action=failedAction;problem.value=null;failedAction=null;if(action==='save')return save();return false}
function dismissProblem(){problem.value=null;failedAction=null}
return {rows,errors:validation.errors,dirty,saving:computed(()=>mutation.isPending.value),lastResult,problem,enteredCount:computed(()=>enteredRows.value.length),addRow,duplicateRows,onCellChanged,applyItemLookup,save,clear,touch,retryProblem,dismissProblem}
}
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import {
isKbxProblem,
kbxFieldReadonly,
resolveKbxRecordStatePolicy,
type KbxAuditEntry,
type KbxConflictSnapshot,
type KbxProblem,
type KbxSearchField,
} from '@kbx/contracts'
import {
KbxBarcodeField,
KbxCheckbox,
KbxDataGrid,
KbxFormGrid,
KbxFormSection,
KbxInput,
KbxLookup,
KbxMasterPage,
KbxSearchPanel,
KbxUnsavedChangesDialog,
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 auditEntries = ref<KbxAuditEntry[]>([])
const conflict = ref<KbxConflictSnapshot | null>(null)
const problem = ref<KbxProblem | null>(null)
const failedCommand = ref<'load'|'save'|'deactivate'|null>(null)
const errors = ref<any[]>([])
const masterGrid = ref<any>(null)
const pendingTransition = ref<{ kind:'row'; row:ItemMasterRow } | { kind:'new' } | null>(null)
const savingTransition = ref(false)
const { dirty, touch, markSaved, reset:resetDirty } = useKbxDirtyState()
const status = computed(() => !form.id ? '신규' : form.active ? '사용' : '사용중지')
const policy = computed(() => resolveKbxRecordStatePolicy(itemStatePolicies, status.value))
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 = []
problem.value = null
failedCommand.value = null
await query.refetch()
}
async function loadRow(row:ItemMasterRow) {
problem.value = null
failedCommand.value = null
try {
const latest = await itemApi.get(row.id)
const audit = await itemApi.audit(latest.id)
selected.value = [latest]
Object.assign(form, latest)
auditEntries.value = audit
errors.value = []
conflict.value = null
resetDirty()
await nextTick()
masterGrid.value?.selectRowByKey?.(latest.id)
} catch (error) {
if (isKbxProblem(error)) {
problem.value = error
failedCommand.value = 'load'
await nextTick()
masterGrid.value?.selectRowByKey?.(form.id || null)
return
}
throw error
}
}
async function requestChoose(rows:ItemMasterRow[]) {
const row = rows[0]
if (!row || row.id === form.id) return
if (dirty.value) {
pendingTransition.value = { kind:'row', row }
await nextTick()
masterGrid.value?.selectRowByKey?.(form.id || null)
return
}
await loadRow(row)
}
function state(field:string) {
return kbxFieldReadonly(policy.value, field)
}
function createNew() {
Object.assign(form, emptyItem())
selected.value = []
auditEntries.value = []
conflict.value = null
problem.value = null
failedCommand.value = null
errors.value = []
resetDirty()
masterGrid.value?.clearSelection?.()
}
function requestNew() {
if (dirty.value) {
pendingTransition.value = { kind:'new' }
return
}
createNew()
}
function copy() {
const source = { ...form }
Object.assign(form, { ...source, id:'', code:'', barcode:'', version:0, active:true })
selected.value = []
auditEntries.value = []
conflict.value = null
problem.value = null
failedCommand.value = null
errors.value = []
resetDirty()
masterGrid.value?.clearSelection?.()
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 = []
problem.value = null
failedCommand.value = null
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 false
}
try {
const result = form.id ? await itemApi.update(form.id, payload()) : await itemApi.create(payload())
form.id = result.id
form.version = result.version
form.active = true
markSaved()
auditEntries.value = await itemApi.audit(form.id)
await query.refetch()
await nextTick()
masterGrid.value?.selectRowByKey?.(form.id)
return true
} catch (error) {
if (isKbxProblem(error) && error.type === 'validation') {
errors.value = error.errors
return false
}
if (isKbxProblem(error) && error.type === 'conflict' && form.id) {
const latest = await itemApi.get(form.id)
conflict.value = {
code:error.code,
title:error.title,
detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',
entityId:form.id,
requestedVersion:form.version,
currentVersion:error.currentVersion ?? latest.version,
changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label]) => ({
field,
label,
mine:(form as any)[field],
latest:(latest as any)[field],
})),
}
return false
}
if (isKbxProblem(error)) {
problem.value = error
failedCommand.value = 'save'
return false
}
throw error
}
}
async function applyPendingTransition() {
const pending = pendingTransition.value
pendingTransition.value = null
if (!pending) return
if (pending.kind === 'new') createNew()
else await loadRow(pending.row)
}
async function saveThenTransition() {
if (!pendingTransition.value) return
savingTransition.value = true
try {
if (await save()) await applyPendingTransition()
} finally {
savingTransition.value = false
}
}
async function deactivate() {
if (!form.id) return
problem.value = null
failedCommand.value = null
try {
const result = await itemApi.deactivate(form.id, form.version)
form.version = result.version
form.active = false
markSaved()
auditEntries.value = await itemApi.audit(form.id)
await query.refetch()
} catch (error) {
if (isKbxProblem(error) && error.type === 'conflict') {
const latest = await itemApi.get(form.id)
conflict.value = {
code:error.code,
title:error.title,
currentVersion:error.currentVersion ?? latest.version,
requestedVersion:form.version,
entityId:form.id,
}
return
}
if (isKbxProblem(error)) {
problem.value = error
failedCommand.value = 'deactivate'
return
}
throw error
}
}
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
problem.value = null
failedCommand.value = null
resetDirty()
await nextTick()
masterGrid.value?.selectRowByKey?.(form.id)
}
async function retryProblem() {
const action = failedCommand.value
const row = selected.value[0]
problem.value = null
failedCommand.value = null
if (action === 'load' && row) return loadRow(row)
if (action === 'save') return save()
if (action === 'deactivate') return deactivate()
}
function dismissProblem() {
problem.value = null
failedCommand.value = null
}
async function command(id:string) {
if (id === 'search') return search()
if (id === 'new') return requestNew()
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"
:problem="problem"
:audit-entries="auditEntries"
list-title="품목 목록"
:list-count="query.data.value?.totalCount ?? rows.length"
detail-title="품목 상세"
:detail-description="form.id ? `${form.code} · ${form.name}` : '신규 품목을 등록합니다.'"
: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"
@retry-problem="retryProblem"
@dismiss-problem="dismissProblem"
>
<template #list>
<KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search" />
<KbxDataGrid
ref="masterGrid"
:rows="rows"
:columns="itemMasterColumns"
row-key="id"
aria-label="품목 목록"
selection="single"
:active-row-key="form.id || undefined"
:loading="query.isFetching.value"
@selection-changed="requestChoose"
/>
</template>
<template #detail>
<KbxFormSection title="기본정보" description="품목 식별과 분류에 필요한 핵심 기준정보입니다.">
<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="물류정보" description="창고·바코드·LOT 등 현장 처리 기준을 정의합니다.">
<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>
<KbxUnsavedChangesDialog
:open="Boolean(pendingTransition)"
:saving="savingTransition"
@stay="pendingTransition=null"
@discard="applyPendingTransition"
@save="saveThenTransition"
/>
</template>
@@ -0,0 +1,20 @@
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
export interface ItemMasterRow { id:string; code:string; name:string; categoryName:string; specification:string; unit:string; barcode:string; defaultWarehouseId:string|null; defaultWarehouseName:string; lotManaged:boolean; expiryManaged:boolean; active:boolean; version:number }
export const itemWorkflow:KbxWorkflowDefinition={id:'erp.item.lifecycle',version:'1.0.0',states:[{value:'신규',label:'신규',semantic:'draft'},{value:'사용',label:'사용',semantic:'completed'},{value:'사용중지',label:'사용중지',semantic:'disabled',terminal:true}],transitions:[{id:'deactivate',from:['사용'],to:'사용중지',label:'사용중지',permission:'erp.item.write',confirm:true}]}
export const itemStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'사용',editability:'editable'},{status:'사용중지',editability:'readonly',message:'사용중지된 품목은 조회·복사·이력확인만 가능합니다.'}]
export const itemMasterScreen = defineKbxScreen({
id: 'ERP-MST-ITEM-001', version: '1.1.0', module: 'ERP', type: 'master', templateCode:'T02', title: '품목관리',
description: '품목 기준정보와 물류 속성을 동일한 문법으로 관리합니다.', helpKey:'ERP-MST-ITEM-001', permissions:['erp.item.read'], telemetry:{enabled:true},
commands:[
{id:'search',label:'조회',group:'query',shortcut:'F3'},
{id:'new',label:'신규',group:'edit',permission:'erp.item.create'},
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'erp.item.create','사용':'erp.item.write'},allowedStatuses:['신규','사용'],requiresDirty:true},
{id:'copy',label:'복사',group:'edit',permission:'erp.item.create'},
{id:'deactivate',label:'사용중지',group:'workflow',permission:'erp.item.write',allowedStatuses:['사용'],confirm:{title:'품목을 사용중지하시겠습니까?',detail:'사용중지 후 신규 업무에서 이 품목을 선택할 수 없습니다. 기존 이력은 유지됩니다.',level:'high',confirmLabel:'사용중지'}},
{id:'excel',label:'엑셀',group:'output'},
],
})
export const itemMasterColumns:KbxGridColumn<ItemMasterRow>[]=[{field:'code',header:'품목코드',type:'code',width:120,pinned:'left'},{field:'name',header:'품목명',width:200},{field:'specification',header:'규격',width:160},{field:'unit',header:'단위',width:70},{field:'active',header:'사용',type:'boolean',width:70}]
@@ -0,0 +1,14 @@
import { kbxApi } from '../../../http/generated/kbxApiClient'
import type { KbxAuditEntry } from '@kbx/contracts'
import type { ItemMasterRow } from './item.definition'
export interface ItemSearchResponse { items: ItemMasterRow[]; totalCount: number }
export interface ItemSaveRequest { id?:string; version?:number; code:string; name:string; categoryName?:string; specification?:string; unit:string; barcode?:string; defaultWarehouseId?:string|null; lotManaged:boolean; expiryManaged:boolean }
export interface ItemSaveResponse { id:string; version:number; status:string }
export const itemApi = {
search(keyword = '') { return kbxApi.request<ItemSearchResponse>('erp.items.search', { query: { keyword, page: 1, pageSize: 200 } }) },
get(id: string) { return kbxApi.request<ItemMasterRow>('erp.items.get', { path: { id } }) },
create(body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.create', { body }) },
update(id:string,body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.update', { path:{id}, body }) },
deactivate(id:string,version:number) { return kbxApi.request<ItemSaveResponse>('erp.items.deactivate', { path:{id}, body:{version} }) },
audit(id:string) { return kbxApi.request<KbxAuditEntry[]>('erp.items.audit', { path:{id} }) },
}
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxInput, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
import { purchaseColumns, purchaseScreen, purchaseWorkflow, type PurchaseLine } from './purchase.definition'
const status=ref('DRAFT'); const lines=ref<PurchaseLine[]>([{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',quantity:1,unitPrice:0,amount:0,dueDate:''}])
const header=reactive({ purchaseDate:new Date().toISOString().slice(0,10), supplierId:null as string|null, warehouseId:null as string|null, buyer:'', remark:'' })
const total=computed(()=>lines.value.reduce((s,x)=>s+x.quantity*x.unitPrice,0))
function command(id:string){ if(id==='new'){ status.value='DRAFT'; lines.value=[] } if(id==='confirm' && status.value==='DRAFT') status.value='CONFIRMED' }
</script>
<template>
<KbxTransactionPage :screen="purchaseScreen" :status="status" :summary-items="[{key:'amount',label:' 구매금액',value:`${total.toLocaleString('ko-KR')}`,emphasis:true}]" header-title="구매 정보" header-description="거래처·입고창고·담당자 기준을 먼저 확정합니다." detail-title="구매 품목" :detail-count="lines.length" detail-description="수량·단가·납기일을 Grid에서 연속 입력합니다." @command="command">
<template #header>
<KbxWorkflowBar :workflow="purchaseWorkflow" :current="status" @transition="command" />
<KbxFormSection title="구매정보"><KbxFormGrid><KbxDateField v-model="header.purchaseDate" label="구매일" required/><KbxLookup v-model="header.supplierId" entity="customer" label="거래처" required/><KbxLookup v-model="header.warehouseId" entity="warehouse" label="입고창고" required/><KbxInput v-model="header.buyer" label="담당자"/></KbxFormGrid></KbxFormSection>
</template>
<template #detail><KbxDataGrid :rows="lines" :columns="purchaseColumns" row-key="clientId" editable clipboard /></template>
</KbxTransactionPage>
</template>
@@ -0,0 +1,17 @@
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
export interface PurchaseLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; quantity:number; unitPrice:number; amount:number; dueDate:string }
export const purchaseScreen = defineKbxScreen({
id:'ERP-PUR-001', version:'1.0.0', module:'ERP', type:'transaction', templateCode:'T03', title:'구매등록', helpKey:'ERP-PUR-001', permissions:['erp.purchase.read'], telemetry:{enabled:true},
description:'거래처 구매를 Header/Detail 방식으로 입력하고 확정합니다.',
commands:[{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.purchase.write'},{id:'confirm',label:'구매확정',group:'workflow',variant:'primary',permission:'erp.purchase.confirm'},{id:'excel',label:'엑셀',group:'output'}]
})
export const purchaseColumns: KbxGridColumn<PurchaseLine>[] = [
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'}, {field:'itemName',header:'품목명',width:210},
{field:'quantity',header:'수량',type:'quantity',width:95,editable:true}, {field:'unitPrice',header:'단가',type:'money',width:120,editable:true}, {field:'amount',header:'금액',type:'money',width:130}, {field:'dueDate',header:'납기일',type:'date',width:110,editable:true}
]
export const purchaseWorkflow: KbxWorkflowDefinition = { id:'erp.purchase', version:'1.0.0', states:[
{value:'DRAFT',label:'작성',semantic:'draft'}, {value:'CONFIRMED',label:'확정',semantic:'processing'}, {value:'PARTIALLY_RECEIVED',label:'부분입고',semantic:'processing'}, {value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true}, {value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}
], transitions:[
{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'구매확정',permission:'erp.purchase.confirm',confirm:true}, {id:'cancel',from:['DRAFT','CONFIRMED'],to:'CANCELLED',label:'구매취소',permission:'erp.purchase.cancel',reasonRequired:true}
] }
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const erpPurchaseRoutes: RouteRecordRaw[] = [
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
]
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxDataGrid, KbxListPage, KbxSearchPanel, KbxWorkflowBar } from '@kbx/ui'
import { claimColumns, claimScreen, claimSearchFields, claimWorkflow, type ClaimRow } from './claims.definition'
import { claimsApi } from './claimsApi'
const search = reactive<Record<string,unknown>>({ status:'REQUESTED' })
const selected = ref<ClaimRow[]>([])
const q = useQuery({ queryKey:['oms','claims',search], queryFn:()=>claimsApi.search(search), enabled:false })
async function run(){ selected.value=[]; await q.refetch() }
async function command(id:string){ if(id==='search') return run(); if(['approve','hold'].includes(id)) for(const row of selected.value) await claimsApi.transition(row.id,id); await run() }
</script>
<template>
<KbxListPage :screen="claimScreen" :content-state="q.error.value?'error':q.isFetching.value&&!q.data.value?'loading':!q.data.value?'idle':q.data.value.totalCount===0?'empty':'ready'" :refreshing="q.isFetching.value&&Boolean(q.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="search" :fields="claimSearchFields" @search="run" /></template>
<template #content>
<KbxWorkflowBar v-if="selected.length === 1" :workflow="claimWorkflow" :current="selected[0].status" @transition="command" />
<KbxDataGrid v-model:selection="selected" :rows="q.data.value?.items ?? []" :columns="claimColumns" row-key="id" selection="multiple" :loading="q.isFetching.value" personalization exportable />
</template>
<template #summary>클레임 {{ q.data.value?.totalCount ?? 0 }} · 선택 {{ selected.length }}</template>
</KbxListPage>
</template>
@@ -0,0 +1,39 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkflowDefinition } from '@kbx/ui'
export interface ClaimRow {
id: string; claimNo: string; orderNo: string; channelName: string; type: string; reason: string; requestedQty: number; status: string; ownerName: string; requestedAt: string
}
export const claimScreen = defineKbxScreen({
id:'OMS-CLM-001', version:'1.0.0', module:'OMS', type:'list', templateCode:'T01', title:'반품·클레임 관리', helpKey:'OMS-CLM-001', permissions:['oms.claim.read'], telemetry:{ enabled:true },
description:'반품·교환·취소 요청을 조회하고 정상 건은 표준 상태전이로 처리합니다.',
commands:[
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
{ id:'approve', label:'승인', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, permission:'oms.claim.approve' },
{ id:'hold', label:'보류', group:'workflow', requiresSelection:true, minSelection:1, permission:'oms.claim.hold' },
{ id:'excel', label:'엑셀', group:'output' },
]
})
export const claimSearchFields: KbxSearchField[] = [
{ key:'period', label:'요청기간', type:'date-range', range:{ from:'from', to:'to' } },
{ key:'type', label:'유형', type:'select', options:[{value:'RETURN',label:'반품'},{value:'EXCHANGE',label:'교환'},{value:'CANCEL',label:'취소'}] },
{ key:'status', label:'상태', type:'select', options:[{value:'REQUESTED',label:'접수'},{value:'APPROVED',label:'승인'},{value:'IN_PROGRESS',label:'처리중'},{value:'COMPLETED',label:'완료'},{value:'HOLD',label:'보류'}] },
{ key:'keyword', label:'검색', type:'text', width:'lg', placeholder:'클레임번호/주문번호/고객' },
]
export const claimColumns: KbxGridColumn<ClaimRow>[] = [
{ field:'claimNo', header:'클레임번호', type:'code', width:150, pinned:'left' },
{ field:'orderNo', header:'주문번호', type:'code', width:150 },
{ field:'channelName', header:'채널', width:100 }, { field:'type', header:'유형', width:90 }, { field:'reason', header:'사유', width:220 },
{ field:'requestedQty', header:'수량', type:'quantity', width:90 }, { field:'status', header:'상태', type:'status', width:100 }, { field:'ownerName', header:'담당', width:100 }, { field:'requestedAt', header:'요청일시', type:'datetime', width:160 },
]
export const claimWorkflow: KbxWorkflowDefinition = {
id:'oms.claim', version:'1.0.0',
states:[
{value:'REQUESTED',label:'접수',semantic:'pending'}, {value:'APPROVED',label:'승인',semantic:'processing'}, {value:'IN_PROGRESS',label:'처리중',semantic:'processing'}, {value:'COMPLETED',label:'완료',semantic:'completed',terminal:true}
],
transitions:[
{id:'approve',from:['REQUESTED'],to:'APPROVED',label:'승인',permission:'oms.claim.approve'},
{id:'start',from:['APPROVED'],to:'IN_PROGRESS',label:'처리 시작',permission:'oms.claim.process'},
{id:'complete',from:['IN_PROGRESS'],to:'COMPLETED',label:'처리 완료',permission:'oms.claim.process',confirm:true},
]
}
@@ -0,0 +1,22 @@
import type { ClaimRow } from './claims.definition'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface ClaimSearch { from?: string; to?: string; type?: string; status?: string; keyword?: string }
const transitionOperations = {
approve: 'oms.claims.approve',
hold: 'oms.claims.hold',
start: 'oms.claims.start',
complete: 'oms.claims.complete',
} as const
export const claimsApi = {
search(filter: ClaimSearch) {
return kbxApi.request<{ items: ClaimRow[]; totalCount: number }>('oms.claims.search', { query: filter })
},
transition(id: string, transition: string) {
const operation = transitionOperations[transition as keyof typeof transitionOperations]
if (!operation) throw new Error(`Unsupported claim transition: ${transition}`)
return kbxApi.request<{ id: string; status: string; version: number }>(operation, { path: { id } })
},
}
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const omsClaimRoutes: RouteRecordRaw[] = [
{ path:'/oms/claims', name:'oms-claims', component:()=>import('./ClaimsPage.vue'), meta:{ screenId:'OMS-CLM-001' } },
]
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { watch } from 'vue'
import { KbxExcelImport, KbxImportPage, useKbxImportProgress } from '@kbx/ui'
import { orderImportDefinition, orderImportScreen } from './order-import.definition'
import { useOrderExcelImport } from './useOrderExcelImport'
import { createImportProgressConnection } from '../../../../imports/createImportProgressConnection'
const vm = useOrderExcelImport()
const realtime = useKbxImportProgress(createImportProgressConnection())
watch(vm.sessionId, id => { if (id) void realtime.watch(id) })
</script>
<template>
<KbxImportPage :screen="orderImportScreen" :refreshing="vm.busy.value" :problem="vm.problem.value" @retry-problem="vm.retryProblem" @dismiss-problem="vm.dismissProblem">
<KbxExcelImport
:definition="orderImportDefinition"
:session="vm.session.value"
:busy="vm.busy.value"
:progress="realtime.lastEvent.value"
@upload="vm.upload"
@save-mapping="vm.saveMapping"
@save-named-mapping="vm.saveNamedMapping"
@validate="vm.validate"
@commit="vm.commit"
@download-template="vm.downloadTemplate"
@download-errors="vm.downloadErrors"
@cancel="vm.reset"
/>
</KbxImportPage>
</template>
@@ -0,0 +1,40 @@
import { defineKbxScreen } from '@kbx/ui'
import { kbxImportField, type KbxImportDefinition } from '@kbx/contracts'
export const orderImportScreen = defineKbxScreen({
id: 'OMS-ORD-003',
version: '1.1.0',
module: 'OMS',
type: 'import', templateCode:'T08',
title: '주문 Excel 업로드',
helpKey: 'OMS-ORD-003',
permissions: ['oms.order.import'],
commands: [],
telemetry: { enabled: true },
})
export const orderImportDefinition: KbxImportDefinition = {
id: 'oms.orders.v1',
screenId: 'OMS-ORD-003',
entity: 'order',
title: '주문 Excel 업로드',
allowCreate: true,
allowUpdate: true,
maxFileSizeBytes: 20 * 1024 * 1024,
maxRows: 100_000,
fields: [
kbxImportField('orderNo'),
kbxImportField('orderDate'),
kbxImportField('customerCode'),
kbxImportField('warehouseCode'),
kbxImportField('receiverName'),
kbxImportField('phone'),
kbxImportField('postalCode'),
kbxImportField('address1'),
kbxImportField('address2'),
kbxImportField('itemCode'),
kbxImportField('orderQty'),
kbxImportField('unitPrice'),
kbxImportField('remark'),
],
}
@@ -0,0 +1,58 @@
import type { KbxImportMapping, KbxImportSession } from '@kbx/contracts'
import { kbxApi } from '../../../../http/generated/kbxApiClient'
export const orderImportApi = {
createSession(file: File) {
const form = new FormData()
form.append('file', file)
form.append('importType', 'oms.orders.v1')
return kbxApi.request<KbxImportSession, FormData>('common.imports.createSession', { body: form })
},
getSession(id: string) {
return kbxApi.request<KbxImportSession>('common.imports.getSession', { path: { sessionId: id } })
},
saveMapping(id: string, mappings: KbxImportMapping[]) {
return kbxApi.request<KbxImportSession, { mappings: KbxImportMapping[] }>('common.imports.saveMapping', {
path: { sessionId: id }, body: { mappings },
})
},
saveNamedMapping(id: string, name: string, mappings: KbxImportMapping[]) {
return kbxApi.request<void, { name: string; mappings: KbxImportMapping[] }>('common.imports.saveNamedMapping', {
path: { sessionId: id }, body: { name, mappings },
})
},
validate(id: string) {
return kbxApi.request<KbxImportSession>('common.imports.validate', { path: { sessionId: id } })
},
commit(id: string) {
return kbxApi.request<KbxImportSession>('common.imports.commit', { path: { sessionId: id } })
},
async downloadTemplate() {
const value = await kbxApi.request<Blob>('common.imports.template', {
path: { importType: 'oms.orders.v1' }, responseType: 'blob',
})
downloadBlob(value, 'OMS_주문_업로드_양식.xlsx')
},
async downloadErrors(id: string) {
const value = await kbxApi.request<Blob>('common.imports.errorWorkbook', {
path: { sessionId: id }, responseType: 'blob',
})
downloadBlob(value, 'OMS_주문_업로드_오류.xlsx')
},
}
function downloadBlob(value: BlobPart, fileName: string) {
const url = URL.createObjectURL(new Blob([value]))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
@@ -0,0 +1,87 @@
import { computed, ref, watch } from 'vue'
import { useMutation, useQuery } from '@tanstack/vue-query'
import { isKbxProblem, type KbxImportMapping, type KbxImportSession, type KbxProblem } from '@kbx/contracts'
import { orderImportApi } from './orderImportApi'
import { kbxTelemetry } from '../../../telemetry/kbxTelemetryClient'
import { orderImportScreen } from './order-import.definition'
export function useOrderExcelImport() {
const sessionId = ref<string | null>(null)
const localSession = ref<KbxImportSession | null>(null)
let importStartedAt:number|undefined
let terminalRecorded=false
const problem=ref<KbxProblem|null>(null)
let retryAction:(()=>Promise<unknown>)|null=null
const sessionQuery = useQuery({
queryKey: computed(() => ['excel-import', sessionId.value]),
queryFn: () => orderImportApi.getSession(sessionId.value!),
enabled: computed(() => !!sessionId.value),
refetchInterval: query => {
const status = query.state.data?.status
return status === 'validating' || status === 'committing' ? 1500 : false
},
})
const session = computed(() => sessionQuery.data.value ?? localSession.value)
const uploadMutation = useMutation({
mutationFn: orderImportApi.createSession,
onMutate() { importStartedAt=performance.now(); terminalRecorded=false; kbxTelemetry.track('excel.import.start',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,attributes:{importType:'oms-order',rowCountBucket:'unknown'}}) },
onSuccess(value) { sessionId.value = value.id; localSession.value = value },
onError() { kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs:Math.round(performance.now()-(importStartedAt??performance.now())),attributes:{importType:'oms-order',reasonCode:'UPLOAD_FAILED',rowCountBucket:'unknown'}}); terminalRecorded=true },
})
const mappingMutation = useMutation({
mutationFn: ({ id, mappings }: { id: string; mappings: KbxImportMapping[] }) => orderImportApi.saveMapping(id, mappings),
onSuccess(value) { localSession.value = value },
})
const savedMappingMutation = useMutation({
mutationFn: ({ id, name, mappings }: { id: string; name: string; mappings: KbxImportMapping[] }) => orderImportApi.saveNamedMapping(id, name, mappings),
})
const validationMutation = useMutation({
mutationFn: orderImportApi.validate,
onSuccess(value) { localSession.value = value; void sessionQuery.refetch() },
})
const commitMutation = useMutation({
mutationFn: orderImportApi.commit,
onSuccess(value) { localSession.value = value; void sessionQuery.refetch() },
onError() { kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs:Math.round(performance.now()-(importStartedAt??performance.now())),attributes:{importType:'oms-order',reasonCode:'COMMIT_REQUEST_FAILED',rowCountBucket:'unknown'}}); terminalRecorded=true },
})
async function runProblemAware<T>(action:()=>Promise<T>):Promise<T|null>{
problem.value=null; retryAction=null
try{return await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return null}throw error}
}
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
function dismissProblem(){problem.value=null;retryAction=null}
watch(session, value => {
if(!value || terminalRecorded || importStartedAt==null) return
const bucket=value.totalRows<1000?'0-999':value.totalRows<10000?'1k-9k':value.totalRows<100000?'10k-99k':'100k+'
const durationMs=Math.round(performance.now()-importStartedAt)
if(value.status==='completed'||value.status==='partially-completed'){kbxTelemetry.track('excel.import.completed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs,attributes:{importType:'oms-order',result:value.status,rowCountBucket:bucket}});terminalRecorded=true}
if(value.status==='failed'){kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs,attributes:{importType:'oms-order',reasonCode:'JOB_FAILED',rowCountBucket:bucket}});terminalRecorded=true}
})
return {
session,
sessionId,
problem,
busy: computed(() => uploadMutation.isPending.value || mappingMutation.isPending.value || validationMutation.isPending.value || commitMutation.isPending.value),
upload: (file: File) => runProblemAware(()=>uploadMutation.mutateAsync(file)),
saveMapping: (mappings: KbxImportMapping[]) => sessionId.value ? runProblemAware(()=>mappingMutation.mutateAsync({ id: sessionId.value!, mappings })) : Promise.resolve(null),
saveNamedMapping: (name: string, mappings: KbxImportMapping[]) => sessionId.value ? runProblemAware(()=>savedMappingMutation.mutateAsync({ id: sessionId.value!, name, mappings })) : Promise.resolve(null),
validate: () => sessionId.value ? runProblemAware(()=>validationMutation.mutateAsync(sessionId.value!)) : Promise.resolve(null),
commit: () => sessionId.value ? runProblemAware(()=>commitMutation.mutateAsync(sessionId.value!)) : Promise.resolve(null),
downloadTemplate: () => runProblemAware(()=>orderImportApi.downloadTemplate()),
downloadErrors: () => sessionId.value ? runProblemAware(()=>orderImportApi.downloadErrors(sessionId.value!)) : Promise.resolve(null),
reset: () => { sessionId.value = null; localSession.value = null; problem.value=null; retryAction=null },
retryProblem,
dismissProblem,
}
}
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, nextTick, 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,
KbxInput,
KbxLookup,
KbxLookupDialog,
KbxTransactionPage,
KbxUnsavedChangesDialog,
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 () => Boolean(await vm.save()))
const detailGrid = ref<any>(null)
const itemLookupOpen = ref(false)
const activeLine = ref<OrderLineForm | null>(null)
const activeLookupField = ref<keyof OrderLineForm & string>('itemCode')
const pendingReset = ref<'new' | 'copy' | null>(null)
const savingReset = ref(false)
const statePolicy = computed(() => resolveKbxRecordStatePolicy(orderStatePolicies, vm.status.value))
const readonly = computed(() => statePolicy.value.editability === 'readonly')
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 },
])
function fieldReadonly(field:string) {
return kbxFieldReadonly(statePolicy.value, field)
}
function openGridLookup(event:{ row:OrderLineForm; field:keyof OrderLineForm & string; entity:string }) {
if (event.entity !== 'item' || readonly.value) return
activeLine.value = event.row
activeLookupField.value = event.field
itemLookupOpen.value = true
}
async function selectGridItem(item:KbxLookupItem<string>) {
const line = activeLine.value
if (!line) return
vm.applyItemLookup(line, item)
itemLookupOpen.value = false
await nextTick()
detailGrid.value?.focusNextEditableCell?.(line.clientId, activeLookupField.value)
}
function applyReset(kind:'new' | 'copy') {
if (kind === 'new') vm.createNew()
else vm.copyCurrent()
pendingReset.value = null
}
function requestReset(kind:'new' | 'copy') {
if (readonly.value && kind === 'copy') {
applyReset(kind)
return
}
if (vm.dirty.value) {
pendingReset.value = kind
return
}
applyReset(kind)
}
async function saveThenReset() {
const kind = pendingReset.value
if (!kind) return
savingReset.value = true
try {
if (await vm.save()) applyReset(kind)
} finally {
savingReset.value = false
}
}
async function executeCommand(id:string) {
if (id === 'save') await vm.save()
else if (id === 'new') requestReset('new')
else if (id === 'copy') requestReset('copy')
else if (id === 'confirm') await vm.confirm()
}
useKbxPageShortcuts([{ key:'F8', execute:() => vm.save() }])
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"
:problem="vm.problem.value"
:audit-entries="vm.auditEntries.value"
:summary-items="summaryItems"
header-title="주문 정보"
header-description="주문 조건과 배송 기준을 먼저 확인한 상품 상세를 입력합니다."
detail-title="주문 상품"
:detail-count="vm.lines.value.length"
detail-description="Enter 연속입력, F2 품목 조회, Excel 붙여넣기 문법을 동일하게 사용합니다."
breadcrumb="OMS > 주문"
@command="executeCommand"
@transition="executeCommand"
@reload-conflict="vm.reloadConflict"
@dismiss-conflict="vm.dismissConflict"
@retry-problem="vm.retryProblem"
@dismiss-problem="vm.dismissProblem"
>
<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">
<KbxDataGrid
ref="detailGrid"
:rows="vm.lines.value"
:columns="orderLineColumns"
row-key="clientId"
aria-label="주문 상품"
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"
/>
<KbxUnsavedChangesDialog
:open="Boolean(pendingReset)"
:saving="savingReset"
@stay="pendingReset=null"
@discard="pendingReset && applyReset(pendingReset)"
@save="saveThenReset"
/>
</template>
<style scoped>
.order-lines {
min-height:var(--kbx-grid-min-height);
display:flex;
flex-direction:column;
gap:var(--kbx-space-2);
}
</style>
@@ -0,0 +1,22 @@
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
export interface OrderLineForm { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number|null; orderQty:number; unitPrice:number; amount:number; remark:string }
export const orderWorkflow:KbxWorkflowDefinition={id:'oms.order.lifecycle',version:'1.0.0',states:[
{value:'신규',label:'신규',semantic:'draft'},{value:'작성',label:'작성',semantic:'draft'},{value:'확정',label:'확정',semantic:'pending'},{value:'할당',label:'할당',semantic:'processing'},{value:'피킹',label:'피킹',semantic:'processing'},{value:'검수',label:'검수',semantic:'processing'},{value:'출고완료',label:'출고완료',semantic:'completed',terminal:true}],
transitions:[{id:'confirm',from:['작성'],to:'확정',label:'주문확정',permission:'oms.order.confirm',confirm:true}]}
export const orderStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'작성',editability:'editable'},{status:'확정',editability:'readonly',message:'확정된 주문은 직접 수정할 수 없습니다.'},{status:'할당',editability:'readonly'},{status:'피킹',editability:'readonly'},{status:'검수',editability:'readonly'},{status:'출고완료',editability:'readonly'}]
export const orderRegisterScreen = defineKbxScreen({
id:'OMS-ORD-002',version:'1.3.0',module:'OMS',type:'transaction', templateCode:'T03',title:'주문등록',helpKey:'OMS-ORD-002',permissions:['oms.order.read','erp.item.read'],
commands:[
{id:'new',label:'신규',group:'edit'},
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'oms.order.create','작성':'oms.order.write'},allowedStatuses:['신규','작성'],requiresDirty:true},
{id:'confirm',label:'주문확정',group:'workflow',permission:'oms.order.confirm',allowedStatuses:['작성'],requiresClean:true,disabledReason:'변경사항을 저장한 뒤 주문을 확정하세요.',confirm:{title:'주문을 확정하시겠습니까?',detail:'확정 후 주문의 일반 입력 필드는 직접 수정할 수 없습니다.',level:'high',confirmLabel:'주문확정'}},
{id:'copy',label:'주문복사',group:'edit'},
{id:'excel',label:'엑셀',group:'output'},
],telemetry:{enabled:true},
})
export const orderLineColumns:KbxGridColumn<OrderLineForm>[]=[
{field:'itemCode',header:'품목코드',type:'lookup',width:130,editable:true,pinned:'left',lookup:{entity:'item'}},{field:'itemName',header:'품목명',width:220},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'orderQty',header:'수량',type:'quantity',width:95,editable:true},{field:'unitPrice',header:'단가',type:'money',width:120,editable:true},{field:'amount',header:'금액',type:'money',width:130},{field:'remark',header:'비고',width:220,editable:true},
]
@@ -0,0 +1,31 @@
import { z } from 'zod'
import {
kbxDecimalSchema,
kbxNullableEntityIdSchema,
kbxRequiredTextSchema,
kbxTextSchema,
} from '../../../../validation/kbxFieldSchema'
export const orderHeaderSchema = z.object({
orderDate: kbxRequiredTextSchema('orderDate', '주문일을 입력하세요.'),
customerId: kbxNullableEntityIdSchema('customerId', '거래처를 선택하세요.'),
warehouseId: kbxNullableEntityIdSchema('warehouseId', '출고창고를 선택하세요.'),
receiverName: kbxRequiredTextSchema('receiverName'),
phone: kbxRequiredTextSchema('phone'),
postalCode: kbxTextSchema('postalCode').optional(),
address1: kbxRequiredTextSchema('address1'),
address2: kbxTextSchema('address2').optional(),
})
export const orderLineSchema = z.object({
clientId: z.string(),
itemId: kbxNullableEntityIdSchema('itemId', '품목을 선택하세요.'),
// Positive quantity is an order rule, so the field dictionary only supplies numeric structure.
orderQty: kbxDecimalSchema('orderQty').positive('수량은 0보다 커야 합니다.'),
unitPrice: kbxDecimalSchema('unitPrice').nonnegative('단가는 0 이상이어야 합니다.'),
})
export const orderRegisterSchema = z.object({
header: orderHeaderSchema,
lines: z.array(orderLineSchema).min(1, '주문 품목을 한 건 이상 입력하세요.'),
})
@@ -0,0 +1,14 @@
import { isKbxProblem, type KbxAuditEntry, type KbxProblem } from '@kbx/contracts'
import { kbxApi } from '../../../../http/generated/kbxApiClient'
export interface RegisterOrderRequest { orderId?:string; version?:number; orderDate:string; customerId:string; warehouseId:string; receiverName:string; phone:string; postalCode?:string; address1:string; address2?:string; lines:Array<{clientId:string;itemId:string;orderQty:number;unitPrice:number;remark?:string}> }
export interface RegisterOrderResponse { orderId:string; orderNo:string; version:number; status:string }
export interface OrderEditResponse extends RegisterOrderResponse { orderDate:string; customerId:string; warehouseId:string; receiverName:string; phone:string; postalCode?:string; address1:string; address2?:string; lines:Array<{id:string;clientId:string;itemId:string;itemCode:string;itemName:string;orderQty:number;unitPrice:number;amount:number;remark:string}> }
export interface ItemLookupResult { id:string; code:string; displayName:string; status?:string }
export function toKbxProblem(error:unknown):KbxProblem|null{return isKbxProblem(error)?error:null}
export const orderRegisterApi={
async resolveItemByCode(code:string){try{return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode',{path:{code}})}catch(error){if(isKbxProblem(error)&&error.type==='not-found')return null;throw error}},
save(request:RegisterOrderRequest){return request.orderId?kbxApi.request<RegisterOrderResponse,RegisterOrderRequest>('oms.orders.update',{path:{id:request.orderId},body:request}):kbxApi.request<RegisterOrderResponse,RegisterOrderRequest>('oms.orders.register',{body:request})},
get(orderId:string){return kbxApi.request<OrderEditResponse>('oms.orders.get',{path:{id:orderId}})},
confirm(orderId:string,version:number){return kbxApi.request<RegisterOrderResponse>('oms.orders.confirm',{path:{id:orderId},body:{version},idempotencyKey:`order-confirm:${orderId}:${version}`})},
audit(orderId:string){return kbxApi.request<KbxAuditEntry[]>('oms.orders.audit',{path:{id:orderId}})},
}
@@ -0,0 +1,38 @@
import { computed, reactive, ref } from 'vue'
import { useMutation } from '@tanstack/vue-query'
import type { KbxAuditEntry, KbxConflictSnapshot, KbxProblem, KbxValidationError } from '@kbx/contracts'
import { isKbxProblem } from '@kbx/contracts'
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
import { orderRegisterSchema } from './order-register.schema'
import { orderRegisterApi, toKbxProblem } from './orderRegisterApi'
import type { OrderLineForm } from './order-register.definition'
import { orderRegisterScreen } from './order-register.definition'
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
interface OrderHeaderForm { orderDate:string;customerId:string|null;warehouseId:string|null;receiverName:string;phone:string;postalCode:string;address1:string;address2:string }
function localDate(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}
function newLine():OrderLineForm{return{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',availableQty:null,orderQty:1,unitPrice:0,amount:0,remark:''}}
export function useOrderRegistration(){
const orderId=ref<string>();const orderNo=ref<string>();const version=ref<number>();const status=ref('신규');const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null);const problem=ref<KbxProblem|null>(null);const failedAction=ref<'save'|'confirm'|null>(null)
const header=reactive<OrderHeaderForm>({orderDate:localDate(),customerId:null,warehouseId:null,receiverName:'',phone:'',postalCode:'',address1:'',address2:''});const lines=ref<OrderLineForm[]>([newLine()])
const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState();const validation=useKbxValidation();const mutation=useMutation({mutationFn:orderRegisterApi.save})
const totalQty=computed(()=>lines.value.reduce((s,l)=>s+Number(l.orderQty||0),0));const totalAmount=computed(()=>lines.value.reduce((s,l)=>s+Number(l.amount||0),0))
function addLine(){lines.value.push(newLine());touch()}function duplicateLines(source:OrderLineForm[]){if(!source.length)return;lines.value.push(...source.map(l=>({...l,clientId:crypto.randomUUID()})));touch()}function recalc(l:OrderLineForm){l.amount=Number(l.orderQty||0)*Number(l.unitPrice||0)}
async function onCellChanged(event:{row:OrderLineForm;field:keyof OrderLineForm;newValue:unknown}){if(event.field==='itemCode'){const code=String(event.newValue??'').trim();if(!code)Object.assign(event.row,{itemId:null,itemName:'',availableQty:null});else{const item=await orderRegisterApi.resolveItemByCode(code);if(!item){Object.assign(event.row,{itemId:null,itemName:'',availableQty:null});validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})}else{Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName});validation.setRowFieldError(event.row.clientId,'itemCode')}}}if(event.field==='orderQty'||event.field==='unitPrice')recalc(event.row);touch()}
function applyItemLookup(line:OrderLineForm,item:{id:string;code:string;displayName:string}){Object.assign(line,{itemId:item.id,itemCode:item.code,itemName:item.displayName});validation.setRowFieldError(line.clientId,'itemCode');touch()}
function toValidationErrors():KbxValidationError[]{const parsed=orderRegisterSchema.safeParse({header,lines:lines.value});if(parsed.success)return[];return parsed.error.issues.map(issue=>{const p=issue.path;if(p[0]==='header')return{field:String(p[1]??''),code:'CLIENT_VALIDATION',message:issue.message};if(p[0]==='lines'&&typeof p[1]==='number'){const raw=String(p[2]??'');return{rowKey:lines.value[p[1]]?.clientId,field:raw==='itemId'?'itemCode':raw,code:'CLIENT_VALIDATION',message:issue.message}}return{code:'CLIENT_VALIDATION',message:issue.message}})}
async function refreshAudit(){auditEntries.value=orderId.value?await orderRegisterApi.audit(orderId.value):[]}
async function load(id:string){problem.value=null;failedAction.value=null;const r=await orderRegisterApi.get(id);orderId.value=r.orderId;orderNo.value=r.orderNo;version.value=r.version;status.value=r.status;Object.assign(header,{orderDate:r.orderDate,customerId:r.customerId,warehouseId:r.warehouseId,receiverName:r.receiverName,phone:r.phone,postalCode:r.postalCode??'',address1:r.address1,address2:r.address2??''});lines.value=r.lines.map(l=>({...l,availableQty:null}));validation.clear();conflict.value=null;resetDirty();await refreshAudit()}
async function save(){problem.value=null;failedAction.value=null;const task=startKbxTask(orderRegisterScreen.id,orderRegisterScreen.version,'save-order');validation.clear();const local=toValidationErrors();if(local.length){validation.setErrors(local);task.abandon('client-validation');return false}try{const r=await mutation.mutateAsync({orderId:orderId.value,version:version.value,orderDate:header.orderDate,customerId:header.customerId!,warehouseId:header.warehouseId!,receiverName:header.receiverName,phone:header.phone,postalCode:header.postalCode||undefined,address1:header.address1,address2:header.address2||undefined,lines:lines.value.map(l=>({clientId:l.clientId,itemId:l.itemId!,orderQty:l.orderQty,unitPrice:l.unitPrice,remark:l.remark||undefined}))});orderId.value=r.orderId;orderNo.value=r.orderNo;version.value=r.version;status.value=r.status;markSaved();conflict.value=null;await refreshAudit();task.complete('success');return true}catch(error){const issue=toKbxProblem(error);if(issue?.type==='validation')validation.applyProblem(issue);if(issue?.type==='conflict'&&orderId.value){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:issue.code,title:issue.title,detail:'입력 중인 주문은 유지됩니다. 최신 값과 비교한 뒤 다시 읽으세요.',entityId:orderId.value,requestedVersion:version.value,currentVersion:issue.currentVersion??latest.version,changes:[['orderDate','주문일'],['customerId','거래처'],['warehouseId','출고창고'],['receiverName','수취인'],['address1','주소']].map(([field,label])=>({field,label,mine:(header as any)[field],latest:(latest as any)[field]}))}}if(issue && !['validation','conflict'].includes(issue.type)){ problem.value=issue; failedAction.value='save' }task.abandon(issue?.type??'system');if(!issue)throw error;return false}}
async function confirm(){if(!orderId.value||version.value==null||dirty.value)return;problem.value=null;failedAction.value=null;try{const r=await orderRegisterApi.confirm(orderId.value,version.value);version.value=r.version;status.value=r.status;await refreshAudit()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:e.code,title:e.title,requestedVersion:version.value,currentVersion:e.currentVersion??latest.version,entityId:orderId.value};return}if(isKbxProblem(e)){problem.value=e;failedAction.value='confirm';return}throw e}}
async function reloadConflict(){if(orderId.value)await load(orderId.value)}
function dismissConflict(){conflict.value=null}
async function retryProblem(){const action=failedAction.value;problem.value=null;failedAction.value=null;if(action==='confirm')return confirm();if(action==='save')return save()}
function dismissProblem(){problem.value=null;failedAction.value=null}
function createNew(){orderId.value=undefined;orderNo.value=undefined;version.value=undefined;status.value='신규';Object.assign(header,{orderDate:localDate(),customerId:null,warehouseId:null,receiverName:'',phone:'',postalCode:'',address1:'',address2:''});lines.value=[newLine()];auditEntries.value=[];conflict.value=null;problem.value=null;failedAction.value=null;validation.clear();resetDirty()}
function copyCurrent(){
orderId.value=undefined;orderNo.value=undefined;version.value=undefined;status.value='신규'
lines.value=lines.value.map(line=>({...line,clientId:crypto.randomUUID()}))
auditEntries.value=[];conflict.value=null;problem.value=null;failedAction.value=null;validation.clear();resetDirty();touch()
}
return{orderId,orderNo,version,status,header,lines,dirty,errors:validation.errors,fieldError:validation.fieldError,totalQty,totalAmount,saving:computed(()=>mutation.isPending.value),auditEntries,conflict,problem,addLine,duplicateLines,onCellChanged,applyItemLookup,save,confirm,load,reloadConflict,dismissConflict,retryProblem,dismissProblem,createNew,copyCurrent,touch}
}
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { KbxButton, KbxDataState, KbxDrawer, KbxStatus } from '@kbx/ui'
import type { KbxStatusSemantic } from '@kbx/contracts'
import type { OrderDetailResponse } from './orderApi'
import type { OrderSearchRow } from './order-list.definition'
const props = defineProps<{
open:boolean
summary:OrderSearchRow | null
detail:OrderDetailResponse | null
loading?:boolean
error?:string
}>()
const emit = defineEmits<{
'update:open':[boolean]
retry:[]
fullDetail:[string]
}>()
const title = computed(() => props.summary?.orderNo ? `주문 ${props.summary.orderNo}` : '주문 상세')
function statusSemantic(status:string):KbxStatusSemantic {
if (['SHIPPED','출고완료','COMPLETED','완료'].includes(status)) return 'completed'
if (['HOLD','보류'].includes(status)) return 'hold'
if (['ERROR','오류'].includes(status)) return 'error'
if (['CONFIRMED','확정','READY','출고대기'].includes(status)) return 'pending'
return 'processing'
}
const maskedPhone = computed(() => {
const value = props.detail?.phone ?? ''
const digits = value.replace(/\D/g, '')
if (digits.length < 7) return value
return `${digits.slice(0,3)}-****-${digits.slice(-4)}`
})
</script>
<template>
<KbxDrawer :open="open" :title="title" width="580px" @update:open="emit('update:open',$event)">
<KbxDataState v-if="loading" state="loading" title="주문 상세를 불러오는 중입니다." />
<KbxDataState v-else-if="error" state="error" title="주문 상세를 불러오지 못했습니다." :detail="error" action-label="다시 조회" @action="emit('retry')" />
<div v-else-if="detail" class="order-detail" data-kbx-order-detail>
<section class="order-detail__headline">
<div><span>주문상태</span><KbxStatus :label="detail.status" :semantic="statusSemantic(detail.status)" /></div>
<div><span>판매채널</span><strong>{{summary?.channelName || '-'}}</strong></div>
<div><span>주문일</span><strong>{{detail.orderDate}}</strong></div>
</section>
<section>
<h3>수취인</h3>
<dl><dt>이름</dt><dd>{{detail.receiverName}}</dd><dt>연락처</dt><dd>{{maskedPhone}}</dd></dl>
</section>
<section>
<h3>배송</h3>
<p>{{detail.postalCode ? `[${detail.postalCode}] ` : ''}}{{detail.address1}} {{detail.address2}}</p>
</section>
<section>
<h3>상품 <small>{{detail.lines.length}}</small></h3>
<div class="order-detail__lines">
<article v-for="line in detail.lines" :key="line.id || line.clientId">
<div><b>{{line.itemCode}}</b><span>{{line.itemName}}</span></div>
<strong>{{Number(line.orderQty).toLocaleString('ko-KR')}} EA</strong>
</article>
</div>
</section>
</div>
<template #footer>
<div class="order-detail__footer">
<KbxButton label="닫기" variant="secondary" @click="emit('update:open',false)" />
<KbxButton v-if="detail" label="전체 상세" variant="primary" @click="emit('fullDetail',detail.orderId)" />
</div>
</template>
</KbxDrawer>
</template>
<style scoped>
.order-detail{display:flex;flex-direction:column;gap:var(--kbx-space-4);font-size:var(--kbx-font-md)}
.order-detail section{border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);padding-bottom:var(--kbx-space-3)}
.order-detail section:last-child{border-bottom:0}.order-detail h3{margin:0 0 var(--kbx-space-2);font-size:var(--kbx-font-lg)}
.order-detail h3 small{margin-left:var(--kbx-space-1);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);font-weight:400}
.order-detail__headline{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--kbx-space-2)}
.order-detail__headline>div{display:grid;gap:var(--kbx-space-1)}.order-detail__headline span,dt{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
dl{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);margin:0}dd{margin:0}.order-detail p{margin:0;line-height:1.6}
.order-detail__lines{border:var(--kbx-border-width) solid var(--kbx-color-border)}.order-detail__lines article{min-height:var(--kbx-control-lg);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);padding:var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.order-detail__lines article:last-child{border-bottom:0}.order-detail__lines article>div{display:grid;min-width:0}.order-detail__lines span{color:var(--kbx-color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.order-detail__footer{display:flex;justify-content:flex-end;gap:var(--kbx-space-2)}
@media(max-width:42rem){.order-detail__headline{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
KbxDataGrid,
KbxListPage,
KbxQuickFilterBar,
KbxSearchPanel,
KbxSummaryBar,
useKbxPageShortcuts,
} from '@kbx/ui'
import { orderColumns, orderListScreen, orderSearchFields, type OrderSearchRow } from './order-list.definition'
import { orderApi, type OrderDetailResponse } from './orderApi'
import { orderImportApi } from '../import/orderImportApi'
import OrderDetailDrawer from './OrderDetailDrawer.vue'
import { useOrderSearch } from './useOrderSearch'
import { useKbxExperiment } from '../../../../experiments/kbxExperimentClient'
const vm = useOrderSearch()
const router = useRouter()
const grid = ref<any>(null)
const detailOpen = ref(false)
const detailSummary = ref<OrderSearchRow | null>(null)
const detail = ref<OrderDetailResponse | null>(null)
const detailLoading = ref(false)
const detailError = ref('')
const pageNotice = ref('')
let detailSequence = 0
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()
}
async function openDetail(row:OrderSearchRow) {
const sequence = ++detailSequence
detailSummary.value = row
detail.value = null
detailError.value = ''
detailLoading.value = true
detailOpen.value = true
try {
const result = await orderApi.getDetail(row.id)
if (sequence === detailSequence) detail.value = result
} catch {
if (sequence === detailSequence) detailError.value = '네트워크 상태를 확인한 후 다시 시도하세요.'
} finally {
if (sequence === detailSequence) detailLoading.value = false
}
}
function closeDetail() {
detailSequence += 1
detailOpen.value = false
detailLoading.value = false
}
async function openFullDetail(orderId:string) {
closeDetail()
await router.push({ name:'oms-order-edit', params:{ orderId } })
}
async function executeCommand(id:string) {
pageNotice.value = ''
if (id === 'excel.export') {
const total = vm.result.value?.totalCount ?? 0
if (total > vm.rows.value.length) {
pageNotice.value = `전체 ${total.toLocaleString('ko-KR')}건 중 현재 로드된 ${vm.rows.value.length.toLocaleString('ko-KR')}건만 있습니다. 누락 방지를 위해 전체 조회결과 Export API가 연결될 때까지 다운로드를 차단합니다.`
return
}
grid.value?.exportCsv?.()
pageNotice.value = `${total.toLocaleString('ko-KR')}건을 CSV로 다운로드했습니다.`
return
}
if (id === 'excel.template') {
await orderImportApi.downloadTemplate()
return
}
if (id === 'excel.import') return router.push({ name:'oms-order-import' })
if (id === 'excel.paste') return router.push({ name:'oms-order-import', query:{ source:'clipboard' } })
if (id === 'excel.history') return router.push({ name:'oms-order-import', query:{ view:'history' } })
await vm.executeCommand(id)
}
const quickFilters = computed(() => {
const counters = vm.result.value?.counters
if (!counters) return []
return [
{ key:'all', label:'전체', count:counters.all, active:!vm.search.status && !vm.search.exceptionOnly },
{ key:'new', label:'신규', count:counters.new, active:vm.search.status === 'NEW' },
{ key:'ready', label:'출고대기', count:counters.readyToShip, active:vm.search.status === 'READY' },
{ key:'exceptions', label:'오류', count:counters.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)"
:problem="vm.problem.value"
empty-action-label="조회조건 초기화"
breadcrumb="OMS > 주문"
@command="executeCommand"
@empty-action="vm.resetAndSearch"
@retry-problem="vm.retryProblem"
@dismiss-problem="vm.dismissProblem"
>
<template #notice><div v-if="pageNotice || vm.preferenceNotice.value" class="page-notice" role="status">{{pageNotice || vm.preferenceNotice.value}}</div></template>
<template #search>
<KbxSearchPanel
:model-value="vm.search"
:fields="orderSearchFields"
remember
saved-search
:remember-checked="vm.rememberSearch.value"
@update:model-value="value => Object.assign(vm.search, value)"
@update:remember-checked="vm.setRememberSearch"
@saved-search-requested="vm.saveCurrentSearchDefaults"
@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
ref="grid"
:rows="vm.rows.value"
:columns="orderColumns"
row-key="id"
aria-label="주문 조회 결과"
selection="multiple"
:loading="vm.loading.value"
:total-count="vm.result.value?.totalCount ?? 0"
:selection-state="vm.selectionState.value"
allow-all-filtered-selection
export-file-name="orders-current-page.csv"
@selection-state-changed="value => vm.selectionState.value = value"
@drill-down-requested="openDetail"
@row-double-clicked="openDetail"
/>
</template>
<template #summary>
<KbxSummaryBar v-if="summaryItems.length" :items="summaryItems" />
</template>
<template #detail>
<OrderDetailDrawer
:open="detailOpen"
:summary="detailSummary"
:detail="detail"
:loading="detailLoading"
:error="detailError"
@update:open="value => value ? detailOpen = true : closeDetail()"
@retry="detailSummary && openDetail(detailSummary)"
@full-detail="openFullDetail"
/>
</template>
</KbxListPage>
</template>
<style scoped>
.page-notice{padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}
.experiment-summary {
margin-bottom:var(--kbx-space-1);
padding:var(--kbx-space-2);
border:var(--kbx-border-width) solid var(--kbx-color-border);
background:var(--kbx-color-surface-muted);
}
</style>
@@ -0,0 +1,59 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/contracts'
export interface OrderSearchRow {
id: string
orderNo: string
channelName: string
orderedAt: string
customerName: string
itemSummary: string
totalQty: number
amount: number
allocationStatus: string
shipmentStatus: string
exceptionCount: number
}
export const orderListScreen = defineKbxScreen({
id: 'OMS-ORD-001',
version: '1.3.0',
module: 'OMS',
type: 'list', templateCode:'T01',
title: '주문관리',
description: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
permissions: ['oms.order.read'],
helpKey: 'OMS-ORD-001',
telemetry: { enabled: true },
commands: [
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
{ id: 'new', label: '신규', group: 'edit', permission: 'oms.order.create' },
{
id: 'ship', label: '출고지시', group: 'workflow', variant: 'primary',
requiresSelection: true, minSelection: 1, permission: 'oms.order.ship'
},
{ id: 'excel', label: '엑셀', group: 'output', menu: 'excel' },
],
})
export const orderColumns: KbxGridColumn<OrderSearchRow>[] = [
{ field: 'orderNo', header: '주문번호', type: 'link', width: 150, pinned: 'left' },
{ field: 'channelName', header: '판매채널', width: 110 },
{ field: 'orderedAt', header: '주문일시', type: 'datetime', width: 160 },
{ field: 'customerName', header: '주문자', width: 120 },
{ field: 'itemSummary', header: '대표상품', width: 240 },
{ field: 'totalQty', header: '수량', type: 'quantity', width: 90 },
{ field: 'amount', header: '금액', type: 'money', width: 130 },
{ field: 'allocationStatus', header: '재고', type: 'status', width: 100 },
{ field: 'shipmentStatus', header: '출고상태', type: 'status', width: 110 },
{ field: 'exceptionCount', header: '오류', type: 'integer', width: 80 },
]
export const orderSearchFields: KbxSearchField[] = [
{ key: 'period', label: '주문기간', type: 'date-range', range: { from: 'from', to: 'to' } },
{ key: 'status', label: '상태', type: 'select', options: [
{ value: 'READY', label: '출고대기' },
{ value: 'SHIPPED', label: '출고완료' },
{ value: 'HOLD', label: '보류' },
] },
{ key: 'keyword', label: '통합검색', type: 'text', width: 'lg', placeholder: '주문번호/주문자/상품' },
]
@@ -0,0 +1,59 @@
import { kbxApi } from '../../../../http/generated/kbxApiClient'
import type { KbxBulkSelectionRequest } from '@kbx/contracts'
import type { OrderSearchRow } from './order-list.definition'
export interface OrderSearchFilter {
from: string
to: string
channelId?: string | null
status?: string | null
exceptionOnly?: boolean
keyword?: string
page: number
pageSize: number
}
export type OrderBulkFilter = Omit<OrderSearchFilter, 'page' | 'pageSize'>
export interface OrderSearchResponse {
items: OrderSearchRow[]
totalCount: number
totalQty: number
totalAmount: number
counters: { all: number; new: number; readyToShip: number; exceptions: number }
}
export interface ShipOrdersResult { requested: number; accepted: number; rejected: number }
export interface OrderDetailResponse {
orderId:string
orderNo:string
version:number
status:string
orderDate:string
customerId:string
warehouseId:string
receiverName:string
phone:string
postalCode?:string
address1:string
address2?:string
lines:Array<{id:string;clientId:string;itemId:string;itemCode:string;itemName:string;orderQty:number;unitPrice:number;amount:number;remark:string}>
}
export const orderApi = {
search(filter: OrderSearchFilter) {
return kbxApi.request<OrderSearchResponse>('oms.orders.search', { query: filter })
},
getDetail(orderId:string) {
return kbxApi.request<OrderDetailResponse>('oms.orders.get', { path:{ id:orderId } })
},
ship(selection: KbxBulkSelectionRequest<OrderBulkFilter>) {
return kbxApi.request<ShipOrdersResult, KbxBulkSelectionRequest<OrderBulkFilter>>('oms.orders.ship', {
body: selection,
idempotencyKey: crypto.randomUUID(),
})
},
}
@@ -0,0 +1,205 @@
import { computed, inject, onMounted, reactive, ref } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useRoute, useRouter } from 'vue-router'
import { isKbxProblem, type KbxProblem, 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'
import { KbxScreenPreferenceScopeKey, loadScreenPreference, saveScreenPreference } from '../../../../preferences/screenPreferenceStore'
function todayIso() { return new Date().toISOString().slice(0, 10) }
export function useOrderSearch() {
const router = useRouter()
const route = useRoute()
const queryClient = useQueryClient()
const selectionState = ref<KbxSelectionState<string>>({ mode:'explicit', selectedIds:[] })
const preferenceScope = inject(KbxScreenPreferenceScopeKey, ref(''))
const rememberSearch = ref(false)
const savedSearchDefaults = ref<Record<string, unknown> | undefined>()
const preferenceNotice = ref('')
const problem = ref<KbxProblem | null>(null)
const failedCommand = ref<'ship' | null>(null)
const search = reactive<OrderSearchFilter>({
from: todayIso(), to: todayIso(), channelId: null, status: null,
exceptionOnly: false, keyword: '', page: 1, pageSize: 100,
})
const appliedSearch = ref<OrderSearchFilter>({ ...search })
function firstQueryValue(value: unknown) {
return Array.isArray(value) ? value[0] : value
}
function hydrateSearchFromRoute() {
let contextual = false
const from = String(firstQueryValue(route.query.from) ?? '')
const to = String(firstQueryValue(route.query.to) ?? '')
const status = String(firstQueryValue(route.query.status) ?? '')
const exceptionOnly = String(firstQueryValue(route.query.exceptionOnly) ?? '')
const keyword = String(firstQueryValue(route.query.keyword) ?? '')
const channelId = String(firstQueryValue(route.query.channelId) ?? '')
if (/^\d{4}-\d{2}-\d{2}$/.test(from)) { search.from = from; contextual = true }
if (/^\d{4}-\d{2}-\d{2}$/.test(to)) { search.to = to; contextual = true }
if (['NEW','READY','SHIPPED','HOLD'].includes(status)) { search.status = status; contextual = true }
if (exceptionOnly === 'true') { search.exceptionOnly = true; contextual = true }
if (keyword) { search.keyword = keyword.slice(0, 100); contextual = true }
if (channelId) { search.channelId = channelId.slice(0, 64); contextual = true }
return contextual
}
function safePersistableSearch() {
return {
from: /^\d{4}-\d{2}-\d{2}$/.test(search.from) ? search.from : todayIso(),
to: /^\d{4}-\d{2}-\d{2}$/.test(search.to) ? search.to : todayIso(),
channelId: typeof search.channelId === 'string' ? search.channelId.slice(0, 64) : null,
status: ['NEW','READY','SHIPPED','HOLD'].includes(String(search.status)) ? search.status : null,
exceptionOnly: Boolean(search.exceptionOnly),
}
}
function applySafeSearchDefaults(value: Record<string, unknown> | undefined) {
if (!value) return false
let applied = false
if (typeof value.from === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.from)) { search.from = value.from; applied = true }
if (typeof value.to === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.to)) { search.to = value.to; applied = true }
if (typeof value.channelId === 'string' && value.channelId.length <= 64) { search.channelId = value.channelId || null; applied = true }
if (value.channelId === null) { search.channelId = null; applied = true }
if (typeof value.status === 'string' && ['NEW','READY','SHIPPED','HOLD'].includes(value.status)) { search.status = value.status; applied = true }
if (value.status === null) { search.status = null; applied = true }
if (typeof value.exceptionOnly === 'boolean') { search.exceptionOnly = value.exceptionOnly; applied = true }
// Free-text keyword is intentionally never restored from local preference storage.
search.keyword = ''
return applied
}
function persistPreference(message: string) {
const scope = preferenceScope.value
if (!scope) return false
const saved = saveScreenPreference(scope, {
screenId: orderListScreen.id,
screenVersion: orderListScreen.version,
rememberSearch: rememberSearch.value,
searchDefaults: savedSearchDefaults.value,
})
preferenceNotice.value = saved ? message : '개인화 설정을 저장하지 못했습니다. 현재 업무는 계속 사용할 수 있습니다.'
return saved
}
function setRememberSearch(value: boolean) {
rememberSearch.value = value
if (value) savedSearchDefaults.value = safePersistableSearch()
persistPreference(value ? '마지막 조회조건 기억을 켰습니다. 자유검색어는 저장하지 않습니다.' : '마지막 조회조건 기억을 껐습니다.')
}
function saveCurrentSearchDefaults() {
savedSearchDefaults.value = safePersistableSearch()
persistPreference('현재 조회조건을 이 화면의 기본조건으로 저장했습니다. 자유검색어는 저장하지 않습니다.')
}
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() {
problem.value = null
failedCommand.value = null
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()
if (rememberSearch.value) { savedSearchDefaults.value = safePersistableSearch(); persistPreference('마지막 조회조건을 기억했습니다. 자유검색어는 저장하지 않습니다.') }
kbxTelemetry.track('command.succeeded',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,durationMs:Math.round(performance.now()-started),attributes:{commandId:'search',operationKind:'query'}})
}
async function resetAndSearch() {
Object.assign(search, { from:todayIso(), to:todayIso(), channelId:null, status:null, exceptionOnly:false, keyword:'', page:1, pageSize:100 })
await executeSearch()
}
onMounted(() => {
const contextual = hydrateSearchFromRoute()
const preference = loadScreenPreference(preferenceScope.value, orderListScreen.id, orderListScreen.version)
rememberSearch.value = Boolean(preference?.rememberSearch)
savedSearchDefaults.value = preference?.searchDefaults
if (!contextual && savedSearchDefaults.value) applySafeSearchDefaults(savedSearchDefaults.value)
if (contextual) void executeSearch()
})
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());problem.value=null;failedCommand.value=null;task.complete('success')}catch(error){task.abandon('failed');if(isKbxProblem(error)){problem.value=error;failedCommand.value='ship';return}throw error}
},
}
return handlers[commandId]?.()
}
async function retryProblem() {
const action = failedCommand.value
problem.value = null
failedCommand.value = null
if (action === 'ship' && selectionCount.value > 0) return executeCommand('ship')
}
function dismissProblem() {
problem.value = null
failedCommand.value = null
}
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,
rememberSearch,
preferenceNotice,
problem,
setRememberSearch,
saveCurrentSearchDefaults,
executeSearch,
resetAndSearch,
executeCommand,
retryProblem,
dismissProblem,
}
}
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { KbxBarcodeCapture, KbxProblemFeedback, KbxWmsActionButton, KbxWmsMobilePage, playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
import { countingScreen } from './counting.definition'
import { createWmsExecutionNotConnectedProblem, wmsFrontendScenarioMode } from '../shared/wmsFrontendCapability'
const props=defineProps<{taskId:string}>()
const {online}=useKbxNetworkState()
const stage=ref<'location'|'item'|'counted'>('location')
const location=ref('')
const item=ref('')
const counted=ref(0)
const bookQty=3
const expectedLocation='C-01-01'
const expectedBarcode='8801234567003'
const message=ref('실사 위치를 스캔하세요.')
const problem=createWmsExecutionNotConnectedProblem('재고 실사')
const difference=computed(()=>counted.value-bookQty)
function scan(value:string){
if(!wmsFrontendScenarioMode)return
if(stage.value==='location'){
if(value!==expectedLocation){message.value=`다른 위치입니다. 실사 위치 ${expectedLocation}로 이동하세요.`;playKbxWmsFeedback('error');return}
location.value=value;stage.value='item';message.value='실사 상품을 스캔하세요.';playKbxWmsFeedback('success');return
}
if(stage.value==='item'){
if(value!==expectedBarcode){message.value=`다른 상품입니다. 필요한 바코드는 ${expectedBarcode}입니다.`;playKbxWmsFeedback('error');return}
counted.value+=1;item.value=value;message.value=`실사 ${counted.value}개를 확인했습니다. 계속 스캔하거나 수량을 확정하세요.`;playKbxWmsFeedback('success')
}
}
function finish(){stage.value='counted';message.value=difference.value===0?'장부수량과 일치합니다.':'차이가 있습니다. 운영에서는 관리자 확인 대상으로 Server에 등록해야 합니다.';playKbxWmsFeedback(difference.value===0?'success':'warning')}
function reset(){stage.value='location';location.value='';item.value='';counted.value=0;message.value='실사 위치를 스캔하세요.'}
</script>
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :task-context="`Task ${props.taskId}`" :online="online" :actions-visible="wmsFrontendScenarioMode"><template #notice><KbxProblemFeedback v-if="!wmsFrontendScenarioMode" :problem="problem" :dismissible="false" /></template><template v-if="wmsFrontendScenarioMode"><p class="msg" role="status" aria-live="polite">{{message}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>기능성 양말</strong><span>{{item}}</span><p>실사 {{counted}} · 장부 {{bookQty}} · 차이 {{difference>0?'+':''}}{{difference}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'LOCATION SCAN':'ITEM SCAN'" @scan="event=>scan(event.normalizedValue)" /></template><template #actions><template v-if="wmsFrontendScenarioMode"><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" :disabled="!online" @click="finish"/><KbxWmsActionButton v-else-if="stage==='counted'" label="다음 위치" @click="reset"/></template></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.box{display:flex;flex-direction:column;gap:var(--kbx-space-1);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);margin-bottom:var(--kbx-space-3)}.box small,.box span{color:var(--kbx-color-text-muted)}.box strong{font-size:var(--kbx-font-2xl)}.box p{font-size:var(--kbx-font-lg);font-weight:650}</style>
@@ -0,0 +1,3 @@
import { defineKbxScreen } from '@kbx/ui'
export const countingScreen=defineKbxScreen({id:'WMS-COUNT-001',version:'1.1.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'재고실사',helpKey:'WMS-COUNT-001',permissions:['wms.inventory.count'],telemetry:{enabled:true}})
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsCountingRoutes: RouteRecordRaw[] = [
{ path:'/wms/counting/:taskId', name:'wms-counting', component:()=>import('./WmsCountingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-COUNT-001' } },
]
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { KbxWmsExceptionType } from '@kbx/contracts'
import { KbxWmsActionButton } from '@kbx/ui'
defineProps<{ busy?: boolean }>()
const emit = defineEmits<{
close: []
submit: [KbxWmsExceptionType, string]
}>()
const type = ref<KbxWmsExceptionType>('no-stock')
const memo = ref('')
const options: { value: KbxWmsExceptionType; label: string }[] = [
{ value: 'no-stock', label: '실물 없음' },
{ value: 'short-quantity', label: '수량 부족' },
{ value: 'wrong-location', label: '위치 오류' },
{ value: 'damaged-item', label: '상품 이상' },
{ value: 'barcode-issue', label: '바코드 문제' },
{ value: 'other', label: '기타' },
]
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="exception-title">
<h2 id="exception-title">피킹 문제 신고</h2>
<p>가장 가까운 사유 하나만 선택하세요.</p>
<label v-for="option in options" :key="option.value" class="reason">
<input v-model="type" type="radio" :value="option.value">
<span>{{ option.label }}</span>
</label>
<label class="memo">메모 <small>선택 입력</small>
<textarea v-model="memo" rows="3" maxlength="300" />
</label>
<div class="actions">
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
<KbxWmsActionButton label="문제 등록" :busy="busy" @click="$emit('submit', type, memo)" />
</div>
</section>
</div>
</template>
<style scoped>
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:30; }
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; max-height:85dvh; overflow:auto; }
h2 { margin:0 0 4px; font-size:20px; }
p { margin:0 0 14px; color:var(--kbx-color-text-muted); }
.reason { min-height:48px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--kbx-color-border); }
.reason input { width:20px; height:20px; }
.memo { display:block; margin-top:16px; font-weight:600; }
.memo small { font-weight:400; color:var(--kbx-color-text-muted); }
textarea { width:100%; margin-top:6px; border:1px solid var(--kbx-color-border); border-radius:6px; padding:10px; font:inherit; box-sizing:border-box; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxWmsActionButton } from '@kbx/ui'
const props = defineProps<{
current: number
required: number
busy?: boolean
}>()
const emit = defineEmits<{ close: []; submit: [number] }>()
const quantity = ref(props.current)
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="quantity-title">
<h2 id="quantity-title">피킹 수량 입력</h2>
<p>상품 바코드를 최소 1 확인한 사용합니다.</p>
<label>
피킹 수량
<input v-model.number="quantity" type="number" inputmode="decimal" :min="1" :max="required">
</label>
<div class="hint">필요수량 {{ required }}</div>
<div class="actions">
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
<KbxWmsActionButton
label="수량 적용"
:busy="busy"
:disabled="quantity <= 0 || quantity > required"
@click="$emit('submit', quantity)"
/>
</div>
</section>
</div>
</template>
<style scoped>
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:31; }
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; }
h2 { margin:0 0 4px; font-size:20px; }
p { margin:0 0 16px; color:var(--kbx-color-text-muted); }
label { display:block; font-weight:600; }
input { width:100%; min-height:52px; margin-top:6px; padding:0 12px; box-sizing:border-box; font:inherit; font-size:22px; text-align:right; border:1px solid var(--kbx-color-border-strong); border-radius:6px; }
.hint { margin-top:6px; text-align:right; color:var(--kbx-color-text-muted); font-size:13px; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; }
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
KbxBarcodeCapture,
KbxWmsActionButton,
KbxWmsMobilePage,
} from '@kbx/ui'
import PickingExceptionSheet from './PickingExceptionSheet.vue'
import PickingQuantitySheet from './PickingQuantitySheet.vue'
import { useWmsPicking } from './useWmsPicking'
import { pickingScreen } from './picking.definition'
const props = defineProps<{ taskId: string }>()
const vm = useWmsPicking(props.taskId)
const progress = computed(() => vm.task.value
? `${vm.task.value.completedLines} / ${vm.task.value.totalLines}`
: undefined)
const current = computed(() => vm.task.value?.currentLine ?? null)
const stageTitle = computed(() => {
switch (vm.task.value?.stage) {
case 'ready': return '작업을 시작하세요.'
case 'await-location': return '위치를 스캔하세요.'
case 'await-item': return '상품을 스캔하세요.'
case 'processing': return '처리 중입니다.'
case 'completed': return '피킹을 완료했습니다.'
case 'blocked': return '관리자 확인이 필요합니다.'
default: return '작업을 불러오는 중입니다.'
}
})
</script>
<template>
<KbxWmsMobilePage
:screen="pickingScreen"
:progress="progress"
:online="vm.online.value"
:pending-commands="vm.pendingCommands.value"
:syncing="vm.syncing.value"
:content-state="vm.contentState.value"
retry-label="작업 다시 불러오기"
@retry="vm.reload"
>
<p v-if="vm.message.value" class="message" role="status">{{ vm.message.value }}</p>
<section v-if="vm.task.value" class="instruction">
<p class="eyebrow">{{ stageTitle }}</p>
<template v-if="current">
<div class="location">
<span>LOCATION</span>
<strong>{{ current.locationCode }}</strong>
</div>
<div class="item">
<span>상품</span>
<strong>{{ current.itemName }}</strong>
<small>{{ current.itemCode }}<template v-if="current.itemOption"> · {{ current.itemOption }}</template></small>
<small>바코드 {{ current.barcode }}</small>
</div>
<div class="qty">
<div><span>필요</span><strong>{{ current.requiredQty }}</strong></div>
<div><span>피킹</span><strong>{{ current.pickedQty }}</strong></div>
<div><span>남음</span><strong>{{ current.remainingQty }}</strong></div>
</div>
</template>
<KbxWmsActionButton
v-if="current && vm.task.value.stage === 'await-item' && current.pickedQty > 0 && current.remainingQty > 0"
class="quantity-action"
label="수량 직접 입력"
variant="secondary"
@click="vm.openQuantity"
/>
<div v-else-if="vm.task.value.stage === 'ready'" class="ready">
<strong>{{ vm.task.value.taskNo }}</strong>
<span> {{ vm.task.value.totalLines }} 라인 · {{ vm.task.value.totalQty }}</span>
</div>
</section>
<KbxBarcodeCapture
v-if="vm.task.value && !['ready','completed','blocked'].includes(vm.task.value.stage)"
:enabled="vm.scanEnabled.value"
:label="vm.scanLabel.value"
@scan="vm.handleScan"
/>
<template #actions>
<KbxWmsActionButton
v-if="vm.task.value?.stage === 'ready'"
label="작업 시작"
:busy="vm.starting.value"
:disabled="!vm.online.value"
@click="vm.start"
/>
<KbxWmsActionButton
v-else-if="vm.task.value && ['await-location','await-item'].includes(vm.task.value.stage)"
label="문제 신고"
variant="secondary"
:disabled="!vm.online.value || vm.pendingCommands.value > 0"
@click="vm.openException"
/>
<KbxWmsActionButton
v-else-if="vm.task.value?.stage === 'completed'"
label="작업 목록으로"
@click="$router.push('/wms/picking')"
/>
</template>
</KbxWmsMobilePage>
<PickingQuantitySheet
v-if="vm.quantityOpen.value && current"
:current="current.pickedQty"
:required="current.requiredQty"
:busy="vm.settingQuantity.value"
@close="vm.closeQuantity"
@submit="vm.setQuantity"
/>
<PickingExceptionSheet
v-if="vm.exceptionOpen.value"
:busy="vm.reportingException.value"
@close="vm.closeException"
@submit="(type, memo) => vm.reportException(type, memo)"
/>
</template>
<style scoped>
.message { margin:0 0 12px; padding:10px 12px; background:var(--kbx-color-surface-subtle); border-radius:6px; font-size:14px; }
.instruction { text-align:center; }
.eyebrow { margin:0 0 12px; font-size:16px; font-weight:650; }
.location { padding:16px; background:var(--kbx-color-surface-muted); border:1px solid var(--kbx-color-border); border-radius:8px; }
.location span, .item span { display:block; color:var(--kbx-color-text-muted); font-size:12px; font-weight:600; }
.location strong { display:block; margin-top:3px; font-size:32px; letter-spacing:.03em; }
.item { padding:20px 4px 8px; }
.item strong { display:block; margin-top:4px; font-size:22px; }
.item small { display:block; margin-top:4px; color:var(--kbx-color-text-muted); font-size:13px; }
.qty { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:12px; }
.qty div { border:1px solid var(--kbx-color-border); border-radius:8px; padding:12px 4px; }
.qty span { display:block; font-size:13px; color:var(--kbx-color-text-muted); }
.qty strong { display:block; margin-top:2px; font-size:28px; }
.quantity-action { margin-top:var(--kbx-space-3); }
.ready { min-height:260px; display:flex; flex-direction:column; justify-content:center; gap:8px; }
.ready strong { font-size:26px; }
.ready span { color:var(--kbx-color-text-muted); }
</style>
@@ -0,0 +1,12 @@
import { defineKbxScreen } from '@kbx/ui'
export const pickingScreen = defineKbxScreen({
id: 'WMS-PICK-001',
version: '1.0.0',
module: 'WMS',
type: 'wms-mobile', templateCode:'T09',
title: '출고 피킹',
helpKey: 'WMS-PICK-001',
permissions: ['wms.picking.execute'],
telemetry: { enabled: true },
})
@@ -0,0 +1,36 @@
import type {
KbxWmsExceptionCommand,
KbxWmsPickingTask,
KbxWmsScanCommand,
KbxWmsScanResult,
KbxWmsSetQuantityCommand,
} from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export function getPickingTask(taskId: string) {
return kbxApi.request<KbxWmsPickingTask>('wms.picking.getTask', { path: { taskId } })
}
export function startPickingTask(taskId: string, expectedVersion: number, idempotencyKey = crypto.randomUUID()) {
return kbxApi.request<KbxWmsPickingTask, { expectedVersion: number }>('wms.picking.start', {
path: { taskId }, body: { expectedVersion }, idempotencyKey,
})
}
export function scanPickingBarcode(command: KbxWmsScanCommand) {
return kbxApi.request<KbxWmsScanResult, KbxWmsScanCommand>('wms.picking.scan', {
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
})
}
export function reportPickingException(command: KbxWmsExceptionCommand) {
return kbxApi.request<KbxWmsPickingTask, KbxWmsExceptionCommand>('wms.picking.reportException', {
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
})
}
export function setPickingQuantity(command: KbxWmsSetQuantityCommand) {
return kbxApi.request<KbxWmsScanResult, KbxWmsSetQuantityCommand>('wms.picking.setQuantity', {
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
})
}
@@ -0,0 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsPickingRoutes: RouteRecordRaw[] = [
{
path: '/wms/picking/:taskId',
name: 'wms-picking-task',
component: () => import('./WmsPickingPage.vue'),
props: route => ({ taskId: String(route.params.taskId) }),
},
]
@@ -0,0 +1,245 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useMutation, useQuery } from '@tanstack/vue-query'
import type {
KbxBarcodeEvent,
KbxWmsExceptionType,
KbxWmsPickingTask,
KbxWmsScanCommand,
KbxWmsSetQuantityCommand,
KbxAsyncState,
} from '@kbx/contracts'
import { isKbxProblem, isRetryableKbxProblem } from '@kbx/contracts'
import { playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
import {
getPickingTask,
reportPickingException,
scanPickingBarcode,
setPickingQuantity,
startPickingTask,
} from './pickingApi'
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
import { pickingScreen } from './picking.definition'
import { queuePickingScan, readPickingRetryQueue, removePickingScan } from './wmsRetryQueue'
function newIdempotencyKey(taskId: string) {
return `${taskId}:${crypto.randomUUID()}`
}
export function useWmsPicking(taskId: string) {
const { online } = useKbxNetworkState()
const task = ref<KbxWmsPickingTask | null>(null)
const message = ref('')
const pendingCommands = ref(0)
const syncing = ref(false)
const exceptionOpen = ref(false)
const quantityOpen = ref(false)
let taskTelemetry:ReturnType<typeof startKbxTask>|undefined
const query = useQuery({
queryKey: ['wms-picking-task', taskId],
queryFn: () => getPickingTask(taskId),
})
const contentState = computed<KbxAsyncState>(() => query.isLoading.value
? 'loading'
: query.isError.value
? 'error'
: task.value
? 'ready'
: 'empty')
watch(() => query.data.value, value => {
if (value) task.value = value
}, { immediate: true })
const startMutation = useMutation({
mutationFn: () => startPickingTask(taskId, task.value?.version ?? 0),
onSuccess(result) {
taskTelemetry=startKbxTask(pickingScreen.id,pickingScreen.version,'wms-picking')
task.value = result
message.value = result.message ?? '피킹을 시작합니다.'
playKbxWmsFeedback('neutral')
},
})
const scanMutation = useMutation({
mutationFn: (command: KbxWmsScanCommand) => scanPickingBarcode(command),
onSuccess(result, command) {
removePickingScan(command.idempotencyKey)
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
task.value = result.task
message.value = result.message
playKbxWmsFeedback(result.feedback)
if(result.task.stage==='completed'){taskTelemetry?.complete('success');taskTelemetry=undefined}
},
onError(error, command) {
// 4xx is an authoritative server rejection. Only network/5xx ambiguity is safe-retried.
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
message.value = error.title ?? '현재 작업 상태를 다시 확인하세요.'
playKbxWmsFeedback('error')
void query.refetch()
return
}
// The request may have committed even if the response was lost. Reuse the exact idempotency key.
queuePickingScan(command)
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
message.value = '서버 확인이 필요합니다. 연결되면 같은 작업을 안전하게 재전송합니다.'
playKbxWmsFeedback('warning')
},
})
const quantityMutation = useMutation({
mutationFn: (pickedQty: number) => {
if (!task.value?.currentLine) throw new Error('Current line not loaded')
const command: KbxWmsSetQuantityCommand = {
taskId,
lineId: task.value.currentLine.lineId,
pickedQty,
idempotencyKey: newIdempotencyKey(taskId),
expectedVersion: task.value.version,
}
return setPickingQuantity(command)
},
onSuccess(result) {
task.value = result.task
message.value = result.message
quantityOpen.value = false
playKbxWmsFeedback(result.feedback)
},
onError(error) {
message.value = isKbxProblem(error)
? error.title
: '수량을 반영하지 못했습니다.'
playKbxWmsFeedback('error')
void query.refetch()
},
})
const exceptionMutation = useMutation({
mutationFn: ({ type, memo }: { type: KbxWmsExceptionType; memo?: string }) => {
if (!task.value) throw new Error('Task not loaded')
return reportPickingException({
taskId,
lineId: task.value.currentLine?.lineId,
type,
memo,
idempotencyKey: newIdempotencyKey(taskId),
expectedVersion: task.value.version,
})
},
onSuccess(result, input) {
task.value = result
kbxTelemetry.track('manual.intervention',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{workType:'wms-picking',reasonCode:input.type,exceptionType:input.type}})
exceptionOpen.value = false
message.value = result.message ?? '예외를 등록했습니다.'
playKbxWmsFeedback('warning')
},
})
const scanEnabled = computed(() =>
Boolean(
task.value &&
online.value &&
pendingCommands.value === 0 &&
!scanMutation.isPending.value &&
['await-location', 'await-item'].includes(task.value.stage),
),
)
const scanLabel = computed(() => {
switch (task.value?.stage) {
case 'await-location': return 'LOCATION SCAN'
case 'await-item': return 'ITEM SCAN'
case 'processing': return 'PROCESSING'
case 'completed': return 'COMPLETED'
default: return 'SCAN READY'
}
})
async function handleScan(event: KbxBarcodeEvent) {
if (!task.value || !scanEnabled.value) return
taskTelemetry?.interaction('scan')
kbxTelemetry.track('command.execute',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{commandId:'scan',operationKind:'command'}})
const command: KbxWmsScanCommand = {
taskId,
barcode: event.normalizedValue,
source: event.source,
idempotencyKey: newIdempotencyKey(taskId),
expectedVersion: task.value.version,
occurredAt: new Date(event.occurredAt).toISOString(),
}
await scanMutation.mutateAsync(command).catch(() => undefined)
}
async function flushRetryQueue() {
if (!online.value || syncing.value) return
const queued = readPickingRetryQueue()
.filter(x => x.command.taskId === taskId)
.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt))
if (!queued.length) return
syncing.value = true
try {
// Authoritative picking is sequence-sensitive. Replay one-by-one and stop at first failure.
for (const item of queued) {
try {
const result = await scanPickingBarcode(item.command)
task.value = result.task
message.value = result.message
removePickingScan(item.command.idempotencyKey)
} catch (error) {
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
removePickingScan(item.command.idempotencyKey)
message.value = error.title ?? '작업 상태가 변경되었습니다. 최신 상태를 확인하세요.'
playKbxWmsFeedback('error')
await query.refetch()
}
break
}
}
} finally {
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
syncing.value = false
}
}
watch(online, value => {
if (value) void flushRetryQueue()
})
onMounted(() => {
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
if (online.value) void flushRetryQueue()
})
return {
task,
message,
online,
pendingCommands,
syncing,
scanEnabled,
scanLabel,
exceptionOpen,
quantityOpen,
loading: query.isLoading,
contentState,
reload: () => query.refetch(),
starting: startMutation.isPending,
scanning: scanMutation.isPending,
reportingException: exceptionMutation.isPending,
settingQuantity: quantityMutation.isPending,
start: () => startMutation.mutateAsync(),
handleScan,
openException: () => { exceptionOpen.value = true },
openQuantity: () => { quantityOpen.value = true },
closeQuantity: () => { quantityOpen.value = false },
setQuantity: (quantity: number) => quantityMutation.mutateAsync(quantity),
closeException: () => { exceptionOpen.value = false },
reportException: (type: KbxWmsExceptionType, memo?: string) => exceptionMutation.mutateAsync({ type, memo }),
}
}
@@ -0,0 +1,31 @@
import type { KbxWmsScanCommand } from '@kbx/contracts'
const STORAGE_KEY = 'kbx:wms:safe-retry:v1'
export interface QueuedPickingScan {
command: KbxWmsScanCommand
queuedAt: string
}
export function readPickingRetryQueue(): QueuedPickingScan[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as QueuedPickingScan[]
} catch {
return []
}
}
export function queuePickingScan(command: KbxWmsScanCommand) {
// Safety-first queue: only unacknowledged commands are retained for replay.
// UI stops accepting the next authoritative scan until the server confirms this one.
const queue = readPickingRetryQueue()
if (!queue.some(x => x.command.idempotencyKey === command.idempotencyKey)) {
queue.push({ command, queuedAt: new Date().toISOString() })
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue))
}
}
export function removePickingScan(idempotencyKey: string) {
const next = readPickingRetryQueue().filter(x => x.command.idempotencyKey !== idempotencyKey)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
}
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxBarcodeCapture, KbxProblemFeedback, KbxWmsActionButton, KbxWmsMobilePage, playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
import { putawayScreen } from './putaway.definition'
import { createWmsExecutionNotConnectedProblem, wmsFrontendScenarioMode } from '../shared/wmsFrontendCapability'
const props=defineProps<{taskId:string}>()
const {online}=useKbxNetworkState()
const stage=ref<'item'|'location'|'done'>('item')
const item=ref('')
const suggested='A-01-03'
const expectedBarcode='8801234567001'
const message=ref('적치할 상품을 스캔하세요.')
const problem=createWmsExecutionNotConnectedProblem('입고 적치')
function scan(value:string){
if(!wmsFrontendScenarioMode)return
if(stage.value==='item'){
if(value!==expectedBarcode){message.value=`다른 상품입니다. ${expectedBarcode}을 스캔하세요.`;playKbxWmsFeedback('error');return}
item.value=value;stage.value='location';message.value=`추천 위치 ${suggested}를 스캔하세요.`;playKbxWmsFeedback('success');return
}
if(stage.value==='location'){
if(value!==suggested){message.value=`잘못된 위치입니다. 현재 ${value}, 작업 위치 ${suggested}. ${suggested}로 이동하세요.`;playKbxWmsFeedback('error');return}
stage.value='done';message.value='Demo 적치 흐름을 완료했습니다. 운영에서는 Server 결과 확인 후 완료됩니다.';playKbxWmsFeedback('success')
}
}
function reset(){stage.value='item';item.value='';message.value='적치할 상품을 스캔하세요.'}
</script>
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'적치완료':'적치중'" :task-context="`Task ${props.taskId}`" :online="online" :actions-visible="wmsFrontendScenarioMode"><template #notice><KbxProblemFeedback v-if="!wmsFrontendScenarioMode" :problem="problem" :dismissible="false" /></template><template v-if="wmsFrontendScenarioMode"><p class="msg" role="status" aria-live="polite">{{message}}</p><div v-if="item" class="box"><small>상품</small><strong>운동화 BLACK 270</strong><span>{{item}}</span></div><div v-if="stage!=='item'" class="box location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'ITEM SCAN':'LOCATION SCAN'" @scan="event=>scan(event.normalizedValue)" /></template><template #actions><KbxWmsActionButton v-if="wmsFrontendScenarioMode&&stage==='done'" label="다음 상품" @click="reset" /></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.box{display:flex;flex-direction:column;gap:var(--kbx-space-1);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);margin-bottom:var(--kbx-space-3)}.box small,.box span{color:var(--kbx-color-text-muted)}.box strong{font-size:var(--kbx-font-xl)}.location strong{font-size:var(--kbx-font-2xl)}</style>
@@ -0,0 +1,3 @@
import { defineKbxScreen } from '@kbx/ui'
export const putawayScreen=defineKbxScreen({id:'WMS-PUT-001',version:'1.1.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 적치',helpKey:'WMS-PUT-001',permissions:['wms.putaway.execute'],telemetry:{enabled:true}})
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsPutawayRoutes: RouteRecordRaw[] = [
{ path:'/wms/putaway/:taskId', name:'wms-putaway', component:()=>import('./WmsPutawayPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-PUT-001' } },
]
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { KbxBarcodeCapture, KbxProblemFeedback, KbxWmsActionButton, KbxWmsMobilePage, playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
import { receivingScreen } from './receiving.definition'
import { createWmsExecutionNotConnectedProblem, wmsFrontendScenarioMode } from '../shared/wmsFrontendCapability'
const props=defineProps<{taskId:string}>()
const {online}=useKbxNetworkState()
const stage=ref<'asn'|'item'|'completed'>('asn')
const asnNo=ref('')
const received=ref(0)
const expectedQty=3
const expectedAsn='ASN-260811-001'
const expectedBarcode='8801234567001'
const message=ref('입고예정번호 또는 ASN을 스캔하세요.')
const problem=createWmsExecutionNotConnectedProblem('입고 검수')
const progress=computed(()=>stage.value==='completed'?'검수완료':stage.value==='asn'?'입고대기':`검수 ${received.value} / ${expectedQty}`)
function scan(value:string){
if(!wmsFrontendScenarioMode)return
if(stage.value==='asn'){
if(value!==expectedAsn){message.value=`다른 입고예정입니다. ${expectedAsn}을 스캔하세요.`;playKbxWmsFeedback('error');return}
asnNo.value=value;stage.value='item';message.value='상품 바코드를 스캔하세요.';playKbxWmsFeedback('success');return
}
if(stage.value==='item'){
if(value!==expectedBarcode){message.value=`다른 상품입니다. 필요한 바코드는 ${expectedBarcode}입니다.`;playKbxWmsFeedback('error');return}
if(received.value>=expectedQty){message.value='예정수량을 모두 확인했습니다. 검수를 완료하세요.';playKbxWmsFeedback('neutral');return}
received.value+=1;message.value=received.value===expectedQty?'예정수량과 일치합니다. 검수를 완료하세요.':`${received.value} / ${expectedQty} 확인했습니다. 같은 상품을 계속 스캔하세요.`;playKbxWmsFeedback('success')
}
}
function complete(){
if(received.value!==expectedQty){message.value=`예정 ${expectedQty}개 중 ${received.value}개만 확인했습니다. 계속 스캔하세요.`;playKbxWmsFeedback('error');return}
stage.value='completed';message.value='Demo 검수 흐름을 완료했습니다. 운영에서는 Server 결과 확인 후 완료됩니다.';playKbxWmsFeedback('success')
}
function reset(){stage.value='asn';asnNo.value='';received.value=0;message.value='입고예정번호 또는 ASN을 스캔하세요.'}
</script>
<template>
<KbxWmsMobilePage :screen="receivingScreen" :progress="progress" :task-context="`Task ${props.taskId}`" :online="online" :actions-visible="wmsFrontendScenarioMode">
<template #notice><KbxProblemFeedback v-if="!wmsFrontendScenarioMode" :problem="problem" :dismissible="false" /></template>
<template v-if="wmsFrontendScenarioMode">
<p class="msg" role="status" aria-live="polite">{{message}}</p>
<section v-if="asnNo" class="card"><small>입고예정</small><strong>{{asnNo}}</strong></section>
<section v-if="stage!=='asn'" class="card"><small>상품</small><strong>운동화 BLACK 270</strong><span>{{expectedBarcode}}</span><div class="qty">예정 {{expectedQty}} · 검수 {{received}} · 남음 {{Math.max(expectedQty-received,0)}}</div></section>
<KbxBarcodeCapture v-if="stage!=='completed'" :enabled="online" :label="stage==='asn'?'ASN SCAN':'ITEM SCAN'" @scan="event=>scan(event.normalizedValue)" />
</template>
<template #actions>
<template v-if="wmsFrontendScenarioMode">
<KbxWmsActionButton v-if="stage==='item'" label="검수 완료" :disabled="!online || received!==expectedQty" @click="complete" />
<KbxWmsActionButton v-else-if="stage==='completed'" label="다음 입고" @click="reset" />
</template>
</template>
</KbxWmsMobilePage>
</template>
<style scoped>.msg{margin:0 0 var(--kbx-space-3);font-weight:650}.card{display:flex;flex-direction:column;gap:var(--kbx-space-1);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);padding:var(--kbx-space-4);margin-bottom:var(--kbx-space-3);background:var(--kbx-color-surface)}.card small,.card span{color:var(--kbx-color-text-muted)}.card strong{font-size:var(--kbx-font-xl)}.qty{font-size:var(--kbx-font-lg);font-weight:650}</style>
@@ -0,0 +1,3 @@
import { defineKbxScreen } from '@kbx/ui'
export const receivingScreen=defineKbxScreen({id:'WMS-REC-001',version:'1.1.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 검수',helpKey:'WMS-REC-001',permissions:['wms.receiving.execute'],telemetry:{enabled:true}})
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsReceivingRoutes: RouteRecordRaw[] = [
{ path:'/wms/receiving/:taskId', name:'wms-receiving', component:()=>import('./WmsReceivingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-REC-001' } },
]
@@ -0,0 +1,13 @@
import type { KbxIntegrationProblem } from '@kbx/contracts'
export const wmsFrontendScenarioMode = import.meta.env.VITE_KBX_DEMO_MODE === 'true'
export function createWmsExecutionNotConnectedProblem(workflowLabel:string):KbxIntegrationProblem{
return {
type:'integration',
code:'WMS_EXECUTION_API_NOT_CONNECTED',
title:`${workflowLabel} 실행 API가 연결되지 않았습니다.`,
detail:'실제 현장 작업은 Server 검증·Idempotency·Audit 결과가 확인되기 전 성공으로 표시하지 않습니다. Demo Mode에서만 FE 시나리오를 실행할 수 있습니다.',
retryable:false,
}
}
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { isKbxProblem } from '@kbx/contracts'
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)
const queryProblem=computed(()=>isKbxProblem(error.value)?error.value:null)
const contentState=computed(()=>error.value?'error':loading.value&&!searched.value?'loading':!searched.value?'idle':rows.value.length===0?'empty':'ready')
async function executeSearch(){loading.value=true;error.value=null;selection.value=[];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()}
async function resetSearch(){search.taskType='';search.status='READY';search.owner='mine';search.keyword='';activeExceptionKey.value=null;await executeSearch()}
</script>
<template><KbxQueuePage :screen="wmsWorkScreen" :selection-count="selection.length" :content-state="contentState" :refreshing="loading&&searched&&!error" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" queue-title="현재 작업 Queue" queue-description="작업유형·작업자·상태를 기준으로 지금 처리할 업무만 모아서 봅니다." :queue-count="rows.length" breadcrumb="WMS > 작업관리" empty-title="조건에 맞는 물류 작업이 없습니다." empty-detail="작업유형·상태·작업자 조건을 변경하거나 기본조건으로 다시 조회하세요." empty-action-label="기본조건으로 조회" :error-title="queryProblem?.title || '물류 작업을 조회하지 못했습니다.'" :error-detail="queryProblem?.detail || '네트워크 상태를 확인한 다시 조회하세요.'" :error-retryable="queryProblem?.retryable !== false" retry-label="다시 조회" @command="executeCommand" @exception-filter="filterException" @empty-action="resetSearch"><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>
@@ -0,0 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsWorkRoutes: RouteRecordRaw[] = [{ path:'/wms/work', name:'wms-work', component:()=>import('./WmsWorkPage.vue'), meta:{ screenId:'WMS-WORK-001' } }]
@@ -0,0 +1,47 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
export interface WmsWorkRow {
taskId: string
taskNo: string
taskType: 'RECEIVING' | 'PUTAWAY' | 'PICKING' | 'CHECKING' | 'COUNTING'
waveNo?: string
ownerName?: string
progress: number
totalLines: number
completedLines: number
slaAt?: string
status: string
exceptionCount: number
}
export const wmsWorkScreen = defineKbxScreen({
id: 'WMS-WORK-001', version: '1.2.0', module: 'WMS', type: 'queue', templateCode:'T06', title: '물류 작업',
description: '입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회합니다.',
helpKey: 'WMS-WORK-001', permissions: ['wms.work.read'], telemetry: { enabled: true },
commands: [
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
{ id:'start', label:'작업 시작', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, maxSelection:1, permission:'wms.work.execute' },
],
})
export const wmsWorkSearchFields: KbxSearchField[] = [
{ key:'taskType', label:'작업유형', type:'select', primary:true, options:[
{value:'RECEIVING',label:'입고검수'},{value:'PUTAWAY',label:'적치'},{value:'PICKING',label:'피킹'},{value:'CHECKING',label:'검수'},{value:'COUNTING',label:'실사'},
] },
{ key:'status', label:'상태', type:'select', primary:true, options:[{value:'READY',label:'대기'},{value:'IN_PROGRESS',label:'진행'},{value:'BLOCKED',label:'예외'}] },
{ key:'owner', label:'작업자', type:'select', primary:true, options:[{value:'mine',label:'내 작업'},{value:'unassigned',label:'미배정'}] },
{ key:'keyword', label:'검색', type:'text', primary:true, width:'lg', placeholder:'작업번호 / Wave / 주문번호' },
]
export const wmsWorkColumns: KbxGridColumn<WmsWorkRow>[] = [
{ field:'taskNo', header:'작업번호', type:'code', width:150, pinned:'left' },
{ field:'taskType', header:'작업유형', width:100 },
{ field:'waveNo', header:'Wave', type:'code', width:130 },
{ field:'ownerName', header:'작업자', width:100 },
{ field:'progress', header:'진행률(%)', type:'percent', width:100 },
{ field:'completedLines', header:'완료', type:'integer', width:80 },
{ field:'totalLines', header:'전체', type:'integer', width:80 },
{ field:'slaAt', header:'SLA', type:'datetime', width:155 },
{ field:'status', header:'상태', type:'status', width:100 },
{ field:'exceptionCount', header:'예외', type:'integer', width:80 },
]
@@ -0,0 +1,51 @@
import type { KbxIntegrationProblem } from '@kbx/contracts'
import type { WmsWorkRow } from './work.definition'
export interface WmsWorkSearch { taskType?: string; status?: string; owner?: string; keyword?: string }
const demoRows:WmsWorkRow[]=[
{taskId:'41000000-0000-4000-8000-000000000001',taskNo:'RCV-260811-001',taskType:'RECEIVING',waveNo:'ASN-260811-001',ownerName:'QA 사용자',progress:0,totalLines:3,completedLines:0,slaAt:'2026-08-11T23:40:00+09:00',status:'READY',exceptionCount:0},
{taskId:'42000000-0000-4000-8000-000000000001',taskNo:'PUT-260811-004',taskType:'PUTAWAY',waveNo:'PUT-260811-A',ownerName:'QA 사용자',progress:33,totalLines:3,completedLines:1,slaAt:'2026-08-12T00:10:00+09:00',status:'IN_PROGRESS',exceptionCount:0},
{taskId:'30000000-0000-4000-8000-000000000001',taskNo:'PICK-260811-001',taskType:'PICKING',waveNo:'W260811-01',ownerName:'QA 사용자',progress:17,totalLines:138,completedLines:23,slaAt:'2026-08-11T23:30:00+09:00',status:'READY',exceptionCount:0},
{taskId:'43000000-0000-4000-8000-000000000001',taskNo:'CNT-260811-002',taskType:'COUNTING',waveNo:'COUNT-A',ownerName:'QA 사용자',progress:50,totalLines:8,completedLines:4,slaAt:'2026-08-12T00:30:00+09:00',status:'IN_PROGRESS',exceptionCount:1},
{taskId:'30000000-0000-4000-8000-000000000009',taskNo:'PICK-260811-009',taskType:'PICKING',waveNo:'W260811-03',ownerName:'QA 사용자',progress:42,totalLines:62,completedLines:26,slaAt:'2026-08-11T23:20:00+09:00',status:'BLOCKED',exceptionCount:2},
]
function matches(row:WmsWorkRow, search:WmsWorkSearch){
if(search.taskType&&row.taskType!==search.taskType)return false
if(search.status&&row.status!==search.status)return false
if(search.owner==='mine'&&row.ownerName!=='QA 사용자')return false
if(search.owner==='unassigned'&&row.ownerName)return false
const keyword=String(search.keyword??'').trim().toLocaleLowerCase()
if(keyword&&!`${row.taskNo} ${row.waveNo??''} ${row.ownerName??''} ${row.taskType}`.toLocaleLowerCase().includes(keyword))return false
return true
}
/**
* The work queue must never pretend that an unconnected production query returned an empty set.
* Demo mode provides browser-only fixtures so the FE workflow can be exercised without BE coupling.
*/
export async function searchWmsWork(search: WmsWorkSearch): Promise<WmsWorkRow[]> {
if(import.meta.env.VITE_KBX_DEMO_MODE==='true'){
await new Promise(resolve=>setTimeout(resolve,90))
return demoRows.filter(row=>matches(row,search)).map(row=>({...row}))
}
const problem:KbxIntegrationProblem={
type:'integration',
code:'WMS_WORK_QUERY_NOT_CONNECTED',
title:'물류 작업 조회를 연결할 수 없습니다.',
detail:'운영 WMS 작업조회 API가 연결되기 전에는 빈 작업목록으로 가장하지 않습니다. 연결 상태를 확인한 후 다시 조회하세요.',
retryable:false,
}
throw problem
}
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)}`
}
}
@@ -0,0 +1,11 @@
import { kbxAiAuthorizationPolicy, kbxPermissionCatalog, kbxSensitiveDataPolicies, type KbxAiCapability } from '@kbx/contracts'
const byId = new Map(kbxPermissionCatalog.map(x => [x.id, x]))
export function getKbxPermission(id:string){ return byId.get(id) }
export function canKbx(granted:readonly string[], permission:string){ return granted.includes(permission) }
export function kbxAiCapabilities(granted:readonly string[], screenAllowed:readonly KbxAiCapability[]=['explain','suggest','draft']):KbxAiCapability[]{
if(!canKbx(granted,kbxAiAuthorizationPolicy.usePermission)) return []
const result=screenAllowed.filter(x=>x!=='execute')
if(screenAllowed.includes('execute') && canKbx(granted,kbxAiAuthorizationPolicy.executePermission)) result.push('execute')
return result
}
export function getSensitivePolicyForField(fieldKey:string){ return kbxSensitiveDataPolicies.find(p=>p.fields.includes(fieldKey as never)) }
@@ -0,0 +1,10 @@
import type { KbxPermissionContext } from '@kbx/contracts'
export function createPermissionContext(granted: Iterable<string>): KbxPermissionContext {
const permissions = new Set(granted)
return {
has: permission => permissions.has(permission),
hasAny: required => required.some(x => permissions.has(x)),
hasAll: required => required.every(x => permissions.has(x)),
}
}
@@ -0,0 +1,103 @@
import type { InjectionKey, Ref } from 'vue'
import type { KbxScreenPreference } from '@kbx/contracts'
const PREFIX = 'kbx.screen.preference.v2'
const MAX_STORED_BYTES = 32_000
const MAX_SCOPE_LENGTH = 160
const MAX_ID_LENGTH = 128
const MAX_VERSION_LENGTH = 40
const MAX_SEARCH_KEYS = 24
const MAX_STRING_LENGTH = 256
export const KbxScreenPreferenceScopeKey: InjectionKey<Readonly<Ref<string>>> = Symbol('KbxScreenPreferenceScope')
function bounded(value: unknown, max: number) {
if (typeof value !== 'string') return ''
const clean = value.trim()
return clean.length <= max ? clean : ''
}
function normalizeScope(scope: unknown) { return bounded(scope, MAX_SCOPE_LENGTH) }
function normalizeId(value: unknown) { return bounded(value, MAX_ID_LENGTH) }
function normalizeVersion(value: unknown) { return bounded(value, MAX_VERSION_LENGTH) }
function storageKey(scope: string, screenId: string, screenVersion: string) {
return `${PREFIX}:${encodeURIComponent(scope)}:${encodeURIComponent(screenId)}:${encodeURIComponent(screenVersion)}`
}
function safeScalar(value: unknown): string | number | boolean | null | undefined {
if (value === null || typeof value === 'boolean') return value
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
return undefined
}
function sanitizeJsonValue(value: unknown, depth=0): unknown {
if (depth > 4) return undefined
const scalar = safeScalar(value)
if (scalar !== undefined) return scalar
if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeJsonValue(item, depth + 1)).filter(item => item !== undefined)
if (!value || typeof value !== 'object') return undefined
const result: Record<string, unknown> = {}
for (const [key, raw] of Object.entries(value).slice(0, 100)) {
const safeKey = bounded(key, 64)
const safeValue = sanitizeJsonValue(raw, depth + 1)
if (safeKey && safeValue !== undefined) result[safeKey] = safeValue
}
return result
}
function sanitizeSearchDefaults(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const result: Record<string, string | number | boolean | null> = {}
for (const [key, raw] of Object.entries(value).slice(0, MAX_SEARCH_KEYS)) {
const safeKey = bounded(key, 64)
const safeValue = safeScalar(raw)
if (safeKey && safeValue !== undefined) result[safeKey] = safeValue
}
return Object.keys(result).length ? result : undefined
}
function normalizePreference(value: unknown, screenId: string, screenVersion: string): KbxScreenPreference {
const raw = value && typeof value === 'object' ? value as Partial<KbxScreenPreference> : {}
const density = ['compact', 'comfortable', 'touch'].includes(String(raw.density)) ? raw.density : undefined
const pageSize = Number(raw.grid?.pageSize)
const columnState = sanitizeJsonValue(raw.grid?.columnState)
const searchDefaults = sanitizeSearchDefaults(raw.searchDefaults)
const grid = raw.grid && (Number.isFinite(pageSize) || columnState !== undefined) ? {
...(columnState !== undefined ? { columnState } : {}),
...(Number.isFinite(pageSize) ? { pageSize:Math.min(500, Math.max(10, pageSize)) } : {}),
} : undefined
return {
screenId,
screenVersion,
...(density ? { density } : {}),
...(grid ? { grid } : {}),
...(searchDefaults ? { searchDefaults } : {}),
rememberSearch: Boolean(raw.rememberSearch),
}
}
export function loadScreenPreference(scopeValue: string, screenIdValue: string, screenVersionValue: string): KbxScreenPreference | null {
const scope = normalizeScope(scopeValue)
const screenId = normalizeId(screenIdValue)
const screenVersion = normalizeVersion(screenVersionValue)
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return null
let raw = ''
try { raw = localStorage.getItem(storageKey(scope, screenId, screenVersion)) ?? '' } catch { return null }
if (!raw || raw.length > MAX_STORED_BYTES) return null
try { return normalizePreference(JSON.parse(raw), screenId, screenVersion) } catch { return null }
}
export function saveScreenPreference(scopeValue: string, preference: KbxScreenPreference) {
const scope = normalizeScope(scopeValue)
const screenId = normalizeId(preference.screenId)
const screenVersion = normalizeVersion(preference.screenVersion)
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return false
const normalized = normalizePreference(preference, screenId, screenVersion)
const payload = JSON.stringify(normalized)
if (payload.length > MAX_STORED_BYTES) return false
try { localStorage.setItem(storageKey(scope, screenId, screenVersion), payload); return true } catch { return false }
}
export function clearScreenPreference(scopeValue: string, screenIdValue: string, screenVersionValue: string) {
const scope = normalizeScope(scopeValue)
const screenId = normalizeId(screenIdValue)
const screenVersion = normalizeVersion(screenVersionValue)
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return false
try { localStorage.removeItem(storageKey(scope, screenId, screenVersion)); return true } catch { return false }
}
@@ -0,0 +1,44 @@
// AUTO-GENERATED by scripts/generate-app-screen-registry.mjs. Do not edit.
import { externalDataScreen as screen0 } from '../modules/common/external-data/external-data.definition'
import { designSystemCatalogScreen as screen1 } from '../modules/common/design-system/design-system.definition'
import { experimentsScreen as screen2 } from '../modules/common/experiments/experiments.definition'
import { operationsQueueScreen as screen3 } from '../modules/common/operations/operations.definition'
import { reconcileScreen as screen4 } from '../modules/common/reconcile/reconcile.definition'
import { uxMetricsScreen as screen5 } from '../modules/common/ux-metrics/ux-metrics.definition'
import { inventoryScreen as screen6 } from '../modules/erp/inventory/inventory.definition'
import { inventoryMoveScreen as screen7 } from '../modules/erp/inventory-move/inventory-move.definition'
import { itemMasterScreen as screen8 } from '../modules/erp/items/item.definition'
import { itemPriceScreen as screen9 } from '../modules/erp/item-prices/item-price.definition'
import { purchaseScreen as screen10 } from '../modules/erp/purchases/purchase.definition'
import { claimScreen as screen11 } from '../modules/oms/claims/claims.definition'
import { orderListScreen as screen12 } from '../modules/oms/orders/search/order-list.definition'
import { orderRegisterScreen as screen13 } from '../modules/oms/orders/register/order-register.definition'
import { orderImportScreen as screen14 } from '../modules/oms/orders/import/order-import.definition'
import { countingScreen as screen15 } from '../modules/wms/counting/counting.definition'
import { pickingScreen as screen16 } from '../modules/wms/picking/picking.definition'
import { putawayScreen as screen17 } from '../modules/wms/putaway/putaway.definition'
import { receivingScreen as screen18 } from '../modules/wms/receiving/receiving.definition'
import { wmsWorkScreen as screen19 } from '../modules/wms/work/work.definition'
export const generatedScreens = [
screen0,
screen1,
screen2,
screen3,
screen4,
screen5,
screen6,
screen7,
screen8,
screen9,
screen10,
screen11,
screen12,
screen13,
screen14,
screen15,
screen16,
screen17,
screen18,
screen19,
] as const
@@ -0,0 +1,8 @@
import { registerKbxScreens } from '@kbx/ui'
import { generatedScreens } from './screens.generated'
export const appScreens = generatedScreens
export function installScreenRegistry() {
registerKbxScreens(appScreens)
}
@@ -0,0 +1,36 @@
import type { RouteRecordRaw } from 'vue-router'
export const appRoutes:RouteRecordRaw[]=[
{path:'/',redirect:'/home'},
{path:'/home',name:'home',component:()=>import('../modules/common/home/HomeRoutePage.vue'),meta:{shellHome:true}},
{path:'/oms/orders',name:'oms-orders',component:()=>import('../modules/oms/orders/search/OrderListPage.vue'),meta:{screenId:'OMS-ORD-001'}},
{path:'/oms/orders/new',name:'oms-order-new',component:()=>import('../modules/oms/orders/register/OrderRegisterPage.vue'),meta:{screenId:'OMS-ORD-002'}},
{path:'/oms/orders/:orderId/edit',name:'oms-order-edit',component:()=>import('../modules/oms/orders/register/OrderRegisterPage.vue'),meta:{screenId:'OMS-ORD-002'}},
{path:'/oms/orders/import',name:'oms-order-import',component:()=>import('../modules/oms/orders/import/OrderImportPage.vue'),meta:{screenId:'OMS-ORD-003'}},
{path:'/oms/claims',name:'oms-claims',component:()=>import('../modules/oms/claims/ClaimsPage.vue'),meta:{screenId:'OMS-CLM-001'}},
{path:'/erp/items',name:'erp-items',component:()=>import('../modules/erp/items/ItemMasterPage.vue'),meta:{screenId:'ERP-MST-ITEM-001'}},
{path:'/erp/item-prices',name:'erp-item-prices',component:()=>import('../modules/erp/item-prices/ItemPriceFastEntryPage.vue'),meta:{screenId:'ERP-PRICE-001'}},
{path:'/erp/purchases/new',name:'erp-purchase-new',component:()=>import('../modules/erp/purchases/PurchasePage.vue'),meta:{screenId:'ERP-PUR-001'}},
{path:'/erp/inventory',name:'erp-inventory',component:()=>import('../modules/erp/inventory/InventoryPage.vue'),meta:{screenId:'ERP-INV-001'}},
{path:'/erp/inventory-moves/new',name:'erp-inventory-move-new',component:()=>import('../modules/erp/inventory-move/InventoryMovePage.vue'),meta:{screenId:'ERP-INV-MOVE-001'}},
{path:'/wms/work',name:'wms-work',component:()=>import('../modules/wms/work/WmsWorkPage.vue'),meta:{screenId:'WMS-WORK-001'}},
{path:'/wms/receiving/:taskId',name:'wms-receiving',component:()=>import('../modules/wms/receiving/WmsReceivingPage.vue'),props:true,meta:{screenId:'WMS-REC-001'}},
{path:'/wms/putaway/:taskId',name:'wms-putaway',component:()=>import('../modules/wms/putaway/WmsPutawayPage.vue'),props:true,meta:{screenId:'WMS-PUT-001'}},
{path:'/wms/picking/:taskId',name:'wms-picking',component:()=>import('../modules/wms/picking/WmsPickingPage.vue'),props:true,meta:{screenId:'WMS-PICK-001'}},
{path:'/wms/counting/:taskId',name:'wms-counting',component:()=>import('../modules/wms/counting/WmsCountingPage.vue'),props:true,meta:{screenId:'WMS-COUNT-001'}},
{path:'/operations/exceptions',name:'common-operations-exceptions',component:()=>import('../modules/common/operations/OperationsQueuePage.vue'),meta:{screenId:'COMMON-OPS-001'}},
{path:'/operations/reconcile',name:'common-reconcile',component:()=>import('../modules/common/reconcile/ReconcilePage.vue'),meta:{screenId:'COMMON-REC-001'}},
{path:'/internal/kbx/experiments',name:'kbx-experiments',component:()=>import('../modules/common/experiments/ExperimentsPage.vue'),meta:{screenId:'COMMON-EXP-001'}},
{path:'/internal/kbx/ux-metrics',name:'kbx-ux-metrics',component:()=>import('../modules/common/ux-metrics/UxMetricsPage.vue'),meta:{screenId:'COMMON-UX-001'}},
{path:'/internal/kbx/external-data',name:'kbx-external-data',component:()=>import('../modules/common/external-data/ExternalDataStatusPage.vue'),meta:{screenId:'COMMON-DATA-001'}},
{path:'/internal/kbx/catalog',name:'kbx-component-catalog',component:()=>import('../modules/common/design-system/ComponentCatalogPage.vue'),meta:{screenId:'COMMON-DS-001'}},
{path:'/:pathMatch(.*)*',name:'not-found',component:()=>import('../modules/common/not-found/NotFoundRoutePage.vue'),meta:{shellRecovery:true}},
]
/** Canonical route capability map used to validate workspace/deep-link paths before navigation. */
export const screenRoutePatterns = appRoutes.reduce<Record<string,string[]>>((acc,route)=>{
const screenId=typeof route.meta?.screenId==='string'?route.meta.screenId:''
if(!screenId)return acc
;(acc[screenId]??=[]).push(String(route.path))
return acc
},{})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { KbxNotificationCenter, KbxOperationCenter, KbxRuntimeBanner } from '@kbx/ui'
import { ref } from 'vue'
import { useKbxRuntime } from './useKbxRuntime'
const { notice, operations, notifications, unreadCount, refresh, markRead } = useKbxRuntime()
const panel = ref<'notifications' | 'operations' | null>(null)
</script>
<template>
<div class="kbx-runtime-shell">
<KbxRuntimeBanner :notice="notice" @retry="refresh" />
<header class="tools">
<button type="button" @click="panel = panel === 'operations' ? null : 'operations'">작업 {{ operations.filter(x => x.status === 'running' || x.status === 'queued').length }}</button>
<button type="button" @click="panel = panel === 'notifications' ? null : 'notifications'">알림 {{ unreadCount }}</button>
</header>
<aside v-if="panel" class="panel">
<KbxOperationCenter v-if="panel === 'operations'" :operations="operations" />
<KbxNotificationCenter v-else :notifications="notifications" @read="markRead" />
</aside>
<slot />
</div>
</template>
<style scoped>
.kbx-runtime-shell { position:relative; min-height:100%; }
.tools { display:flex; justify-content:flex-end; gap:4px; min-height:32px; align-items:center; }
.tools button { min-height:30px; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface); }
.panel { position:absolute; z-index:50; right:8px; top:76px; width:min(420px, calc(100vw - 24px)); max-height:70vh; overflow:auto; padding:8px; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-md); background:var(--kbx-color-surface); box-shadow:0 10px 30px rgba(16,24,40,.16); }
</style>
@@ -0,0 +1,17 @@
import type { KbxOperationRun, KbxRuntimeNotice, KbxUserNotification } from '@kbx/contracts'
import { kbxApi } from '../http/generated/kbxApiClient'
export const runtimeApi = {
getNotice() {
return kbxApi.request<KbxRuntimeNotice | null>('common.runtime.notice')
},
getOperations() {
return kbxApi.request<KbxOperationRun[]>('common.runtime.operations', { query: { limit: 20 } })
},
getNotifications() {
return kbxApi.request<KbxUserNotification[]>('common.runtime.notifications', { query: { limit: 30 } })
},
markNotificationRead(id: string) {
return kbxApi.request<void>('common.runtime.notificationRead', { path: { Id: id } })
},
}
@@ -0,0 +1,42 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { KbxOperationRun, KbxRuntimeNotice, KbxUserNotification } from '@kbx/contracts'
import { runtimeApi } from './runtimeApi'
export function useKbxRuntime() {
const notice = ref<KbxRuntimeNotice | null>(null)
const operations = ref<KbxOperationRun[]>([])
const notifications = ref<KbxUserNotification[]>([])
let timer: ReturnType<typeof setInterval> | undefined
async function refresh() {
const [nextNotice, nextOperations, nextNotifications] = await Promise.all([
runtimeApi.getNotice(),
runtimeApi.getOperations(),
runtimeApi.getNotifications(),
])
notice.value = nextNotice
operations.value = nextOperations
notifications.value = nextNotifications
}
async function markRead(id: string) {
await runtimeApi.markNotificationRead(id)
const item = notifications.value.find(x => x.id === id)
if (item && !item.readAt) item.readAt = new Date().toISOString()
}
onMounted(() => {
void refresh()
timer = setInterval(() => void refresh(), 30_000)
})
onUnmounted(() => { if (timer) clearInterval(timer) })
return {
notice,
operations,
notifications,
unreadCount: computed(() => notifications.value.filter(x => !x.readAt).length),
refresh,
markRead,
}
}
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
KbxAccessDenied,
KbxApplicationShell,
KbxHomePage,
KbxPermissionHostKey,
KbxNotificationCenter,
KbxOperationCenter,
KbxRuntimeBanner,
KbxUnsavedChangesDialog,
KbxUtilityRail,
KbxScreenUtilityHostKey,
resolveKbxSafeRecentPath,
resolveKbxSafeWorkspacePath,
useKbxShortcuts,
applyKbxTheme,
} from '@kbx/ui'
import type { KbxAiAnswer, KbxAiScreenContext, KbxHomeAttentionItem, KbxHomeLaunchItem, KbxNavigationSection, KbxRecentNavigation, KbxScreenDefinition, KbxSuggestionRequest, KbxWorkspaceTab } from '@kbx/contracts'
import type { KbxScreenUtilityHost, KbxScreenUtilityTab } from '@kbx/ui'
import { generatedScreens } from '../registry/screens.generated'
import { navigationSections, resolvedNavigationEntries } from './navigationCatalog'
import { screenRoutePatterns } from '../router/appRoutes'
import { useKbxWorkspaceStore } from './workspaceStore'
import { canWorkspaceSave, requestWorkspaceSave } from './workspaceLifecycleRegistry'
import { useKbxRuntime } from '../runtime/useKbxRuntime'
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
import { helpRegistry } from '../help/helpRegistry'
import { askKbxAssistant } from '../utility/aiAssistantApi'
import { submitKbxSuggestion } from '../utility/suggestionApi'
import { kbxAiCapabilities } from '../permissions/authorizationPolicy'
import { KbxScreenPreferenceScopeKey } from '../preferences/screenPreferenceStore'
import { loadWorkspaceSession, saveWorkspaceSession } from './workspaceSessionStore'
const props=withDefaults(defineProps<{productName?:string;grantedPermissions?:string[];preferenceScope?:string;appVersion?:string;userRole?:string}>(),{productName:'KBX',grantedPermissions:()=>[],preferenceScope:'',appVersion:'unknown',userRole:'user'})
const frameEmit=defineEmits<{profile:[]}>()
const router=useRouter();const route=useRoute();const store=useKbxWorkspaceStore();const pendingClose=ref<KbxWorkspaceTab|null>(null);const savingClose=ref(false);const runtimePanel=ref<'operations'|'notifications'|null>(null);const runtimePanelTargetId=ref<string|null>(null);const runtimePanelEl=ref<HTMLElement|null>(null);const runtimeReturnFocus=ref<HTMLElement|null>(null)
const { notice, operations, notifications, unreadCount, refresh, markRead }=useKbxRuntime()
const themeMode=computed(()=>(store.preference.themeMode==='dark'?'dark':'light') as 'light'|'dark')
const screenPreferenceScope=computed(()=>props.preferenceScope)
provide(KbxScreenPreferenceScopeKey,screenPreferenceScope)
watch(themeMode,mode=>applyKbxTheme(mode),{immediate:true})
const screenById=new Map(generatedScreens.map(screen=>[screen.id,screen]))
const hasPermission=(required?:string[])=>!required?.length || required.every(permission=>props.grantedPermissions.includes(permission))
provide(KbxPermissionHostKey,{has:(permission:string)=>props.grantedPermissions.includes(permission)})
const allowedEntries=computed(()=>resolvedNavigationEntries.filter(entry=>hasPermission(entry.permissions)&&entry.menuVisible!==false))
const allowedIds=computed(()=>new Set(allowedEntries.value.map(x=>x.screenId)))
const sections=computed<KbxNavigationSection[]>(()=>navigationSections.map(section=>({...section,entries:section.entries.filter(entry=>allowedIds.value.has(entry.screenId))})).filter(section=>section.entries.length))
const favorites=computed(()=>store.favoriteEntries(allowedEntries.value))
function screenFallback(screenId:string){return allowedEntries.value.find(entry=>entry.screenId===screenId)?.path??'/home'}
function safeWorkspacePath(screenId:string,candidate:string){return resolveKbxSafeWorkspacePath(screenRoutePatterns[screenId]??[],candidate,screenFallback(screenId))}
const recents=computed<KbxRecentNavigation[]>(()=>store.preference.recents.flatMap(item=>{const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId);if(!nav)return [];const recent=resolveKbxSafeRecentPath(nav,item.path);return [{...item,title:nav.title,path:safeWorkspacePath(item.screenId,recent)}]}))
const allowedTabs=computed(()=>store.tabs.filter(tab=>{const screen=screenById.get(tab.screenId);return Boolean(screen&&hasPermission(screen.permissions)&&safeWorkspacePath(tab.screenId,tab.path)===tab.path)}))
const isHome=computed(()=>route.path==='/home')
const activeScreenId=computed(()=>String(route.meta.screenId??''))
const activeScreen=computed(()=>screenById.get(activeScreenId.value))
const activeScreenKnown=computed(()=>!activeScreenId.value || Boolean(activeScreen.value))
const activeScreenAllowed=computed(()=>activeScreenKnown.value && (!activeScreen.value || hasPermission(activeScreen.value.permissions)))
const activeModule=computed(()=>isHome.value?'':activeScreen.value?.module??'')
const runningOperationCount=computed(()=>operations.value.filter(x=>x.status==='running'||x.status==='queued').length)
const failedOperationCount=computed(()=>operations.value.filter(x=>x.status==='failed'||x.status==='partially-completed').length)
const urgentUnreadCount=computed(()=>notifications.value.filter(x=>!x.readAt&&(x.severity==='warning'||x.severity==='error')).length)
const runtimeScreenAllowed=(screenId?:string)=>!screenId||Boolean(screenById.get(screenId)&&hasPermission(screenById.get(screenId)?.permissions))
const homeOperations=computed(()=>operations.value.filter(item=>runtimeScreenAllowed(item.sourceScreenId)))
const homeNotifications=computed(()=>notifications.value.filter(item=>runtimeScreenAllowed(item.screenId)))
const utilityTab=ref<KbxScreenUtilityTab|null>(null)
const utilityScreen=ref<KbxScreenDefinition|null>(null)
const utilityAiAnswer=ref<KbxAiAnswer|null>(null)
const utilityAiLoading=ref(false)
const utilityAiError=ref('')
const utilitySuggestionSubmitting=ref(false)
const utilityHelp=computed(()=>utilityScreen.value?.helpKey?helpRegistry[utilityScreen.value.helpKey]:undefined)
const utilityCapabilities=computed(()=>kbxAiCapabilities(props.grantedPermissions,['explain','suggest','draft']))
const utilityAiContext=computed<KbxAiScreenContext|undefined>(()=>utilityScreen.value&&utilityCapabilities.value.length?{screenId:utilityScreen.value.id,screenVersion:utilityScreen.value.version,allowedCapabilities:utilityCapabilities.value}:undefined)
const utilitySuggestionContext=computed(()=>utilityScreen.value&&props.grantedPermissions.includes('common.suggestion.create')?{screenId:utilityScreen.value.id,screenVersion:utilityScreen.value.version,route:route.fullPath,appVersion:props.appVersion,userRole:props.userRole,activeFilters:[]}:undefined)
const utilityHost:KbxScreenUtilityHost={
available(screen){const tabs:KbxScreenUtilityTab[]=[];if(screen.helpKey&&helpRegistry[screen.helpKey])tabs.push('help');if(utilityCapabilities.value.length)tabs.push('ai');if(props.grantedPermissions.includes('common.suggestion.create'))tabs.push('suggestion');return tabs},
open(screen,tab){closeRuntimePanel(false);utilityScreen.value=screen;utilityTab.value=tab;utilityAiAnswer.value=null;utilityAiError.value=''},
}
provide(KbxScreenUtilityHostKey,utilityHost)
async function askUtilityAi(question:string){if(!utilityAiContext.value)return;utilityAiLoading.value=true;utilityAiError.value='';try{utilityAiAnswer.value=await askKbxAssistant({question,context:utilityAiContext.value})}catch{utilityAiError.value='네트워크 상태를 확인한 후 다시 질문하세요.'}finally{utilityAiLoading.value=false}}
async function submitUtilitySuggestion(request:KbxSuggestionRequest){utilitySuggestionSubmitting.value=true;try{await submitKbxSuggestion(request);utilityTab.value=null}finally{utilitySuggestionSubmitting.value=false}}
const permittedScreenIds=computed(()=>new Set(generatedScreens.filter(screen=>hasPermission(screen.permissions)).map(screen=>screen.id)))
function restoreWorkspaceForScope(scope:string){
const snapshot=loadWorkspaceSession(scope)
if(!snapshot)return {restored:0,dirtyDiscarded:0}
const restored:KbxWorkspaceTab[]=[]
for(const item of snapshot.tabs){
const screen=screenById.get(item.screenId)
if(!screen||!hasPermission(screen.permissions))continue
const safe=safeWorkspacePath(item.screenId,item.path)
if(safe!==item.path)continue
restored.push({key:`${item.screenId}:${safe}`,screenId:item.screenId,title:screen.title,path:safe,pinned:item.pinned,dirty:false,resumed:true,openedAt:item.openedAt,lastActivatedAt:item.lastActivatedAt})
}
return {restored:store.restoreTabs(restored),dirtyDiscarded:snapshot.dirtyDiscardedCount}
}
function persistWorkspaceSession(){
const scope=screenPreferenceScope.value
if(scope)saveWorkspaceSession(scope,store.tabs)
}
watch(permittedScreenIds,ids=>{const removed=store.pruneTabs(ids);if(removed)store.shellNotice='권한 변경으로 더 이상 사용할 수 없는 열린 업무를 안전하게 닫았습니다.'},{immediate:true})
watch(allowedIds,ids=>store.reconcilePreference(ids),{immediate:true})
async function syncCurrentRoute(){
if(isHome.value)return
const screenId=activeScreenId.value
if(!screenId)return
const screen=screenById.get(screenId)
if(!screen||!hasPermission(screen.permissions))return
const safe=safeWorkspacePath(screenId,route.fullPath)
if(safe!==route.fullPath){store.shellNotice='허용되지 않은 화면 경로를 차단했습니다.';await router.replace('/home');return}
const navEntry=resolvedNavigationEntries.find(entry=>entry.screenId===screenId&&entry.menuVisible!==false&&hasPermission(entry.permissions))
const recent=navEntry&&navEntry.recentPolicy!=='none'?{screenId,title:screen.title,path:navEntry.recentPolicy==='route'?resolveKbxSafeRecentPath(navEntry,safe):navEntry.path,visitedAt:new Date().toISOString()}:null
const synced=store.syncRoute(screenId,screen.title,safe,recent)
if(!synced&&route.path!=='/home')await router.replace('/home')
}
watch(()=>props.preferenceScope,(scope,previous)=>{
store.setPreferenceScope(scope);utilityTab.value=null;utilityScreen.value=null;closeRuntimePanel(false)
const resume=scope?restoreWorkspaceForScope(scope):{restored:0,dirtyDiscarded:0}
if(resume.restored||resume.dirtyDiscarded)void nextTick(()=>store.notifyShell(`${resume.restored?`이전 세션 업무 ${resume.restored}개를 위치만 안전하게 복원했습니다. `:''}${resume.dirtyDiscarded?`미저장 편집 ${resume.dirtyDiscarded}개는 데이터 정합성을 위해 복원하지 않았습니다.`:''}`.trim()))
if(previous!==undefined&&scope!==previous&&route.path!=='/home')void router.replace('/home')
},{immediate:true})
watch(()=>props.grantedPermissions.join('|'),()=>{utilityTab.value=null;utilityScreen.value=null;closeRuntimePanel(false)})
watch(()=>store.tabs.map(tab=>[tab.key,Boolean(tab.dirty),Boolean(tab.pinned),Boolean(tab.resumed),tab.lastActivatedAt]).join('|'),persistWorkspaceSession,{flush:'post'})
watch(()=>route.fullPath,()=>{closeRuntimePanel(false);void syncCurrentRoute()},{immediate:true})
watch(activeScreen,(screen,previous)=>{utilityTab.value=null;utilityScreen.value=null;
if(previous?.telemetry?.enabled&&hasPermission(previous.permissions))kbxTelemetry.track('screen.close',{screenId:previous.id,screenVersion:previous.version,attributes:{module:previous.module,closeReason:'navigate'}})
if(screen?.telemetry?.enabled&&hasPermission(screen.permissions))kbxTelemetry.track('screen.open',{screenId:screen.id,screenVersion:screen.version,attributes:{module:screen.module,launchMode:'tab'}})
},{immediate:true})
function closeRuntimePanel(restoreFocus=true){
const target=runtimeReturnFocus.value
runtimePanel.value=null
runtimePanelTargetId.value=null
runtimeReturnFocus.value=null
if(restoreFocus&&target)void nextTick(()=>target.focus())
}
function openMenuSearch(){closeRuntimePanel(false);store.menuSearchOpen=true}
async function toggleRuntimePanel(kind:'operations'|'notifications',targetId:string|null=null){
if(runtimePanel.value===kind&&!targetId){closeRuntimePanel();return}
utilityTab.value=null;utilityScreen.value=null
runtimeReturnFocus.value=document.activeElement instanceof HTMLElement?document.activeElement:null
runtimePanel.value=kind
runtimePanelTargetId.value=targetId
await nextTick();if(!targetId)runtimePanelEl.value?.focus()
}
async function openHome(){store.deactivate();if(route.path!=='/home')await router.push('/home')}
async function openEntry(requested:(typeof resolvedNavigationEntries)[number]){const entry=allowedEntries.value.find(item=>item.screenId===requested.screenId);if(!entry)return;store.menuSearchOpen=false;await router.push(entry.path)}
async function openRecent(entry:KbxRecentNavigation){const nav=allowedEntries.value.find(item=>item.screenId===entry.screenId);if(!nav)return;const candidate=safeWorkspacePath(entry.screenId,resolveKbxSafeRecentPath(nav,entry.path));await router.push(candidate)}
async function selectTab(tab:KbxWorkspaceTab){const stored=store.tabs.find(item=>item.key===tab.key);if(!stored||!permittedScreenIds.value.has(stored.screenId))return;const safe=safeWorkspacePath(stored.screenId,stored.path);if(safe==='/home'&&stored.path!=='/home'){store.close(stored.key);store.shellNotice='변조되었거나 더 이상 유효하지 않은 열린 업무 경로를 제거했습니다.';await openHome();return}store.activate(stored.key);if(route.fullPath!==safe)await router.push(safe)}
async function launchHomeItem(item:KbxHomeLaunchItem){if(item.tabKey){const tab=store.tabs.find(tab=>tab.key===item.tabKey);if(tab)return selectTab(tab)}const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId);if(!nav)return;const candidate=item.source==='recent'?resolveKbxSafeRecentPath(nav,item.path):nav.path;await router.push(safeWorkspacePath(item.screenId,candidate))}
async function openHomeAttention(item:KbxHomeAttentionItem){
if(item.tabKey){const tab=store.tabs.find(tab=>tab.key===item.tabKey);if(tab)return selectTab(tab)}
if(item.operationId){await toggleRuntimePanel('operations',item.operationId);return}
if(item.notificationId){
await markRead(item.notificationId)
if(item.screenId){
const nav=allowedEntries.value.find(entry=>entry.screenId===item.screenId)
if(nav){
const candidate=item.path??nav.path
const safe=safeWorkspacePath(item.screenId,candidate)
if(item.path&&safe!==item.path){store.shellNotice='알림에 포함된 허용되지 않은 이동 경로를 차단했습니다.';await toggleRuntimePanel('notifications');return}
await router.push(safe);return
}
}
await toggleRuntimePanel('notifications',item.notificationId)
}
}
function closeTab(tab:KbxWorkspaceTab){if(tab.dirty){pendingClose.value=tab;return}void discardAndClose(tab)}
async function discardAndClose(tab:KbxWorkspaceTab){const wasCurrent=route.fullPath===tab.path;store.close(tab.key);pendingClose.value=null;if(wasCurrent){const next=store.activeTab;if(next)await selectTab(next);else await openHome()}}
async function saveAndClose(){const tab=pendingClose.value;if(!tab)return;savingClose.value=true;try{const saved=await requestWorkspaceSave(tab.path);if(saved)await discardAndClose(tab)}finally{savingClose.value=false}}
useKbxShortcuts([{key:'Ctrl+K',scope:'application',execute:openMenuSearch},{key:'Esc',scope:'application',priority:20,enabled:()=>Boolean(runtimePanel.value),execute:()=>closeRuntimePanel()}])
onMounted(()=>kbxTelemetry.start());onBeforeUnmount(()=>kbxTelemetry.stop())
</script>
<template>
<KbxApplicationShell
:product-name="props.productName" :active-module="activeModule" :active-screen-id="activeScreenId" :sections="sections" :all-entries="allowedEntries" :favorites="favorites" :recents="recents" :tabs="allowedTabs" :active-tab-key="store.activeKey" :side-nav-collapsed="store.preference.sideNavCollapsed" :menu-search-open="store.menuSearchOpen" :operation-count="runningOperationCount" :notification-count="unreadCount" :operations-open="runtimePanel==='operations'" :notifications-open="runtimePanel==='notifications'" :home-active="isHome" :workspace-max-tabs="store.preference.workspaceMaxTabs" :shell-notice="store.shellNotice" :theme-mode="themeMode"
@home="openHome" @open="openEntry" @open-recent="openRecent" @select-tab="selectTab" @close-tab="closeTab" @toggle-tab-pin="tab=>store.togglePinned(tab.key)" @toggle-favorite="store.toggleFavorite" @toggle-side-nav="store.toggleSideNav" @menu-search-open="openMenuSearch" @menu-search-close="store.menuSearchOpen=false" @operations="toggleRuntimePanel('operations')" @notifications="toggleRuntimePanel('notifications')" @theme-toggle="store.toggleTheme" @profile="frameEmit('profile')" @dismiss-shell-notice="store.dismissShellNotice"
>
<template #runtime><KbxRuntimeBanner :notice="notice" @retry="refresh" /><aside v-if="runtimePanel" id="kbx-runtime-panel" ref="runtimePanelEl" class="runtime-panel" tabindex="-1" role="region" :aria-labelledby="runtimePanel==='operations'?'kbx-runtime-panel-title-operations':'kbx-runtime-panel-title-notifications'"><header class="runtime-panel__header"><strong :id="runtimePanel==='operations'?'kbx-runtime-panel-title-operations':'kbx-runtime-panel-title-notifications'">{{runtimePanel==='operations'?'작업 센터':'알림 센터'}}</strong><button type="button" aria-label="패널 닫기" @click="closeRuntimePanel()">×</button></header><KbxOperationCenter v-if="runtimePanel==='operations'" :operations="operations" :active-id="runtimePanelTargetId" /><KbxNotificationCenter v-else :notifications="notifications" :active-id="runtimePanelTargetId" @read="markRead" /></aside></template>
<KbxHomePage v-if="isHome" :entries="allowedEntries" :favorites="favorites" :recents="recents" :operation-count="runningOperationCount" :operation-failure-count="failedOperationCount" :notification-count="unreadCount" :urgent-notification-count="urgentUnreadCount" :operations="homeOperations" :notifications="homeNotifications" :tabs="allowedTabs" @open="openEntry" @launch="launchHomeItem" @attention="openHomeAttention" @toggle-favorite="store.toggleFavorite" @menu-search="openMenuSearch" @operations="toggleRuntimePanel('operations')" @notifications="toggleRuntimePanel('notifications')" />
<KbxAccessDenied v-else-if="!activeScreenAllowed" @home="openHome" @menu-search="openMenuSearch" />
<router-view v-else />
</KbxApplicationShell>
<KbxUnsavedChangesDialog :open="Boolean(pendingClose)" :can-save="pendingClose?canWorkspaceSave(pendingClose.path):false" :saving="savingClose" @stay="pendingClose=null" @discard="pendingClose&&discardAndClose(pendingClose)" @save="saveAndClose" />
<KbxUtilityRail triggerless :open-tab="utilityTab" :help="utilityHelp" :ai-context="utilityAiContext" :ai-answer="utilityAiAnswer" :ai-loading="utilityAiLoading" :ai-error="utilityAiError" :ai-current-screen-label="utilityScreen?.title" :suggestion-context="utilitySuggestionContext" :suggestion-submitting="utilitySuggestionSubmitting" @close="utilityTab=null" @ai-ask="askUtilityAi" @suggestion-submit="submitUtilitySuggestion" />
</template>
<style scoped>.runtime-panel__header{min-height:var(--kbx-control-height);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.runtime-panel__header button{width:var(--kbx-control-xs);height:var(--kbx-control-xs);border:0;border-radius:var(--kbx-radius-sm);background:transparent;color:var(--kbx-color-text)}.runtime-panel__header button:hover{background:var(--kbx-color-surface-hover)}.runtime-panel{position:fixed;z-index:var(--kbx-z-runtime-panel);right:var(--kbx-space-2);top:var(--kbx-runtime-panel-top);width:min(var(--kbx-runtime-panel-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}</style>
@@ -0,0 +1,35 @@
import type { KbxNavigationEntry, KbxNavigationResolvedEntry, KbxNavigationSection, KbxScreenDefinition } from '@kbx/contracts'
import { generatedScreens } from '../registry/screens.generated'
export const navigationCatalog: KbxNavigationEntry[] = [
{ screenId:'OMS-ORD-001', path:'/oms/orders', section:'주문', keywords:['주문조회','주문관리','출고대기'], order:10, homePriority:10, homeGroup:'주문 처리' },
{ screenId:'OMS-ORD-002', path:'/oms/orders/new', section:'주문', keywords:['주문등록','신규주문'], order:20, homePriority:20, homeGroup:'주문 처리', permissions:['oms.order.create','erp.item.read'] },
{ screenId:'OMS-ORD-003', path:'/oms/orders/import', section:'주문', keywords:['엑셀','업로드','대량주문'], order:30, homePriority:30, homeGroup:'대량 업무' },
{ screenId:'OMS-CLM-001', path:'/oms/claims', section:'CS/클레임', keywords:['반품','교환','취소','클레임'], order:40 },
{ screenId:'ERP-MST-ITEM-001', path:'/erp/items', section:'기준정보', keywords:['품목','상품','SKU','마스터'], order:10, homePriority:10, homeGroup:'기준정보' },
{ screenId:'ERP-PRICE-001', path:'/erp/item-prices', section:'기준정보', keywords:['품목단가','단가','가격','일괄입력','Fast Entry'], order:15, homePriority:20, homeGroup:'대량 업무' },
{ screenId:'ERP-PUR-001', path:'/erp/purchases/new', section:'구매', keywords:['구매','발주','매입'], order:20 },
{ screenId:'ERP-INV-001', path:'/erp/inventory', section:'재고', keywords:['재고','현재고','가용재고','로케이션'], order:30, homePriority:30, homeGroup:'재고' },
{ screenId:'ERP-INV-MOVE-001', path:'/erp/inventory-moves/new', section:'재고', keywords:['재고이동','창고이동'], order:40 },
{ screenId:'WMS-WORK-001', path:'/wms/work', section:'작업관리', keywords:['입고','적치','피킹','실사','작업'], order:10, homePriority:10, homeGroup:'현장 운영' },
{ screenId:'COMMON-OPS-001', path:'/operations/exceptions', section:'운영', keywords:['예외','오류','실패','미처리'], order:10, homePriority:5, homeGroup:'예외 처리' },
{ screenId:'COMMON-REC-001', path:'/operations/reconcile', section:'운영', keywords:['대사','정합성','불일치','비교'], order:20, homePriority:15, homeGroup:'예외 처리' },
{ screenId:'COMMON-EXP-001', path:'/internal/kbx/experiments', section:'KBX', keywords:['UX','실험','A/B','점진배포','롤백'], order:75, menuVisible:false, favoriteAllowed:false },
{ screenId:'COMMON-UX-001', path:'/internal/kbx/ux-metrics', section:'KBX', keywords:['UX','지표','텔레메트리','자동화','개입률'], order:80, menuVisible:false, favoriteAllowed:false },
{ screenId:'COMMON-DATA-001', path:'/internal/kbx/external-data', section:'KBX', keywords:['외부데이터','KRX','OPENDART','KIS','신선도','출처'], order:85, menuVisible:false, favoriteAllowed:false },
{ screenId:'COMMON-DS-001', path:'/internal/kbx/catalog', section:'KBX', keywords:['컴포넌트','디자인시스템','카탈로그'], order:90, menuVisible:false, favoriteAllowed:false },
]
const screenById = new Map<string,KbxScreenDefinition>(generatedScreens.map(screen => [screen.id, screen]))
export const resolvedNavigationEntries: KbxNavigationResolvedEntry[] = navigationCatalog.map(entry => {
const screen=screenById.get(entry.screenId)
if(!screen) throw new Error(`Navigation references unknown screen: ${entry.screenId}`)
return { ...entry, title:screen.title, module:screen.module, permissions:entry.permissions ?? screen.permissions, launchMode:entry.launchMode ?? 'tab', menuVisible:entry.menuVisible ?? true, favoriteAllowed:entry.favoriteAllowed ?? true, recentPolicy:entry.recentPolicy ?? 'screen' }
})
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통 운영'}
export const navigationSections:KbxNavigationSection[] = ['OMS','ERP','WMS','COMMON'].flatMap(module => {
const moduleEntries=resolvedNavigationEntries.filter(x=>x.module===module && x.menuVisible!==false)
const groups=[...new Set(moduleEntries.map(x=>x.section))]
return groups.map(group=>({ key:`${module}:${group}`, label:`${moduleLabel[module]} · ${group}`, module:module as KbxNavigationSection['module'], entries:moduleEntries.filter(x=>x.section===group).sort((a,b)=>(a.order??0)-(b.order??0)) }))
})

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