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:
+18
@@ -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>
|
||||
+10
@@ -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()
|
||||
}
|
||||
+6
@@ -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)}
|
||||
}
|
||||
+22
@@ -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}
|
||||
}
|
||||
+78
@@ -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 개발·검증 화면입니다.'] },
|
||||
}
|
||||
+217
@@ -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
|
||||
}
|
||||
+38
@@ -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,
|
||||
}
|
||||
+63
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
+29
@@ -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 }
|
||||
}
|
||||
+22
@@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
+33
@@ -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'),
|
||||
}
|
||||
+33
@@ -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')
|
||||
+91
@@ -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>
|
||||
+13
@@ -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},
|
||||
})
|
||||
+36
@@ -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>
|
||||
+7
@@ -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}),
|
||||
}
|
||||
+7
@@ -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}],
|
||||
})
|
||||
+28
@@ -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>
|
||||
+10
@@ -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},
|
||||
],
|
||||
})
|
||||
+6
@@ -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}}),
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<template><div aria-hidden="true" /></template>
|
||||
+8
@@ -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>
|
||||
+129
@@ -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>
|
||||
+46
@@ -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 },
|
||||
]
|
||||
+28
@@ -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 } })
|
||||
},
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const operationsRoutes = [
|
||||
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
|
||||
]
|
||||
+58
@@ -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>
|
||||
+42
@@ -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 },
|
||||
]
|
||||
+19
@@ -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 } })
|
||||
},
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const reconcileRoutes = [
|
||||
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
|
||||
]
|
||||
+28
@@ -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>
|
||||
+7
@@ -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'}],
|
||||
})
|
||||
+5
@@ -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}),
|
||||
}
|
||||
+9
@@ -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>
|
||||
+8
@@ -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}]}
|
||||
+4
@@ -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' } },
|
||||
]
|
||||
+67
@@ -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>
|
||||
+49
@@ -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: '품목코드/품목명' }]
|
||||
+8
@@ -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 } }) },
|
||||
}
|
||||
+46
@@ -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>
|
||||
+36
@@ -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 },
|
||||
]
|
||||
+25
@@ -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 })
|
||||
},
|
||||
}
|
||||
+69
@@ -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}
|
||||
}
|
||||
+385
@@ -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>
|
||||
+20
@@ -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}]
|
||||
+14
@@ -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} }) },
|
||||
}
|
||||
+19
@@ -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>
|
||||
+17
@@ -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}
|
||||
] }
|
||||
+4
@@ -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' } },
|
||||
]
|
||||
+23
@@ -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>
|
||||
+39
@@ -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},
|
||||
]
|
||||
}
|
||||
+22
@@ -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 } })
|
||||
},
|
||||
}
|
||||
+4
@@ -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' } },
|
||||
]
|
||||
+30
@@ -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>
|
||||
+40
@@ -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'),
|
||||
],
|
||||
}
|
||||
+58
@@ -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)
|
||||
}
|
||||
+87
@@ -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,
|
||||
}
|
||||
}
|
||||
+201
@@ -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>
|
||||
+22
@@ -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},
|
||||
]
|
||||
+31
@@ -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, '주문 품목을 한 건 이상 입력하세요.'),
|
||||
})
|
||||
+14
@@ -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}})},
|
||||
}
|
||||
+38
@@ -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}
|
||||
}
|
||||
+89
@@ -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>
|
||||
+210
@@ -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>
|
||||
+59
@@ -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: '주문번호/주문자/상품' },
|
||||
]
|
||||
+59
@@ -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(),
|
||||
})
|
||||
},
|
||||
}
|
||||
+205
@@ -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,
|
||||
}
|
||||
}
|
||||
+33
@@ -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>
|
||||
+3
@@ -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}})
|
||||
+4
@@ -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' } },
|
||||
]
|
||||
+56
@@ -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>
|
||||
+46
@@ -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>
|
||||
+151
@@ -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>
|
||||
+12
@@ -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 },
|
||||
})
|
||||
+36
@@ -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,
|
||||
})
|
||||
}
|
||||
+10
@@ -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) }),
|
||||
},
|
||||
]
|
||||
+245
@@ -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 }),
|
||||
}
|
||||
}
|
||||
+31
@@ -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))
|
||||
}
|
||||
+28
@@ -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>
|
||||
+3
@@ -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}})
|
||||
+4
@@ -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' } },
|
||||
]
|
||||
+54
@@ -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>
|
||||
+3
@@ -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}})
|
||||
+4
@@ -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' } },
|
||||
]
|
||||
+13
@@ -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,
|
||||
}
|
||||
}
|
||||
+22
@@ -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>
|
||||
+2
@@ -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' } }]
|
||||
+47
@@ -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 },
|
||||
]
|
||||
+51
@@ -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)}`
|
||||
}
|
||||
}
|
||||
+11
@@ -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)) }
|
||||
+10
@@ -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)),
|
||||
}
|
||||
}
|
||||
+103
@@ -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 }
|
||||
}
|
||||
+44
@@ -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
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { registerKbxScreens } from '@kbx/ui'
|
||||
import { generatedScreens } from './screens.generated'
|
||||
|
||||
export const appScreens = generatedScreens
|
||||
|
||||
export function installScreenRegistry() {
|
||||
registerKbxScreens(appScreens)
|
||||
}
|
||||
+36
@@ -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
|
||||
},{})
|
||||
+30
@@ -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>
|
||||
+17
@@ -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 } })
|
||||
},
|
||||
}
|
||||
+42
@@ -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,
|
||||
}
|
||||
}
|
||||
+194
@@ -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>
|
||||
+35
@@ -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
Reference in New Issue
Block a user