V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -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)}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { KbxApiOperationId, KbxExperimentAssignmentsResponse, KbxExperimentId } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
|
||||
import { createExperimentRolloutConnection } from './createExperimentRolloutConnection'
|
||||
|
||||
const screenCache=new Map<string,Promise<KbxExperimentAssignmentsResponse>>()
|
||||
const listeners=new Map<string,Set<()=>void>>()
|
||||
let realtimeStarted=false
|
||||
const realtime=createExperimentRolloutConnection()
|
||||
realtime.onChanged(event=>{screenCache.delete(event.screenId);for(const refresh of listeners.get(event.screenId)??[])void refresh()})
|
||||
async function ensureRealtime(){if(realtimeStarted)return;realtimeStarted=true;try{await realtime.start()}catch{realtimeStarted=false}}
|
||||
function assignments(screenId:string,force=false){if(force)screenCache.delete(screenId);if(!screenCache.has(screenId))screenCache.set(screenId,kbxApi.request<KbxExperimentAssignmentsResponse>('common.experiments.assignments' as KbxApiOperationId,{query:{screenId}}));return screenCache.get(screenId)!}
|
||||
export function useKbxExperiment(screenId:string,screenVersion:string,experimentId:KbxExperimentId,surface:string){
|
||||
const variant=ref('control'),enrolled=ref(false);let lastExposure=''
|
||||
const refresh=async()=>{
|
||||
try{const result=await assignments(screenId,true);const assignment=result.assignments.find(x=>x.experimentId===experimentId);if(!assignment||!assignment.enrolled){enrolled.value=false;variant.value='control';lastExposure='';kbxTelemetry.clearExperiment(screenId);return}enrolled.value=true;variant.value=assignment.variant;kbxTelemetry.bindExperiment(screenId,assignment.experimentId,assignment.variant);const exposure=`${assignment.experimentId}:${assignment.variant}`;if(lastExposure!==exposure){lastExposure=exposure;kbxTelemetry.track('experiment.exposed',{screenId,screenVersion,attributes:{surface}})}}catch{/* experiment infrastructure must not block business UI */}
|
||||
}
|
||||
onMounted(async()=>{const set=listeners.get(screenId)??new Set<()=>void>();set.add(refresh);listeners.set(screenId,set);await ensureRealtime();await refresh()})
|
||||
onUnmounted(()=>{listeners.get(screenId)?.delete(refresh);kbxTelemetry.clearExperiment(screenId)})
|
||||
return {variant,enrolled,is:(value:string)=>variant.value===value,refresh}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { KbxHelpContent } from '@kbx/contracts'
|
||||
|
||||
export const helpRegistry: Record<string, KbxHelpContent> = {
|
||||
'OMS-ORD-001': {
|
||||
key: 'OMS-ORD-001', title: '주문관리', purpose: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
|
||||
steps: ['조회조건을 입력합니다.', 'F3 또는 조회를 실행합니다.', '처리할 주문을 선택한 뒤 업무 명령을 실행합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
},
|
||||
'OMS-ORD-002': {
|
||||
key: 'OMS-ORD-002', title: '주문등록', purpose: '거래처 주문을 Header/Detail 방식으로 등록합니다.',
|
||||
steps: ['거래처와 출고창고를 선택합니다.', '품목코드 또는 F2로 상품을 입력합니다.', '수량·단가를 확인하고 F8로 저장합니다.'],
|
||||
shortcuts: [{ key: 'F2', description: '코드 조회' }, { key: 'F8', description: '저장' }],
|
||||
},
|
||||
'ERP-MST-ITEM-001': {
|
||||
key: 'ERP-MST-ITEM-001', title: '품목관리', purpose: '품목 기준정보와 물류 속성을 관리합니다.',
|
||||
steps: ['왼쪽 목록에서 품목을 선택하거나 신규를 누릅니다.', '기준정보와 물류정보를 입력합니다.', 'F8로 저장합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }, { key: 'F8', description: '저장' }, { key: 'F2', description: '창고 조회' }],
|
||||
cautions: ['사용된 품목은 삭제보다 사용중지를 우선합니다.'],
|
||||
},
|
||||
'ERP-INV-001': {
|
||||
key: 'ERP-INV-001', title: '재고현황', purpose: '품목별 현재고에서 창고·로케이션 근거와 재고이력까지 같은 조회 맥락에서 확인합니다.',
|
||||
steps: ['조회조건을 입력하고 F3을 누릅니다.', '품목을 선택하면 오른쪽 위치재고와 아래 재고이력이 함께 바뀝니다.', '현재고 또는 가용수량을 눌러 산정 근거를 확인합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['화면 수량은 조회 Projection이며 실제 변경·확정 시 서버 재고 정책이 다시 검증합니다.'],
|
||||
},
|
||||
'ERP-PRICE-001': {
|
||||
key: 'ERP-PRICE-001', title: '품목 단가 일괄등록', purpose: '품목별 적용일과 단가를 Excel 방식으로 빠르게 입력하고 일괄 저장합니다.',
|
||||
steps: ['품목코드를 입력하거나 F2로 품목을 선택합니다.', '적용일과 단가를 입력하고 필요하면 Excel 영역을 붙여넣습니다.', '오류가 없으면 F8로 입력 건을 한 번에 저장합니다.'],
|
||||
shortcuts: [{ key: 'F2', description: '품목 조회' }, { key: 'F8', description: '저장' }],
|
||||
cautions: ['같은 품목과 적용일을 요청 안에서 중복 입력할 수 없습니다.', '서버가 품목 존재·권한·단가 범위를 다시 검증하고 저장 이력을 남깁니다.'],
|
||||
},
|
||||
'WMS-PICK-001': {
|
||||
key: 'WMS-PICK-001', title: '출고 피킹', purpose: '위치와 상품을 스캔하여 출고 피킹을 수행합니다.',
|
||||
cautions: ['서버 성공 응답 전에 같은 상품을 다시 스캔하지 마세요. 네트워크 오류는 동일 Idempotency Key로 자동 재시도합니다.'],
|
||||
},
|
||||
'OMS-ORD-003': {
|
||||
key: 'OMS-ORD-003', title: '주문 Excel 업로드', purpose: '주문 데이터를 공통 매핑·검증 절차로 대량 등록하거나 수정합니다.',
|
||||
steps: ['업로드 양식 또는 기존 Excel 파일을 선택합니다.', '자동 매핑 결과를 확인하고 필요한 열만 수정합니다.', '검증 결과에서 오류행을 확인한 뒤 정상 데이터만 반영합니다.'],
|
||||
cautions: ['검증되지 않은 데이터는 반영할 수 없습니다.', '기존 주문은 수정 가능한 업무상태에서만 업데이트됩니다.'],
|
||||
},
|
||||
'COMMON-OPS-001': {
|
||||
key: 'COMMON-OPS-001', title: '업무 예외 센터', purpose: '정상 업무가 아니라 사람이 판단하거나 복구해야 할 예외만 모아 처리합니다.',
|
||||
steps: ['미처리 예외를 조회합니다.', '중요도와 경과시간을 기준으로 우선순위를 확인합니다.', '원 업무 보기 또는 등록된 안전한 재처리 기능으로 해결합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['예외 센터는 원천 업무 데이터의 Source of Truth가 아닙니다.', '원천 조건이 해결되지 않은 예외를 단순 숨김 처리하지 않습니다.'],
|
||||
},
|
||||
'COMMON-REC-001': {
|
||||
key: 'COMMON-REC-001', title: '업무 데이터 대사', purpose: 'OMS·WMS·ERP 사이의 기대값과 실제값 차이를 시스템이 계산해 보여줍니다.',
|
||||
steps: ['대사유형과 결과를 선택합니다.', '불일치 건의 기대값·실제값·원인을 확인합니다.', '조사가 필요한 건만 예외 센터에 등록합니다.'],
|
||||
shortcuts: [{ key: 'F3', description: '조회' }],
|
||||
cautions: ['대사 화면에서 원천 데이터를 직접 수정하지 않습니다.'],
|
||||
},
|
||||
'OMS-CLM-001': {
|
||||
key:'OMS-CLM-001', title:'반품·클레임 관리', purpose:'반품·교환·취소 요청을 기존 주문 맥락에서 조회하고 처리합니다.',
|
||||
steps:['F3으로 미처리 요청을 조회합니다.','주문번호와 사유를 확인합니다.','허용된 상태전이만 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['완료된 클레임은 원 주문·물류 이력과 함께 추적합니다.'],
|
||||
},
|
||||
'ERP-PUR-001': {
|
||||
key:'ERP-PUR-001', title:'구매등록', purpose:'거래처 구매를 Header/Detail 방식으로 입력하고 입고 흐름과 연결합니다.',
|
||||
steps:['거래처와 입고창고를 선택합니다.','품목·수량·단가·납기일을 입력합니다.','F8 저장 후 구매확정합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['확정 후에는 입고 실적과 연결되므로 직접 수정 범위를 제한합니다.'],
|
||||
},
|
||||
'ERP-INV-MOVE-001': {
|
||||
key:'ERP-INV-MOVE-001', title:'재고이동', purpose:'창고 간 재고이동을 등록하고 이동중·입고완료 상태를 추적합니다.',
|
||||
steps:['출발·도착창고를 선택합니다.','품목과 이동수량을 입력합니다.','이동확정 후 WMS/입고 흐름에서 완료합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['출발창고와 도착창고는 같을 수 없고 가용재고보다 많이 이동할 수 없습니다.'],
|
||||
},
|
||||
'WMS-REC-001': { key:'WMS-REC-001', title:'입고 검수', purpose:'입고예정/ASN과 실물을 스캔해 수량·품목을 검수합니다.', cautions:['예정과 다른 상품·수량은 자동 확정하지 않고 예외로 남깁니다.'] },
|
||||
'WMS-PUT-001': { key:'WMS-PUT-001', title:'입고 적치', purpose:'검수 완료 상품을 추천 로케이션에 적치합니다.', cautions:['추천 위치와 다른 위치는 정상 흐름에서 자동 승인하지 않습니다.'] },
|
||||
'WMS-COUNT-001': { key:'WMS-COUNT-001', title:'재고실사', purpose:'위치·상품을 스캔해 실물수량을 기록하고 장부수량 차이를 예외로 보냅니다.', cautions:['실사 차이는 현장에서 장부수량을 직접 덮어쓰지 않습니다.'] },
|
||||
|
||||
'WMS-WORK-001': { key:'WMS-WORK-001', title:'물류 작업', purpose:'입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회하고 시작합니다.', steps:['F3으로 내 작업을 조회합니다.','SLA·진행률·예외를 확인합니다.','작업을 선택해 작업 시작을 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['현장 실행은 각 PDA 작업화면의 서버 검증과 Idempotency 규칙을 따릅니다.'] },
|
||||
|
||||
'COMMON-EXP-001': { key:'COMMON-EXP-001', title:'UX 실험·점진배포', purpose:'되돌릴 수 있는 UX 변경을 제한된 사용자에게 점진 배포하고 기존 UX 지표와 Guardrail을 비교합니다.', steps:['Draft 상태에서 변경 범위와 기준 지표를 검토합니다.','소규모 Rollout으로 시작하고 표본·P95·오류율을 확인합니다.','Guardrail 위반 시 즉시 롤백하고 원인을 기록합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['권한·민감정보·Domain Rule·Validation·Idempotency·WMS Scan 규칙은 실험 대상이 아닙니다.','운영 Threshold는 통계적 유의성을 의미하지 않습니다.'] },
|
||||
|
||||
'COMMON-UX-001': { key:'COMMON-UX-001', title:'UX 품질 지표', purpose:'Task 시간·의미 조작수·Manual Intervention Rate·검증/Import 실패율·예외 해결시간·AI 제안 수용률을 확인합니다.', steps:['기간을 선택하고 F3으로 조회합니다.','표본 수와 P50/P95를 함께 확인합니다.','지표 변화는 위험도·업무오류·현장 VOC와 같이 판단합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['DOM 전체 클릭이나 고객·주문 원문 데이터를 수집하지 않습니다.','Manual Intervention Rate를 낮추기 위해 위험 업무를 무리하게 자동화하지 않습니다.'] },
|
||||
|
||||
'COMMON-DATA-001': { key:'COMMON-DATA-001', title:'외부 데이터 상태', purpose:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.', steps:['F3으로 현재 상태를 조회합니다.','Stale/Expired 데이터셋의 최근 수신시각과 정책을 확인합니다.','허용된 데이터셋만 재수집합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['외부 데이터는 Domain 업무원장이 아닙니다.','KRX 승인 서비스는 서비스별 명시적 신선도 정책이 없으면 범용 TTL을 추정하지 않습니다.'] },
|
||||
|
||||
'COMMON-DS-001': { key:'COMMON-DS-001', title:'KBX 컴포넌트 카탈로그', purpose:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 독립적으로 재현합니다.', steps:['밀도를 선택합니다.','컴포넌트의 기본·오류·비활성 상태를 비교합니다.','Visual/Keyboard Regression 기준으로 사용합니다.'], cautions:['업무 사용자를 위한 메뉴가 아니라 KBX 개발·검증 화면입니다.'] },
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { kbxApiCatalog, type KbxApiOperationDefinition, type KbxApiOperationId, type KbxApiRequestOptions } from '@kbx/contracts'
|
||||
import { kbxHttp } from '../kbxHttpClient'
|
||||
|
||||
function buildPath(template: string, values: Record<string, string | number> = {}) {
|
||||
return template.replace(/\{([^}:]+)(?::[^}]+)?\}/g, (_, name: string) => {
|
||||
const key = Object.keys(values).find(x => x.toLowerCase() === name.toLowerCase())
|
||||
if (!key) throw new Error(`API path parameter '${name}' is required.`)
|
||||
return encodeURIComponent(String(values[key]))
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestKbxApi<TResponse = unknown, TBody = unknown>(
|
||||
operationId: KbxApiOperationId,
|
||||
options: KbxApiRequestOptions<TBody> = {},
|
||||
): Promise<TResponse> {
|
||||
const operation: KbxApiOperationDefinition = kbxApiCatalog[operationId]
|
||||
if (operation.idempotency === 'required' && !options.idempotencyKey) {
|
||||
throw new Error(`Operation '${operationId}' requires a stable idempotency key.`)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) }
|
||||
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
|
||||
|
||||
const response = await kbxHttp.request<TResponse>({
|
||||
method: operation.method,
|
||||
url: buildPath(operation.path, options.path),
|
||||
params: options.query,
|
||||
data: options.body,
|
||||
headers,
|
||||
responseType: options.responseType ?? operation.responseType ?? 'json',
|
||||
validateStatus: status => operation.successStatuses.some(value => value === status),
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const kbxApi = {
|
||||
request: requestKbxApi,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import { isKbxProblem, kbxUnexpectedProblem, type KbxProblem, type KbxSystemProblem } from '@kbx/contracts'
|
||||
|
||||
export const kbxHttp = axios.create({
|
||||
timeout: 30_000,
|
||||
headers: { 'X-KBX-Client': 'web' },
|
||||
})
|
||||
|
||||
kbxHttp.interceptors.request.use(config => {
|
||||
config.headers['X-Correlation-Id'] ??= crypto.randomUUID()
|
||||
return config
|
||||
})
|
||||
|
||||
kbxHttp.interceptors.response.use(
|
||||
response => response,
|
||||
(error: AxiosError) => {
|
||||
const correlationId = String(error.config?.headers?.['X-Correlation-Id'] ?? crypto.randomUUID())
|
||||
|
||||
if (!error.response) {
|
||||
const problem: KbxSystemProblem = {
|
||||
type: 'system',
|
||||
code: 'NETWORK_UNAVAILABLE',
|
||||
title: '서버와 연결할 수 없습니다.',
|
||||
detail: '입력한 내용은 유지됩니다. 네트워크 상태를 확인한 후 다시 시도하세요.',
|
||||
correlationId,
|
||||
retryable: true,
|
||||
}
|
||||
return Promise.reject(problem)
|
||||
}
|
||||
|
||||
const data = error.response.data
|
||||
if (isKbxProblem(data)) {
|
||||
const problem: KbxProblem = {
|
||||
...data,
|
||||
correlationId: data.correlationId ?? correlationId,
|
||||
}
|
||||
return Promise.reject(problem)
|
||||
}
|
||||
|
||||
if (error.response.status === 403) {
|
||||
return Promise.reject({
|
||||
type: 'permission', code: 'PERMISSION_DENIED', title: '이 작업을 수행할 권한이 없습니다.', correlationId,
|
||||
} satisfies KbxProblem)
|
||||
}
|
||||
if (error.response.status === 404) {
|
||||
return Promise.reject({
|
||||
type: 'not-found', code: 'RESOURCE_NOT_FOUND', title: '요청한 데이터를 찾을 수 없습니다.', correlationId,
|
||||
} satisfies KbxProblem)
|
||||
}
|
||||
|
||||
return Promise.reject(kbxUnexpectedProblem(correlationId, `HTTP ${error.response.status}`, error.response.status >= 500))
|
||||
},
|
||||
)
|
||||
|
||||
export function withIdempotency(config: AxiosRequestConfig = {}, key = crypto.randomUUID()): AxiosRequestConfig {
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...(config.headers ?? {}),
|
||||
'Idempotency-Key': key,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ref } from 'vue'
|
||||
import type { KbxConflictSnapshot, KbxProblem, KbxSystemProblem } from '@kbx/contracts'
|
||||
|
||||
export function useKbxProblemHandler() {
|
||||
const conflict = ref<KbxConflictSnapshot | null>(null)
|
||||
const systemProblem = ref<KbxSystemProblem | null>(null)
|
||||
|
||||
function handle(problem: KbxProblem) {
|
||||
if (problem.type === 'conflict') {
|
||||
conflict.value = {
|
||||
code: problem.code,
|
||||
title: problem.title,
|
||||
currentVersion: problem.currentVersion,
|
||||
correlationId: problem.correlationId,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (problem.type === 'system') {
|
||||
systemProblem.value = problem
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
conflict.value = null
|
||||
systemProblem.value = null
|
||||
}
|
||||
|
||||
return { conflict, systemProblem, handle, clear }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as signalR from '@microsoft/signalr'
|
||||
import type { KbxImportProgressEvent } from '@kbx/contracts'
|
||||
import type { KbxImportProgressConnection } from '@kbx/ui'
|
||||
|
||||
export function createImportProgressConnection(url = '/hubs/import-progress'): KbxImportProgressConnection {
|
||||
const hub = new signalR.HubConnectionBuilder()
|
||||
.withUrl(url)
|
||||
.withAutomaticReconnect()
|
||||
.build()
|
||||
|
||||
return {
|
||||
start: () => hub.state === signalR.HubConnectionState.Disconnected ? hub.start() : Promise.resolve(),
|
||||
stop: () => hub.stop(),
|
||||
subscribe: sessionId => hub.invoke('Subscribe', sessionId),
|
||||
unsubscribe: sessionId => hub.state === signalR.HubConnectionState.Connected
|
||||
? hub.invoke('Unsubscribe', sessionId)
|
||||
: Promise.resolve(),
|
||||
onProgress(callback) {
|
||||
hub.on('ImportProgress', (event: KbxImportProgressEvent) => callback(event))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isKbxProblem, type KbxLookupProvider } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
|
||||
const lookupOperations = {
|
||||
customer: { search: 'lookup.customers.search', byId: 'lookup.customers.resolveById', byCode: 'lookup.customers.resolveByCode' },
|
||||
item: { search: 'lookup.items.search', byId: 'lookup.items.resolveById', byCode: 'lookup.items.resolveByCode' },
|
||||
warehouse: { search: 'lookup.warehouses.search', byId: 'lookup.warehouses.resolveById', byCode: 'lookup.warehouses.resolveByCode' },
|
||||
} as const
|
||||
|
||||
type LookupEntity = keyof typeof lookupOperations
|
||||
|
||||
function provider(entity: LookupEntity): KbxLookupProvider<string> {
|
||||
const ops = lookupOperations[entity]
|
||||
return {
|
||||
search(request) {
|
||||
return kbxApi.request(ops.search, { query: request })
|
||||
},
|
||||
async resolveById(id) {
|
||||
try { return await kbxApi.request(ops.byId, { path: { id } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
async resolveByCode(code) {
|
||||
try { return await kbxApi.request(ops.byCode, { path: { code } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
} as KbxLookupProvider<string>
|
||||
}
|
||||
|
||||
export const lookupRegistry = {
|
||||
customer: provider('customer'),
|
||||
item: provider('item'),
|
||||
warehouse: provider('warehouse'),
|
||||
}
|
||||
+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}}),
|
||||
}
|
||||
@@ -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>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
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 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() {
|
||||
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 executeCommand(id: string) {
|
||||
const ids = selectedRows.value.map(x => x.id)
|
||||
if (id === 'search') return search()
|
||||
if (id === 'claim' && ids.length) { await operationsApi.claim(ids); return query.refetch() }
|
||||
}
|
||||
|
||||
async function executeAction(action: KbxWorkItemAction) {
|
||||
if (!detail.value) return
|
||||
if (action.kind === 'claim') await operationsApi.claim([detail.value.id])
|
||||
if (action.kind === 'resolve') { const item=detail.value; await operationsApi.resolve([item.id]); kbxTelemetry.track('exception.resolved',{screenId:operationsQueueScreen.id,screenVersion:operationsQueueScreen.version,durationMs:Math.max(0,item.ageMinutes*60000),attributes:{exceptionType:item.code,resolutionType:'manual'}}) }
|
||||
if (action.kind === 'retry') await operationsApi.retry(detail.value.id)
|
||||
if (action.kind === 'navigate') window.dispatchEvent(new CustomEvent('kbx:navigate-source', { detail: detail.value }))
|
||||
detail.value = null
|
||||
await query.refetch()
|
||||
}
|
||||
</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)"
|
||||
@command="executeCommand"
|
||||
>
|
||||
<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 } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const operationsRoutes = [
|
||||
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
|
||||
]
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
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 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() {
|
||||
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 command(id: string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'createException' && selectedRows.value.length) {
|
||||
await reconcileApi.createExceptions(selectedRows.value.map(x => x.id))
|
||||
return query.refetch()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxReconcilePage :screen="reconcileScreen" :summary="query.data.value?.summary" :selection-count="selectedRows.length" :context="pageContext" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.summary.totalCount===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
|
||||
<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 },
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { KbxReconcileResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ReconcileFilter {
|
||||
reconcileType?: string | null
|
||||
status?: string | null
|
||||
keyword?: string | null
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export const reconcileApi = {
|
||||
search(filter: ReconcileFilter) {
|
||||
return kbxApi.request<KbxReconcileResult>('common.reconcile.search', { query: filter })
|
||||
},
|
||||
createExceptions(ids: string[]) {
|
||||
return kbxApi.request<unknown, { ids: string[] }>('common.reconcile.createExceptions', { body: { ids } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const reconcileRoutes = [
|
||||
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
|
||||
]
|
||||
+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'}],
|
||||
})
|
||||
@@ -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}종`}]" @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}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { inventoryApi } from './inventoryApi'
|
||||
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
|
||||
|
||||
const appliedKeyword=ref('')
|
||||
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
|
||||
const selectedItemId=ref<string|null>(null)
|
||||
const selectedItem=ref<InventoryItemRow|null>(null)
|
||||
const breakdownOpen=ref(false)
|
||||
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
|
||||
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
|
||||
async function search(){
|
||||
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
|
||||
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
|
||||
}
|
||||
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
|
||||
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
|
||||
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
|
||||
function command(id:string){if(id==='search')return search()}
|
||||
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
|
||||
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
|
||||
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
|
||||
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
|
||||
])
|
||||
const drawerSummary=computed(()=>selectedItem.value?[
|
||||
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
|
||||
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
|
||||
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
|
||||
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
|
||||
]:[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
|
||||
<template #master>
|
||||
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
|
||||
</template>
|
||||
<template #bottom>
|
||||
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
|
||||
</template>
|
||||
</KbxMasterDetailPage>
|
||||
|
||||
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
|
||||
<div v-if="selectedItem" class="inventory-breakdown">
|
||||
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
|
||||
<KbxSummaryBar :items="drawerSummary" />
|
||||
<dl><dt>현재고</dt><dd>물리적으로 기록된 총 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 수 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 수 있는 수량</dd></dl>
|
||||
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
|
||||
</div>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
|
||||
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
|
||||
</style>
|
||||
+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: '품목코드/품목명' }]
|
||||
@@ -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" breadcrumb="ERP > 기준정보" @command="command">
|
||||
<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 },
|
||||
]
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxLookupItem, 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)
|
||||
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; 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(); 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 problem=toKbxProblem(error); if(problem) validation.applyProblem(problem); throw error }
|
||||
}
|
||||
return {rows,errors:validation.errors,dirty,saving:computed(()=>mutation.isPending.value),lastResult,enteredCount:computed(()=>enteredRows.value.length),addRow,duplicateRows,onCellChanged,applyItemLookup,save,clear,touch}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { isKbxProblem, kbxFieldReadonly, resolveKbxRecordStatePolicy, type KbxAuditEntry, type KbxConflictSnapshot, type KbxSearchField } from '@kbx/contracts'
|
||||
import { KbxBarcodeField, KbxCheckbox, KbxDataGrid, KbxFormGrid, KbxFormSection, KbxLookup, KbxMasterPage, KbxInput, KbxSearchPanel, useKbxDirtyState, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { itemApi } from './itemApi'
|
||||
import { itemMasterColumns, itemMasterScreen, itemStatePolicies, itemWorkflow, type ItemMasterRow } from './item.definition'
|
||||
|
||||
const searchModel=reactive({keyword:''});const searchFields:KbxSearchField[]=[{key:'keyword',label:'검색',type:'text',width:'lg',placeholder:'품목코드/품목명'}]
|
||||
const selected=ref<ItemMasterRow[]>([]);const form=reactive<ItemMasterRow>(emptyItem());const status=computed(()=>!form.id?'신규':form.active?'사용':'사용중지');const policy=computed(()=>resolveKbxRecordStatePolicy(itemStatePolicies,status.value))
|
||||
const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null);const errors=ref<any[]>([]);const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState()
|
||||
const query=useQuery({queryKey:['erp','items'],queryFn:()=>itemApi.search(searchModel.keyword),enabled:false});const rows=computed(()=>query.data.value?.items??[])
|
||||
function emptyItem():ItemMasterRow{return{id:'',code:'',name:'',categoryName:'',specification:'',unit:'EA',barcode:'',defaultWarehouseId:null,defaultWarehouseName:'',lotManaged:false,expiryManaged:false,active:true,version:0}}
|
||||
async function search(){selected.value=[];await query.refetch()}
|
||||
async function choose(rows:ItemMasterRow[]){selected.value=rows;if(!rows[0])return;const latest=await itemApi.get(rows[0].id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(latest.id);errors.value=[];conflict.value=null;resetDirty()}
|
||||
function state(field:string){return kbxFieldReadonly(policy.value,field)}
|
||||
function createNew(){Object.assign(form,emptyItem());selected.value=[];auditEntries.value=[];conflict.value=null;errors.value=[];resetDirty()}
|
||||
function copy(){const source={...form};Object.assign(form,{...source,id:'',code:'',barcode:'',version:0,active:true});auditEntries.value=[];conflict.value=null;resetDirty();touch()}
|
||||
function payload(){return{id:form.id||undefined,version:form.version||undefined,code:form.code.trim(),name:form.name.trim(),categoryName:form.categoryName,specification:form.specification,unit:form.unit,barcode:form.barcode,defaultWarehouseId:form.defaultWarehouseId,lotManaged:form.lotManaged,expiryManaged:form.expiryManaged}}
|
||||
async function save(){errors.value=[];if(!form.code.trim()||!form.name.trim()){errors.value=[...(!form.code.trim()?[{field:'code',code:'REQUIRED',message:'품목코드를 입력하세요.'}]:[]),...(!form.name.trim()?[{field:'name',code:'REQUIRED',message:'품목명을 입력하세요.'}]:[])];return}try{const r=form.id?await itemApi.update(form.id,payload()):await itemApi.create(payload());form.id=r.id;form.version=r.version;form.active=true;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='validation'){errors.value=e.errors;return}if(isKbxProblem(e)&&e.type==='conflict'&&form.id){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',entityId:form.id,requestedVersion:form.version,currentVersion:e.currentVersion??latest.version,changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label])=>({field,label,mine:(form as any)[field],latest:(latest as any)[field]}))};return}throw e}}
|
||||
async function deactivate(){if(!form.id)return;try{const r=await itemApi.deactivate(form.id,form.version);form.version=r.version;form.active=false;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,currentVersion:e.currentVersion??latest.version,requestedVersion:form.version,entityId:form.id};return}throw e}}
|
||||
async function reloadConflict(){if(!form.id)return;const latest=await itemApi.get(form.id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(form.id);conflict.value=null;resetDirty()}
|
||||
async function command(id:string){if(id==='search')return search();if(id==='new')return createNew();if(id==='copy')return copy();if(id==='save')return save();if(id==='deactivate')return deactivate()}
|
||||
useKbxPageShortcuts([{key:'F3',execute:search},{key:'F8',execute:save}])
|
||||
</script>
|
||||
<template>
|
||||
<KbxMasterPage :screen="itemMasterScreen" :status="status" :dirty="dirty" :version="form.version||undefined" :errors="errors" :workflow="itemWorkflow" :conflict="conflict" :audit-entries="auditEntries" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.items.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" breadcrumb="ERP > 기준정보" @command="command" @transition="command" @reload-conflict="reloadConflict" @dismiss-conflict="conflict=null">
|
||||
<template #list><KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search"/><KbxDataGrid :rows="rows" :columns="itemMasterColumns" row-key="id" selection="single" :active-row-key="form.id||undefined" :loading="query.isFetching.value" @selection-changed="choose"/></template>
|
||||
<template #detail>
|
||||
<KbxFormSection title="기본정보"><KbxFormGrid><KbxInput v-model="form.code" label="품목코드" required :readonly="Boolean(form.id)||state('code')" :error="errors.find(x=>x.field==='code')?.message" @update:model-value="touch"/><KbxInput v-model="form.name" label="품목명" required :readonly="state('name')" :error="errors.find(x=>x.field==='name')?.message" @update:model-value="touch"/><KbxInput v-model="form.categoryName" label="품목그룹" :readonly="state('categoryName')" @update:model-value="touch"/><KbxInput v-model="form.specification" label="규격" :readonly="state('specification')" @update:model-value="touch"/><KbxInput v-model="form.unit" label="단위" :readonly="state('unit')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
|
||||
<KbxFormSection title="물류정보"><KbxFormGrid><KbxLookup v-model="form.defaultWarehouseId" entity="warehouse" label="기본창고" :readonly="state('defaultWarehouseId')" @selected="touch"/><KbxBarcodeField v-model="form.barcode" label="바코드" :readonly="state('barcode')" @update:model-value="touch"/><KbxCheckbox v-model="form.lotManaged" label="LOT 관리" :disabled="state('lotManaged')" @update:model-value="touch"/><KbxCheckbox v-model="form.expiryManaged" label="유통기한 관리" :disabled="state('expiryManaged')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
|
||||
</template>
|
||||
</KbxMasterPage>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface ItemMasterRow { id:string; code:string; name:string; categoryName:string; specification:string; unit:string; barcode:string; defaultWarehouseId:string|null; defaultWarehouseName:string; lotManaged:boolean; expiryManaged:boolean; active:boolean; version:number }
|
||||
|
||||
export const itemWorkflow:KbxWorkflowDefinition={id:'erp.item.lifecycle',version:'1.0.0',states:[{value:'신규',label:'신규',semantic:'draft'},{value:'사용',label:'사용',semantic:'completed'},{value:'사용중지',label:'사용중지',semantic:'disabled',terminal:true}],transitions:[{id:'deactivate',from:['사용'],to:'사용중지',label:'사용중지',permission:'erp.item.write',confirm:true}]}
|
||||
export const itemStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'사용',editability:'editable'},{status:'사용중지',editability:'readonly',message:'사용중지된 품목은 조회·복사·이력확인만 가능합니다.'}]
|
||||
|
||||
export const itemMasterScreen = defineKbxScreen({
|
||||
id: 'ERP-MST-ITEM-001', version: '1.1.0', module: 'ERP', type: 'master', templateCode:'T02', title: '품목관리',
|
||||
description: '품목 기준정보와 물류 속성을 동일한 문법으로 관리합니다.', helpKey:'ERP-MST-ITEM-001', permissions:['erp.item.read'], telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'new',label:'신규',group:'edit',permission:'erp.item.create'},
|
||||
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'erp.item.create','사용':'erp.item.write'},allowedStatuses:['신규','사용'],requiresDirty:true},
|
||||
{id:'copy',label:'복사',group:'edit',permission:'erp.item.create'},
|
||||
{id:'deactivate',label:'사용중지',group:'workflow',permission:'erp.item.write',allowedStatuses:['사용'],confirm:{title:'품목을 사용중지하시겠습니까?',detail:'사용중지 후 신규 업무에서 이 품목을 선택할 수 없습니다. 기존 이력은 유지됩니다.',level:'high',confirmLabel:'사용중지'}},
|
||||
{id:'excel',label:'엑셀',group:'output'},
|
||||
],
|
||||
})
|
||||
export const itemMasterColumns:KbxGridColumn<ItemMasterRow>[]=[{field:'code',header:'품목코드',type:'code',width:120,pinned:'left'},{field:'name',header:'품목명',width:200},{field:'specification',header:'규격',width:160},{field:'unit',header:'단위',width:70},{field:'active',header:'사용',type:'boolean',width:70}]
|
||||
@@ -0,0 +1,14 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { KbxAuditEntry } from '@kbx/contracts'
|
||||
import type { ItemMasterRow } from './item.definition'
|
||||
export interface ItemSearchResponse { items: ItemMasterRow[]; totalCount: number }
|
||||
export interface ItemSaveRequest { id?:string; version?:number; code:string; name:string; categoryName?:string; specification?:string; unit:string; barcode?:string; defaultWarehouseId?:string|null; lotManaged:boolean; expiryManaged:boolean }
|
||||
export interface ItemSaveResponse { id:string; version:number; status:string }
|
||||
export const itemApi = {
|
||||
search(keyword = '') { return kbxApi.request<ItemSearchResponse>('erp.items.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
get(id: string) { return kbxApi.request<ItemMasterRow>('erp.items.get', { path: { id } }) },
|
||||
create(body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.create', { body }) },
|
||||
update(id:string,body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.update', { path:{id}, body }) },
|
||||
deactivate(id:string,version:number) { return kbxApi.request<ItemSaveResponse>('erp.items.deactivate', { path:{id}, body:{version} }) },
|
||||
audit(id:string) { return kbxApi.request<KbxAuditEntry[]>('erp.items.audit', { path:{id} }) },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxInput, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { purchaseColumns, purchaseScreen, purchaseWorkflow, type PurchaseLine } from './purchase.definition'
|
||||
const status=ref('DRAFT'); const lines=ref<PurchaseLine[]>([{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',quantity:1,unitPrice:0,amount:0,dueDate:''}])
|
||||
const header=reactive({ purchaseDate:new Date().toISOString().slice(0,10), supplierId:null as string|null, warehouseId:null as string|null, buyer:'', remark:'' })
|
||||
const total=computed(()=>lines.value.reduce((s,x)=>s+x.quantity*x.unitPrice,0))
|
||||
function command(id:string){ if(id==='new'){ status.value='DRAFT'; lines.value=[] } if(id==='confirm' && status.value==='DRAFT') status.value='CONFIRMED' }
|
||||
</script>
|
||||
<template>
|
||||
<KbxTransactionPage :screen="purchaseScreen" :status="status" :summary-items="[{key:'amount',label:'총 구매금액',value:`₩${total.toLocaleString('ko-KR')}`,emphasis:true}]" @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}
|
||||
] }
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpPurchaseRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, KbxListPage, KbxSearchPanel, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { claimColumns, claimScreen, claimSearchFields, claimWorkflow, type ClaimRow } from './claims.definition'
|
||||
import { claimsApi } from './claimsApi'
|
||||
const search = reactive<Record<string,unknown>>({ status:'REQUESTED' })
|
||||
const selected = ref<ClaimRow[]>([])
|
||||
const q = useQuery({ queryKey:['oms','claims',search], queryFn:()=>claimsApi.search(search), enabled:false })
|
||||
async function run(){ selected.value=[]; await q.refetch() }
|
||||
async function command(id:string){ if(id==='search') return run(); if(['approve','hold'].includes(id)) for(const row of selected.value) await claimsApi.transition(row.id,id); await run() }
|
||||
</script>
|
||||
<template>
|
||||
<KbxListPage :screen="claimScreen" :content-state="q.error.value?'error':q.isFetching.value&&!q.data.value?'loading':!q.data.value?'idle':q.data.value.totalCount===0?'empty':'ready'" :refreshing="q.isFetching.value&&Boolean(q.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="search" :fields="claimSearchFields" @search="run" /></template>
|
||||
<template #content>
|
||||
<KbxWorkflowBar v-if="selected.length === 1" :workflow="claimWorkflow" :current="selected[0].status" @transition="command" />
|
||||
<KbxDataGrid v-model:selection="selected" :rows="q.data.value?.items ?? []" :columns="claimColumns" row-key="id" selection="multiple" :loading="q.isFetching.value" personalization exportable />
|
||||
</template>
|
||||
<template #summary>클레임 {{ q.data.value?.totalCount ?? 0 }}건 · 선택 {{ selected.length }}건</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface ClaimRow {
|
||||
id: string; claimNo: string; orderNo: string; channelName: string; type: string; reason: string; requestedQty: number; status: string; ownerName: string; requestedAt: string
|
||||
}
|
||||
export const claimScreen = defineKbxScreen({
|
||||
id:'OMS-CLM-001', version:'1.0.0', module:'OMS', type:'list', templateCode:'T01', title:'반품·클레임 관리', helpKey:'OMS-CLM-001', permissions:['oms.claim.read'], telemetry:{ enabled:true },
|
||||
description:'반품·교환·취소 요청을 조회하고 정상 건은 표준 상태전이로 처리합니다.',
|
||||
commands:[
|
||||
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
|
||||
{ id:'approve', label:'승인', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, permission:'oms.claim.approve' },
|
||||
{ id:'hold', label:'보류', group:'workflow', requiresSelection:true, minSelection:1, permission:'oms.claim.hold' },
|
||||
{ id:'excel', label:'엑셀', group:'output' },
|
||||
]
|
||||
})
|
||||
export const claimSearchFields: KbxSearchField[] = [
|
||||
{ key:'period', label:'요청기간', type:'date-range', range:{ from:'from', to:'to' } },
|
||||
{ key:'type', label:'유형', type:'select', options:[{value:'RETURN',label:'반품'},{value:'EXCHANGE',label:'교환'},{value:'CANCEL',label:'취소'}] },
|
||||
{ key:'status', label:'상태', type:'select', options:[{value:'REQUESTED',label:'접수'},{value:'APPROVED',label:'승인'},{value:'IN_PROGRESS',label:'처리중'},{value:'COMPLETED',label:'완료'},{value:'HOLD',label:'보류'}] },
|
||||
{ key:'keyword', label:'검색', type:'text', width:'lg', placeholder:'클레임번호/주문번호/고객' },
|
||||
]
|
||||
export const claimColumns: KbxGridColumn<ClaimRow>[] = [
|
||||
{ field:'claimNo', header:'클레임번호', type:'code', width:150, pinned:'left' },
|
||||
{ field:'orderNo', header:'주문번호', type:'code', width:150 },
|
||||
{ field:'channelName', header:'채널', width:100 }, { field:'type', header:'유형', width:90 }, { field:'reason', header:'사유', width:220 },
|
||||
{ field:'requestedQty', header:'수량', type:'quantity', width:90 }, { field:'status', header:'상태', type:'status', width:100 }, { field:'ownerName', header:'담당', width:100 }, { field:'requestedAt', header:'요청일시', type:'datetime', width:160 },
|
||||
]
|
||||
export const claimWorkflow: KbxWorkflowDefinition = {
|
||||
id:'oms.claim', version:'1.0.0',
|
||||
states:[
|
||||
{value:'REQUESTED',label:'접수',semantic:'pending'}, {value:'APPROVED',label:'승인',semantic:'processing'}, {value:'IN_PROGRESS',label:'처리중',semantic:'processing'}, {value:'COMPLETED',label:'완료',semantic:'completed',terminal:true}
|
||||
],
|
||||
transitions:[
|
||||
{id:'approve',from:['REQUESTED'],to:'APPROVED',label:'승인',permission:'oms.claim.approve'},
|
||||
{id:'start',from:['APPROVED'],to:'IN_PROGRESS',label:'처리 시작',permission:'oms.claim.process'},
|
||||
{id:'complete',from:['IN_PROGRESS'],to:'COMPLETED',label:'처리 완료',permission:'oms.claim.process',confirm:true},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ClaimRow } from './claims.definition'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ClaimSearch { from?: string; to?: string; type?: string; status?: string; keyword?: string }
|
||||
|
||||
const transitionOperations = {
|
||||
approve: 'oms.claims.approve',
|
||||
hold: 'oms.claims.hold',
|
||||
start: 'oms.claims.start',
|
||||
complete: 'oms.claims.complete',
|
||||
} as const
|
||||
|
||||
export const claimsApi = {
|
||||
search(filter: ClaimSearch) {
|
||||
return kbxApi.request<{ items: ClaimRow[]; totalCount: number }>('oms.claims.search', { query: filter })
|
||||
},
|
||||
transition(id: string, transition: string) {
|
||||
const operation = transitionOperations[transition as keyof typeof transitionOperations]
|
||||
if (!operation) throw new Error(`Unsupported claim transition: ${transition}`)
|
||||
return kbxApi.request<{ id: string; status: string; version: number }>(operation, { path: { id } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const omsClaimRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/oms/claims', name:'oms-claims', component:()=>import('./ClaimsPage.vue'), meta:{ screenId:'OMS-CLM-001' } },
|
||||
]
|
||||
+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">
|
||||
<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)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type { KbxImportMapping, KbxImportSession } 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 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 },
|
||||
})
|
||||
|
||||
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,
|
||||
busy: computed(() => uploadMutation.isPending.value || mappingMutation.isPending.value || validationMutation.isPending.value || commitMutation.isPending.value),
|
||||
upload: (file: File) => uploadMutation.mutateAsync(file),
|
||||
saveMapping: (mappings: KbxImportMapping[]) => sessionId.value ? mappingMutation.mutateAsync({ id: sessionId.value, mappings }) : Promise.resolve(null),
|
||||
saveNamedMapping: (name: string, mappings: KbxImportMapping[]) => sessionId.value ? savedMappingMutation.mutateAsync({ id: sessionId.value, name, mappings }) : Promise.resolve(),
|
||||
validate: () => sessionId.value ? validationMutation.mutateAsync(sessionId.value) : Promise.resolve(null),
|
||||
commit: () => sessionId.value ? commitMutation.mutateAsync(sessionId.value) : Promise.resolve(null),
|
||||
downloadTemplate: orderImportApi.downloadTemplate,
|
||||
downloadErrors: () => sessionId.value ? orderImportApi.downloadErrors(sessionId.value) : Promise.resolve(),
|
||||
reset: () => { sessionId.value = null; localSession.value = null },
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { KbxLookupItem } from '@kbx/contracts'
|
||||
import { kbxFieldReadonly, resolveKbxRecordStatePolicy } from '@kbx/contracts'
|
||||
import { KbxDataGrid,KbxDateField,KbxFormGrid,KbxFormSection,KbxFormSpan,KbxLookup,KbxLookupDialog,KbxInput,KbxSectionHeader,KbxTransactionPage,useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { orderLineColumns, orderRegisterScreen, orderStatePolicies, orderWorkflow, type OrderLineForm } from './order-register.definition'
|
||||
import { useKbxWorkspaceBinding } from '../../../../shell/useKbxWorkspaceBinding'
|
||||
import { useOrderRegistration } from './useOrderRegistration'
|
||||
const route=useRoute();const vm=useOrderRegistration();useKbxWorkspaceBinding(vm.dirty,async()=>{await vm.save();return !vm.dirty.value})
|
||||
const itemLookupOpen=ref(false);const activeLine=ref<OrderLineForm|null>(null);const statePolicy=computed(()=>resolveKbxRecordStatePolicy(orderStatePolicies,vm.status.value));const readonly=computed(()=>statePolicy.value.editability==='readonly')
|
||||
function fieldReadonly(field:string){return kbxFieldReadonly(statePolicy.value,field)}
|
||||
function openGridLookup(event:{row:OrderLineForm;entity:string}){if(event.entity!=='item'||readonly.value)return;activeLine.value=event.row;itemLookupOpen.value=true}
|
||||
function selectGridItem(item:KbxLookupItem<string>){if(activeLine.value)vm.applyItemLookup(activeLine.value,item)}
|
||||
const summaryItems=computed(()=>[{key:'items',label:'품목',value:`${vm.lines.value.length.toLocaleString('ko-KR')}종`},{key:'qty',label:'총수량',value:vm.totalQty.value},{key:'amount',label:'주문금액',value:`₩${vm.totalAmount.value.toLocaleString('ko-KR')}`,emphasis:true}])
|
||||
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
|
||||
async function executeCommand(id:string){if(id==='save')await vm.save();if(id==='new')vm.createNew();if(id==='confirm')await vm.confirm()}
|
||||
onMounted(async()=>{const id=String(route.params.orderId??'');if(id)await vm.load(id)})
|
||||
</script>
|
||||
<template>
|
||||
<KbxTransactionPage :screen="orderRegisterScreen" :status="vm.status.value" :dirty="vm.dirty.value" :version="vm.version.value" :errors="vm.errors.value" :workflow="orderWorkflow" :conflict="vm.conflict.value" :audit-entries="vm.auditEntries.value" :summary-items="summaryItems" breadcrumb="OMS > 주문" @command="executeCommand" @transition="executeCommand" @reload-conflict="vm.reloadConflict" @dismiss-conflict="vm.dismissConflict">
|
||||
<template #header>
|
||||
<KbxFormSection title="기본정보"><KbxFormGrid><KbxDateField v-model="vm.header.orderDate" label="주문일" required :readonly="fieldReadonly('orderDate')" :error="vm.fieldError('orderDate')" @update:model-value="vm.touch"/><KbxLookup v-model="vm.header.customerId" entity="customer" label="거래처" required :readonly="fieldReadonly('customerId')" :error="vm.fieldError('customerId')" @selected="vm.touch"/><KbxLookup v-model="vm.header.warehouseId" entity="warehouse" label="출고창고" required :readonly="fieldReadonly('warehouseId')" :error="vm.fieldError('warehouseId')" @selected="vm.touch"/><KbxInput v-model="vm.header.receiverName" label="수취인" required :readonly="fieldReadonly('receiverName')" :error="vm.fieldError('receiverName')" @update:model-value="vm.touch"/><KbxInput v-model="vm.header.phone" label="연락처" required :readonly="fieldReadonly('phone')" :error="vm.fieldError('phone')" @update:model-value="vm.touch"/></KbxFormGrid></KbxFormSection>
|
||||
<KbxFormSection title="배송정보"><KbxFormGrid><KbxInput v-model="vm.header.postalCode" label="우편번호" :readonly="fieldReadonly('postalCode')" @update:model-value="vm.touch"/><KbxFormSpan span="full"><KbxInput v-model="vm.header.address1" label="주소" required :readonly="fieldReadonly('address1')" :error="vm.fieldError('address1')" @update:model-value="vm.touch"/></KbxFormSpan><KbxFormSpan span="full"><KbxInput v-model="vm.header.address2" label="상세주소" :readonly="fieldReadonly('address2')" @update:model-value="vm.touch"/></KbxFormSpan></KbxFormGrid></KbxFormSection>
|
||||
</template>
|
||||
<template #detail><section class="order-lines"><KbxSectionHeader title="상품" :count="vm.lines.value.length"/><KbxDataGrid :rows="vm.lines.value" :columns="orderLineColumns" row-key="clientId" selection="multiple" :errors="vm.errors.value" :editable="!readonly" :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="vm.addLine" @row-duplicate-requested="vm.duplicateLines" @cell-changed="vm.onCellChanged" @lookup-requested="openGridLookup"/></section></template>
|
||||
</KbxTransactionPage>
|
||||
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem"/>
|
||||
</template>
|
||||
<style scoped>.order-lines{min-height:var(--kbx-grid-min-height);display:flex;flex-direction:column;gap:var(--kbx-space-2)}</style>
|
||||
+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}})},
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxAuditEntry, KbxConflictSnapshot, 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 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){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(){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}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')}catch(error){const problem=toKbxProblem(error);if(problem?.type==='validation')validation.applyProblem(problem);if(problem?.type==='conflict'&&orderId.value){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:problem.code,title:problem.title,detail:'입력 중인 주문은 유지됩니다. 최신 값과 비교한 뒤 다시 읽으세요.',entityId:orderId.value,requestedVersion:version.value,currentVersion:problem.currentVersion??latest.version,changes:[['orderDate','주문일'],['customerId','거래처'],['warehouseId','출고창고'],['receiverName','수취인'],['address1','주소']].map(([field,label])=>({field,label,mine:(header as any)[field],latest:(latest as any)[field]}))}}task.abandon(problem?.type??'system');if(!problem||problem.type==='system')throw error}}
|
||||
async function confirm(){if(!orderId.value||version.value==null||dirty.value)return;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}throw e}}
|
||||
async function reloadConflict(){if(orderId.value)await load(orderId.value)}
|
||||
function dismissConflict(){conflict.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;validation.clear();resetDirty()}
|
||||
return{orderId,orderNo,version,status,header,lines,dirty,errors:validation.errors,fieldError:validation.fieldError,totalQty,totalAmount,saving:computed(()=>mutation.isPending.value),auditEntries,conflict,addLine,duplicateLines,onCellChanged,applyItemLookup,save,confirm,load,reloadConflict,dismissConflict,createNew,touch}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { KbxDataGrid, KbxListPage, KbxQuickFilterBar, KbxSearchPanel, KbxSummaryBar, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { orderColumns, orderListScreen, orderSearchFields } from './order-list.definition'
|
||||
import { useOrderSearch } from './useOrderSearch'
|
||||
import { useKbxExperiment } from '../../../../experiments/kbxExperimentClient'
|
||||
|
||||
const vm=useOrderSearch();const router=useRouter()
|
||||
const exceptionSummaryExperiment=useKbxExperiment(orderListScreen.id,orderListScreen.version,'exp.oms.order-list.exception-summary-v2','information-emphasis')
|
||||
useKbxPageShortcuts([{key:'F3',execute:vm.executeSearch}])
|
||||
async function selectQuickFilter(key:string){
|
||||
vm.search.exceptionOnly=false
|
||||
vm.search.status=null
|
||||
if(key==='new')vm.search.status='NEW'
|
||||
if(key==='ready')vm.search.status='READY'
|
||||
if(key==='exceptions')vm.search.exceptionOnly=true
|
||||
await vm.executeSearch()
|
||||
}
|
||||
const quickFilters=computed(()=>{
|
||||
const c=vm.result.value?.counters
|
||||
if(!c)return []
|
||||
return [
|
||||
{key:'all',label:'전체',count:c.all,active:!vm.search.status&&!vm.search.exceptionOnly},
|
||||
{key:'new',label:'신규',count:c.new,active:vm.search.status==='NEW'},
|
||||
{key:'ready',label:'출고대기',count:c.readyToShip,active:vm.search.status==='READY'},
|
||||
{key:'exceptions',label:'오류',count:c.exceptions,active:vm.search.exceptionOnly,tone:'danger' as const},
|
||||
]
|
||||
})
|
||||
const pageContext=computed(()=>vm.result.value?{label:`${vm.appliedSearch.value.from} ~ ${vm.appliedSearch.value.to}`,hint:vm.appliedSearch.value.exceptionOnly?'예외 주문만 조회 중':'현재 적용된 조회조건 기준',metrics:[{key:'result',label:'조회',value:vm.result.value.totalCount},{key:'selected',label:'선택',value:vm.selectionCount.value},{key:'exceptions',label:'오류',value:vm.result.value.counters.exceptions,tone:'danger' as const,emphasis:vm.result.value.counters.exceptions>0}]}:null)
|
||||
const summaryItems=computed(()=>vm.result.value?[
|
||||
{key:'rows',label:'조회',value:`${vm.result.value.totalCount.toLocaleString('ko-KR')}건`},
|
||||
{key:'selected',label:'선택',value:`${vm.selectionCount.value.toLocaleString('ko-KR')}건`},
|
||||
{key:'qty',label:'수량',value:vm.result.value.totalQty},
|
||||
{key:'amount',label:'금액',value:`₩${vm.result.value.totalAmount.toLocaleString('ko-KR')}`,emphasis:true},
|
||||
]:[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxListPage :screen="orderListScreen" :selection-count="vm.selectionCount.value" :context="pageContext" :content-state="vm.error.value?'error':vm.loading.value&&!vm.result.value?'loading':!vm.result.value?'idle':vm.result.value.totalCount===0?'empty':'ready'" :refreshing="vm.loading.value&&Boolean(vm.result.value)" breadcrumb="OMS > 주문" @command="vm.executeCommand">
|
||||
<template #search><KbxSearchPanel :model-value="vm.search" :fields="orderSearchFields" @update:model-value="value=>Object.assign(vm.search,value)" @search="vm.executeSearch"/></template>
|
||||
<template #quick-filter>
|
||||
<div v-if="vm.result.value&&exceptionSummaryExperiment.is('exception-summary')" class="experiment-summary"><strong>확인 필요</strong> · 오류 {{vm.result.value.counters.exceptions.toLocaleString()}}건 — 기존 조회·처리 문법은 그대로 유지됩니다.</div>
|
||||
<KbxQuickFilterBar v-if="quickFilters.length" :items="quickFilters" @select="selectQuickFilter"/>
|
||||
</template>
|
||||
<template #content><KbxDataGrid :rows="vm.rows.value" :columns="orderColumns" row-key="id" selection="multiple" :loading="vm.loading.value" :total-count="vm.result.value?.totalCount??0" :selection-state="vm.selectionState.value" allow-all-filtered-selection @selection-state-changed="value=>vm.selectionState.value=value" @row-double-clicked="row=>router.push({name:'oms-order-edit',params:{orderId:row.id}})"/></template>
|
||||
<template #summary><KbxSummaryBar v-if="summaryItems.length" :items="summaryItems"/></template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.experiment-summary{margin-bottom:var(--kbx-space-1);padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
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.1.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: 'hold', label: '보류', group: 'workflow', requiresSelection: true, minSelection: 1 },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
})
|
||||
|
||||
export const orderColumns: KbxGridColumn<OrderSearchRow>[] = [
|
||||
{ field: 'orderNo', header: '주문번호', type: 'link', width: 150, pinned: 'left' },
|
||||
{ field: 'channelName', header: '판매채널', width: 110 },
|
||||
{ field: 'orderedAt', header: '주문일시', type: 'datetime', width: 160 },
|
||||
{ field: 'customerName', header: '주문자', width: 120 },
|
||||
{ field: 'itemSummary', header: '대표상품', width: 240 },
|
||||
{ field: 'totalQty', header: '수량', type: 'quantity', width: 90 },
|
||||
{ field: 'amount', header: '금액', type: 'money', width: 130 },
|
||||
{ field: 'allocationStatus', header: '재고', type: 'status', width: 100 },
|
||||
{ field: 'shipmentStatus', header: '출고상태', type: 'status', width: 110 },
|
||||
{ field: 'exceptionCount', header: '오류', type: 'integer', width: 80 },
|
||||
]
|
||||
|
||||
export const orderSearchFields: KbxSearchField[] = [
|
||||
{ key: 'period', label: '주문기간', type: 'date-range', range: { from: 'from', to: 'to' } },
|
||||
{ key: 'status', label: '상태', type: 'select', options: [
|
||||
{ value: 'READY', label: '출고대기' },
|
||||
{ value: 'SHIPPED', label: '출고완료' },
|
||||
{ value: 'HOLD', label: '보류' },
|
||||
] },
|
||||
{ key: 'keyword', label: '통합검색', type: 'text', width: 'lg', placeholder: '주문번호/주문자/상품' },
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
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 const orderApi = {
|
||||
search(filter: OrderSearchFilter) {
|
||||
return kbxApi.request<OrderSearchResponse>('oms.orders.search', { query: filter })
|
||||
},
|
||||
|
||||
ship(selection: KbxBulkSelectionRequest<OrderBulkFilter>) {
|
||||
return kbxApi.request<ShipOrdersResult, KbxBulkSelectionRequest<OrderBulkFilter>>('oms.orders.ship', {
|
||||
body: selection,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
})
|
||||
},
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { KbxSelectionState } from '@kbx/contracts'
|
||||
import { toKbxBulkSelectionRequest } from '@kbx/ui'
|
||||
import { orderApi, type OrderBulkFilter, type OrderSearchFilter } from './orderApi'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { orderListScreen } from './order-list.definition'
|
||||
|
||||
function todayIso() { return new Date().toISOString().slice(0, 10) }
|
||||
|
||||
export function useOrderSearch() {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const selectionState = ref<KbxSelectionState<string>>({ mode:'explicit', selectedIds:[] })
|
||||
|
||||
const search = reactive<OrderSearchFilter>({
|
||||
from: todayIso(), to: todayIso(), channelId: null, status: null,
|
||||
exceptionOnly: false, keyword: '', page: 1, pageSize: 100,
|
||||
})
|
||||
const appliedSearch = ref<OrderSearchFilter>({ ...search })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['oms', 'orders', appliedSearch.value]),
|
||||
queryFn: () => orderApi.search({ ...appliedSearch.value }),
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const shipMutation = useMutation({
|
||||
mutationFn: (selection: ReturnType<typeof currentBulkSelection>) => orderApi.ship(selection),
|
||||
onSuccess: async () => {
|
||||
selectionState.value = { mode:'explicit', selectedIds:[] }
|
||||
await queryClient.invalidateQueries({ queryKey: ['oms', 'orders'] })
|
||||
await query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectionState.value.mode === 'all-filtered'
|
||||
? Math.max((query.data.value?.totalCount ?? 0) - (selectionState.value.excludedIds?.length ?? 0), 0)
|
||||
: selectionState.value.selectedIds.length)
|
||||
|
||||
function currentBulkFilter(): OrderBulkFilter {
|
||||
return {
|
||||
from: appliedSearch.value.from, to: appliedSearch.value.to, channelId: appliedSearch.value.channelId,
|
||||
status: appliedSearch.value.status, exceptionOnly: appliedSearch.value.exceptionOnly, keyword: appliedSearch.value.keyword,
|
||||
}
|
||||
}
|
||||
|
||||
function currentBulkSelection() {
|
||||
return toKbxBulkSelectionRequest(selectionState.value, currentBulkFilter())
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
const started=performance.now();selectionState.value={mode:'explicit',selectedIds:[]};appliedSearch.value={...search}
|
||||
kbxTelemetry.track('command.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{commandId:'search',operationKind:'query'}})
|
||||
kbxTelemetry.track('search.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{resultBucket:'requested'}})
|
||||
await query.refetch()
|
||||
kbxTelemetry.track('command.succeeded',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,durationMs:Math.round(performance.now()-started),attributes:{commandId:'search',operationKind:'query'}})
|
||||
}
|
||||
|
||||
async function executeCommand(commandId: string) {
|
||||
const handlers: Record<string, () => unknown | Promise<unknown>> = {
|
||||
search: executeSearch,
|
||||
new: () => router.push({ name: 'oms-order-new' }),
|
||||
ship: async () => {
|
||||
const task=startKbxTask(orderListScreen.id,orderListScreen.version,'ship-orders');task.interaction('bulk-command','ship')
|
||||
kbxTelemetry.track('grid.bulk_action',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,taskSessionId:task.id,attributes:{commandId:'ship',countBucket:selectionCount.value<10?'1-9':selectionCount.value<100?'10-99':'100+',selectionMode:selectionState.value.mode}})
|
||||
try{await shipMutation.mutateAsync(currentBulkSelection());task.complete('success')}catch(error){task.abandon('failed');throw error}
|
||||
},
|
||||
hold: () => Promise.resolve(),
|
||||
excel: () => Promise.resolve(),
|
||||
}
|
||||
return handlers[commandId]?.()
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
appliedSearch,
|
||||
rows: computed(() => query.data.value?.items ?? []),
|
||||
result: computed(() => query.data.value),
|
||||
loading: computed(() => query.isFetching.value),
|
||||
error: computed(() => query.error.value),
|
||||
selectionState,
|
||||
selectionCount,
|
||||
executeSearch,
|
||||
executeCommand,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { countingScreen } from './counting.definition'
|
||||
const online=ref(true);const stage=ref<'location'|'item'|'counted'>('location');const location=ref('');const item=ref('');const counted=ref(0);const bookQty=ref(12);const msg=ref('실사 위치를 스캔하세요.')
|
||||
function scan(v:string){if(stage.value==='location'){location.value=v;stage.value='item';msg.value='상품을 스캔하세요.';return}if(stage.value==='item'){item.value=v;counted.value++;msg.value='계속 스캔하거나 수량 확정하세요.'}}
|
||||
function finish(){stage.value='counted';msg.value=counted.value===bookQty.value?'장부수량과 일치합니다.':'차이가 있어 관리자 확인 대상으로 등록합니다.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :online="online"><p class="msg">{{msg}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>{{item}}</strong><p>실사 {{counted}} · 장부 {{bookQty}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'위치 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" @click="finish"/><KbxWmsActionButton v-if="stage==='counted'" label="다음 위치" @click="stage='location';location='';item='';counted=0;msg='실사 위치를 스캔하세요.'"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.box{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.box small{display:block;color:var(--kbx-color-text-muted)}.box strong{font-size:28px}.box p{font-size:20px;font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const countingScreen=defineKbxScreen({id:'WMS-COUNT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'재고실사',helpKey:'WMS-COUNT-001',permissions:['wms.inventory.count'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsCountingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/counting/:taskId', name:'wms-counting', component:()=>import('./WmsCountingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-COUNT-001' } },
|
||||
]
|
||||
+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>
|
||||
@@ -0,0 +1,148 @@
|
||||
<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"
|
||||
>
|
||||
<p v-if="vm.message.value" class="message" role="status">{{ vm.message.value }}</p>
|
||||
|
||||
<section v-if="vm.task.value" class="instruction">
|
||||
<p class="eyebrow">{{ stageTitle }}</p>
|
||||
|
||||
<template v-if="current">
|
||||
<div class="location">
|
||||
<span>LOCATION</span>
|
||||
<strong>{{ current.locationCode }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<span>상품</span>
|
||||
<strong>{{ current.itemName }}</strong>
|
||||
<small>{{ current.itemCode }}<template v-if="current.itemOption"> · {{ current.itemOption }}</template></small>
|
||||
<small>바코드 {{ current.barcode }}</small>
|
||||
</div>
|
||||
|
||||
<div class="qty">
|
||||
<div><span>필요</span><strong>{{ current.requiredQty }}</strong></div>
|
||||
<div><span>피킹</span><strong>{{ current.pickedQty }}</strong></div>
|
||||
<div><span>남음</span><strong>{{ current.remainingQty }}</strong></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-if="current && vm.task.value.stage === 'await-item' && current.pickedQty > 0 && current.remainingQty > 0"
|
||||
class="quantity-action"
|
||||
label="수량 직접 입력"
|
||||
variant="secondary"
|
||||
@click="vm.openQuantity"
|
||||
/>
|
||||
|
||||
<div v-else-if="vm.task.value.stage === 'ready'" class="ready">
|
||||
<strong>{{ vm.task.value.taskNo }}</strong>
|
||||
<span>총 {{ vm.task.value.totalLines }}개 라인 · {{ vm.task.value.totalQty }}개</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<KbxBarcodeCapture
|
||||
v-if="vm.task.value && !['ready','completed','blocked'].includes(vm.task.value.stage)"
|
||||
:enabled="vm.scanEnabled.value"
|
||||
:label="vm.scanLabel.value"
|
||||
@scan="vm.handleScan"
|
||||
/>
|
||||
|
||||
<template #actions>
|
||||
<KbxWmsActionButton
|
||||
v-if="vm.task.value?.stage === 'ready'"
|
||||
label="작업 시작"
|
||||
:busy="vm.starting.value"
|
||||
:disabled="!vm.online.value"
|
||||
@click="vm.start"
|
||||
/>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-else-if="vm.task.value && ['await-location','await-item'].includes(vm.task.value.stage)"
|
||||
label="문제 신고"
|
||||
variant="secondary"
|
||||
:disabled="!vm.online.value || vm.pendingCommands.value > 0"
|
||||
@click="vm.openException"
|
||||
/>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-else-if="vm.task.value?.stage === 'completed'"
|
||||
label="작업 목록으로"
|
||||
@click="$router.push('/wms/picking')"
|
||||
/>
|
||||
</template>
|
||||
</KbxWmsMobilePage>
|
||||
|
||||
<PickingQuantitySheet
|
||||
v-if="vm.quantityOpen.value && current"
|
||||
:current="current.pickedQty"
|
||||
:required="current.requiredQty"
|
||||
:busy="vm.settingQuantity.value"
|
||||
@close="vm.closeQuantity"
|
||||
@submit="vm.setQuantity"
|
||||
/>
|
||||
|
||||
<PickingExceptionSheet
|
||||
v-if="vm.exceptionOpen.value"
|
||||
:busy="vm.reportingException.value"
|
||||
@close="vm.closeException"
|
||||
@submit="(type, memo) => vm.reportException(type, memo)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message { margin:0 0 12px; padding:10px 12px; background:var(--kbx-color-surface-subtle); border-radius:6px; font-size:14px; }
|
||||
.instruction { text-align:center; }
|
||||
.eyebrow { margin:0 0 12px; font-size:16px; font-weight:650; }
|
||||
.location { padding:16px; background:var(--kbx-color-surface-muted); border:1px solid var(--kbx-color-border); border-radius:8px; }
|
||||
.location span, .item span { display:block; color:var(--kbx-color-text-muted); font-size:12px; font-weight:600; }
|
||||
.location strong { display:block; margin-top:3px; font-size:32px; letter-spacing:.03em; }
|
||||
.item { padding:20px 4px 8px; }
|
||||
.item strong { display:block; margin-top:4px; font-size:22px; }
|
||||
.item small { display:block; margin-top:4px; color:var(--kbx-color-text-muted); font-size:13px; }
|
||||
.qty { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:12px; }
|
||||
.qty div { border:1px solid var(--kbx-color-border); border-radius:8px; padding:12px 4px; }
|
||||
.qty span { display:block; font-size:13px; color:var(--kbx-color-text-muted); }
|
||||
.qty strong { display:block; margin-top:2px; font-size:28px; }
|
||||
.quantity-action { margin-top:var(--kbx-space-3); }
|
||||
.ready { min-height:260px; display:flex; flex-direction:column; justify-content:center; gap:8px; }
|
||||
.ready strong { font-size:26px; }
|
||||
.ready span { color:var(--kbx-color-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const pickingScreen = defineKbxScreen({
|
||||
id: 'WMS-PICK-001',
|
||||
version: '1.0.0',
|
||||
module: 'WMS',
|
||||
type: 'wms-mobile', templateCode:'T09',
|
||||
title: '출고 피킹',
|
||||
helpKey: 'WMS-PICK-001',
|
||||
permissions: ['wms.picking.execute'],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
KbxWmsExceptionCommand,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsScanResult,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export function getPickingTask(taskId: string) {
|
||||
return kbxApi.request<KbxWmsPickingTask>('wms.picking.getTask', { path: { taskId } })
|
||||
}
|
||||
|
||||
export function startPickingTask(taskId: string, expectedVersion: number, idempotencyKey = crypto.randomUUID()) {
|
||||
return kbxApi.request<KbxWmsPickingTask, { expectedVersion: number }>('wms.picking.start', {
|
||||
path: { taskId }, body: { expectedVersion }, idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function scanPickingBarcode(command: KbxWmsScanCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsScanCommand>('wms.picking.scan', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function reportPickingException(command: KbxWmsExceptionCommand) {
|
||||
return kbxApi.request<KbxWmsPickingTask, KbxWmsExceptionCommand>('wms.picking.reportException', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function setPickingQuantity(command: KbxWmsSetQuantityCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsSetQuantityCommand>('wms.picking.setQuantity', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const wmsPickingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/wms/picking/:taskId',
|
||||
name: 'wms-picking-task',
|
||||
component: () => import('./WmsPickingPage.vue'),
|
||||
props: route => ({ taskId: String(route.params.taskId) }),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type {
|
||||
KbxBarcodeEvent,
|
||||
KbxWmsExceptionType,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} 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),
|
||||
})
|
||||
|
||||
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,
|
||||
starting: startMutation.isPending,
|
||||
scanning: scanMutation.isPending,
|
||||
reportingException: exceptionMutation.isPending,
|
||||
settingQuantity: quantityMutation.isPending,
|
||||
start: () => startMutation.mutateAsync(),
|
||||
handleScan,
|
||||
openException: () => { exceptionOpen.value = true },
|
||||
openQuantity: () => { quantityOpen.value = true },
|
||||
closeQuantity: () => { quantityOpen.value = false },
|
||||
setQuantity: (quantity: number) => quantityMutation.mutateAsync(quantity),
|
||||
closeException: () => { exceptionOpen.value = false },
|
||||
reportException: (type: KbxWmsExceptionType, memo?: string) => exceptionMutation.mutateAsync({ type, memo }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { KbxWmsScanCommand } from '@kbx/contracts'
|
||||
|
||||
const STORAGE_KEY = 'kbx:wms:safe-retry:v1'
|
||||
|
||||
export interface QueuedPickingScan {
|
||||
command: KbxWmsScanCommand
|
||||
queuedAt: string
|
||||
}
|
||||
|
||||
export function readPickingRetryQueue(): QueuedPickingScan[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as QueuedPickingScan[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function queuePickingScan(command: KbxWmsScanCommand) {
|
||||
// Safety-first queue: only unacknowledged commands are retained for replay.
|
||||
// UI stops accepting the next authoritative scan until the server confirms this one.
|
||||
const queue = readPickingRetryQueue()
|
||||
if (!queue.some(x => x.command.idempotencyKey === command.idempotencyKey)) {
|
||||
queue.push({ command, queuedAt: new Date().toISOString() })
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue))
|
||||
}
|
||||
}
|
||||
|
||||
export function removePickingScan(idempotencyKey: string) {
|
||||
const next = readPickingRetryQueue().filter(x => x.command.idempotencyKey !== idempotencyKey)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { putawayScreen } from './putaway.definition'
|
||||
const online=ref(true);const stage=ref<'item'|'location'|'done'>('item');const item=ref('');const suggested=ref('A-01-03');const location=ref('');const msg=ref('적치할 상품을 스캔하세요.')
|
||||
function scan(v:string){if(stage.value==='item'){item.value=v;stage.value='location';msg.value=`추천 위치 ${suggested.value}를 스캔하세요.`;return}if(stage.value==='location'){if(v!==suggested.value){msg.value=`다른 위치입니다. ${suggested.value}로 이동하세요.`;return}location.value=v;stage.value='done';msg.value='적치가 완료되었습니다.'}}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'완료':'적치중'" :online="online"><p class="msg">{{msg}}</p><div v-if="item" class="item"><small>상품</small><strong>{{item}}</strong></div><div v-if="stage!=='item'" class="location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'상품 스캔':'위치 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='done'" label="다음 상품" @click="stage='item';item='';location='';msg='적치할 상품을 스캔하세요.'"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.item,.location{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.item small,.location small{display:block;color:var(--kbx-color-text-muted)}.item strong{font-size:22px}.location strong{font-size:32px}</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const putawayScreen=defineKbxScreen({id:'WMS-PUT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 적치',helpKey:'WMS-PUT-001',permissions:['wms.putaway.execute'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsPutawayRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/putaway/:taskId', name:'wms-putaway', component:()=>import('./WmsPutawayPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-PUT-001' } },
|
||||
]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { KbxBarcodeCapture, KbxWmsActionButton, KbxWmsMobilePage } from '@kbx/ui'
|
||||
import { receivingScreen } from './receiving.definition'
|
||||
const props=defineProps<{taskId:string}>(); const online=ref(true); const stage=ref<'po'|'item'|'qty'|'completed'>('po'); const poNo=ref(''); const item=ref({code:'',name:'',expected:0,received:0}); const message=ref('입고예정번호 또는 ASN을 스캔하세요.')
|
||||
const progress=computed(()=>stage.value==='completed'?'완료':stage.value==='po'?'입고대기':'검수중')
|
||||
function scan(value:string){ if(stage.value==='po'){poNo.value=value; stage.value='item'; message.value='상품을 스캔하세요.'; return} if(stage.value==='item'){item.value={code:value,name:'스캔 품목',expected:10,received:1}; stage.value='qty'; message.value='수량을 확인하세요.'}}
|
||||
function complete(){stage.value='completed';message.value='입고 검수가 완료되었습니다.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="receivingScreen" :progress="progress" :online="online"><p class="msg">{{message}}</p><section v-if="poNo" class="card"><small>입고예정</small><strong>{{poNo}}</strong></section><section v-if="item.code" class="card"><small>상품</small><strong>{{item.name}}</strong><span>{{item.code}}</span><div class="qty">예정 {{item.expected}} · 검수 {{item.received}}</div></section><KbxBarcodeCapture v-if="stage==='po'||stage==='item'" :enabled="online" :label="stage==='po'?'입고예정 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='qty'" label="검수 완료" @click="complete"/><KbxWmsActionButton v-else-if="stage==='completed'" label="다음 입고" @click="stage='po';poNo='';item={code:'',name:'',expected:0,received:0}"/></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.card{display:flex;flex-direction:column;gap:5px;border:1px solid var(--kbx-color-border);border-radius:8px;padding:16px;margin-bottom:12px}.card small,.card span{color:var(--kbx-color-text-muted)}.card strong{font-size:24px}.qty{font-size:20px;font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const receivingScreen=defineKbxScreen({id:'WMS-REC-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 검수',helpKey:'WMS-REC-001',permissions:['wms.receiving.execute'],telemetry:{enabled:true}})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsReceivingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/receiving/:taskId', name:'wms-receiving', component:()=>import('./WmsReceivingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-REC-001' } },
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { KbxDataGrid, KbxQueuePage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { routeForWmsTask, searchWmsWork } from './workApi'
|
||||
import { wmsWorkColumns, wmsWorkScreen, wmsWorkSearchFields, type WmsWorkRow } from './work.definition'
|
||||
const router=useRouter();const search=reactive({taskType:'',status:'READY',owner:'mine',keyword:''});const rows=ref<WmsWorkRow[]>([]);const selection=ref<WmsWorkRow[]>([]);const loading=ref(false);const searched=ref(false);const error=ref<unknown>(null);const activeExceptionKey=ref<string|null>(null)
|
||||
async function executeSearch(){loading.value=true;error.value=null;try{rows.value=await searchWmsWork(search);searched.value=true}catch(cause){error.value=cause;searched.value=true}finally{loading.value=false}}
|
||||
function executeCommand(id:string){if(id==='search')return executeSearch();if(id==='start'&&selection.value[0])return router.push(routeForWmsTask(selection.value[0]))}
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'total',label:'전체 작업',value:rows.value.length},
|
||||
{key:'ready',label:'대기',value:rows.value.filter(x=>x.status==='READY').length},
|
||||
{key:'blocked',label:'예외',value:rows.value.filter(x=>x.status==='BLOCKED').length,emphasis:true},
|
||||
])
|
||||
const exceptionCounters=computed(()=>[{key:'blocked',label:'확인 필요한 작업',count:rows.value.filter(x=>x.status==='BLOCKED').length,severity:'warning' as const}])
|
||||
async function filterException(key:string|null){activeExceptionKey.value=key;search.status=key==='blocked'?'BLOCKED':'READY';await executeSearch()}
|
||||
</script>
|
||||
<template><KbxQueuePage :screen="wmsWorkScreen" :selection-count="selection.length" :content-state="error?'error':loading&&!searched?'loading':!searched?'idle':rows.length===0?'empty':'ready'" :refreshing="loading&&searched" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" breadcrumb="WMS > 작업관리" @command="executeCommand" @exception-filter="filterException"><template #search><KbxSearchPanel v-model="search" :fields="wmsWorkSearchFields" @search="executeSearch"/></template><template #content><KbxDataGrid :rows="rows" :columns="wmsWorkColumns" row-key="taskId" selection="single" :loading="loading" @selection-changed="selection=$event" @row-double-clicked="row=>router.push(routeForWmsTask(row))"/></template></KbxQueuePage></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsWorkRoutes: RouteRecordRaw[] = [{ path:'/wms/work', name:'wms-work', component:()=>import('./WmsWorkPage.vue'), meta:{ screenId:'WMS-WORK-001' } }]
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
|
||||
|
||||
export interface WmsWorkRow {
|
||||
taskId: string
|
||||
taskNo: string
|
||||
taskType: 'RECEIVING' | 'PUTAWAY' | 'PICKING' | 'CHECKING' | 'COUNTING'
|
||||
waveNo?: string
|
||||
ownerName?: string
|
||||
progress: number
|
||||
totalLines: number
|
||||
completedLines: number
|
||||
slaAt?: string
|
||||
status: string
|
||||
exceptionCount: number
|
||||
}
|
||||
|
||||
export const wmsWorkScreen = defineKbxScreen({
|
||||
id: 'WMS-WORK-001', version: '1.0.0', module: 'WMS', type: 'queue', templateCode:'T06', title: '물류 작업',
|
||||
description: '입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회합니다.',
|
||||
helpKey: 'WMS-WORK-001', permissions: ['wms.work.read'], telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
|
||||
{ id:'start', label:'작업 시작', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, maxSelection:1, permission:'wms.work.execute' },
|
||||
],
|
||||
})
|
||||
|
||||
export const wmsWorkSearchFields: KbxSearchField[] = [
|
||||
{ key:'taskType', label:'작업유형', type:'select', primary:true, options:[
|
||||
{value:'RECEIVING',label:'입고검수'},{value:'PUTAWAY',label:'적치'},{value:'PICKING',label:'피킹'},{value:'CHECKING',label:'검수'},{value:'COUNTING',label:'실사'},
|
||||
] },
|
||||
{ key:'status', label:'상태', type:'select', primary:true, options:[{value:'READY',label:'대기'},{value:'IN_PROGRESS',label:'진행'},{value:'BLOCKED',label:'예외'}] },
|
||||
{ key:'owner', label:'작업자', type:'select', primary:true, options:[{value:'mine',label:'내 작업'},{value:'unassigned',label:'미배정'}] },
|
||||
{ key:'keyword', label:'검색', type:'text', primary:true, width:'lg', placeholder:'작업번호 / Wave / 주문번호' },
|
||||
]
|
||||
|
||||
export const wmsWorkColumns: KbxGridColumn<WmsWorkRow>[] = [
|
||||
{ field:'taskNo', header:'작업번호', type:'code', width:150, pinned:'left' },
|
||||
{ field:'taskType', header:'작업유형', width:100 },
|
||||
{ field:'waveNo', header:'Wave', type:'code', width:130 },
|
||||
{ field:'ownerName', header:'작업자', width:100 },
|
||||
{ field:'progress', header:'진행률(%)', type:'percent', width:100 },
|
||||
{ field:'completedLines', header:'완료', type:'integer', width:80 },
|
||||
{ field:'totalLines', header:'전체', type:'integer', width:80 },
|
||||
{ field:'slaAt', header:'SLA', type:'datetime', width:155 },
|
||||
{ field:'status', header:'상태', type:'status', width:100 },
|
||||
{ field:'exceptionCount', header:'예외', type:'integer', width:80 },
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { WmsWorkRow } from './work.definition'
|
||||
export interface WmsWorkSearch { taskType?: string; status?: string; owner?: string; keyword?: string }
|
||||
export async function searchWmsWork(_search: WmsWorkSearch): Promise<WmsWorkRow[]> { return [] }
|
||||
export function routeForWmsTask(row: WmsWorkRow): string {
|
||||
switch (row.taskType) {
|
||||
case 'RECEIVING': return `/wms/receiving/${row.taskId}`
|
||||
case 'PUTAWAY': return `/wms/putaway/${row.taskId}`
|
||||
case 'PICKING': return `/wms/picking/${row.taskId}`
|
||||
case 'COUNTING': return `/wms/counting/${row.taskId}`
|
||||
default: return `/wms/work?task=${encodeURIComponent(row.taskId)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { kbxAiAuthorizationPolicy, kbxPermissionCatalog, kbxSensitiveDataPolicies, type KbxAiCapability } from '@kbx/contracts'
|
||||
const byId = new Map(kbxPermissionCatalog.map(x => [x.id, x]))
|
||||
export function getKbxPermission(id:string){ return byId.get(id) }
|
||||
export function canKbx(granted:readonly string[], permission:string){ return granted.includes(permission) }
|
||||
export function kbxAiCapabilities(granted:readonly string[], screenAllowed:readonly KbxAiCapability[]=['explain','suggest','draft']):KbxAiCapability[]{
|
||||
if(!canKbx(granted,kbxAiAuthorizationPolicy.usePermission)) return []
|
||||
const result=screenAllowed.filter(x=>x!=='execute')
|
||||
if(screenAllowed.includes('execute') && canKbx(granted,kbxAiAuthorizationPolicy.executePermission)) result.push('execute')
|
||||
return result
|
||||
}
|
||||
export function getSensitivePolicyForField(fieldKey:string){ return kbxSensitiveDataPolicies.find(p=>p.fields.includes(fieldKey as never)) }
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { KbxPermissionContext } from '@kbx/contracts'
|
||||
|
||||
export function createPermissionContext(granted: Iterable<string>): KbxPermissionContext {
|
||||
const permissions = new Set(granted)
|
||||
return {
|
||||
has: permission => permissions.has(permission),
|
||||
hasAny: required => required.some(x => permissions.has(x)),
|
||||
hasAll: required => required.every(x => permissions.has(x)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { KbxScreenPreference } from '@kbx/contracts'
|
||||
|
||||
const PREFIX = 'kbx.screen.preference.'
|
||||
|
||||
export function loadScreenPreference(screenId: string): KbxScreenPreference | null {
|
||||
const raw = localStorage.getItem(PREFIX + screenId)
|
||||
if (!raw) return null
|
||||
try { return JSON.parse(raw) as KbxScreenPreference } catch { return null }
|
||||
}
|
||||
|
||||
export function saveScreenPreference(preference: KbxScreenPreference) {
|
||||
localStorage.setItem(PREFIX + preference.screenId, JSON.stringify(preference))
|
||||
}
|
||||
|
||||
export function clearScreenPreference(screenId: string) {
|
||||
localStorage.removeItem(PREFIX + screenId)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// AUTO-GENERATED by scripts/generate-app-screen-registry.mjs. Do not edit.
|
||||
import { externalDataScreen as screen0 } from '../modules/common/external-data/external-data.definition'
|
||||
import { designSystemCatalogScreen as screen1 } from '../modules/common/design-system/design-system.definition'
|
||||
import { experimentsScreen as screen2 } from '../modules/common/experiments/experiments.definition'
|
||||
import { operationsQueueScreen as screen3 } from '../modules/common/operations/operations.definition'
|
||||
import { reconcileScreen as screen4 } from '../modules/common/reconcile/reconcile.definition'
|
||||
import { uxMetricsScreen as screen5 } from '../modules/common/ux-metrics/ux-metrics.definition'
|
||||
import { inventoryScreen as screen6 } from '../modules/erp/inventory/inventory.definition'
|
||||
import { inventoryMoveScreen as screen7 } from '../modules/erp/inventory-move/inventory-move.definition'
|
||||
import { itemMasterScreen as screen8 } from '../modules/erp/items/item.definition'
|
||||
import { itemPriceScreen as screen9 } from '../modules/erp/item-prices/item-price.definition'
|
||||
import { purchaseScreen as screen10 } from '../modules/erp/purchases/purchase.definition'
|
||||
import { claimScreen as screen11 } from '../modules/oms/claims/claims.definition'
|
||||
import { orderListScreen as screen12 } from '../modules/oms/orders/search/order-list.definition'
|
||||
import { orderRegisterScreen as screen13 } from '../modules/oms/orders/register/order-register.definition'
|
||||
import { orderImportScreen as screen14 } from '../modules/oms/orders/import/order-import.definition'
|
||||
import { countingScreen as screen15 } from '../modules/wms/counting/counting.definition'
|
||||
import { pickingScreen as screen16 } from '../modules/wms/picking/picking.definition'
|
||||
import { putawayScreen as screen17 } from '../modules/wms/putaway/putaway.definition'
|
||||
import { receivingScreen as screen18 } from '../modules/wms/receiving/receiving.definition'
|
||||
import { wmsWorkScreen as screen19 } from '../modules/wms/work/work.definition'
|
||||
|
||||
export const generatedScreens = [
|
||||
screen0,
|
||||
screen1,
|
||||
screen2,
|
||||
screen3,
|
||||
screen4,
|
||||
screen5,
|
||||
screen6,
|
||||
screen7,
|
||||
screen8,
|
||||
screen9,
|
||||
screen10,
|
||||
screen11,
|
||||
screen12,
|
||||
screen13,
|
||||
screen14,
|
||||
screen15,
|
||||
screen16,
|
||||
screen17,
|
||||
screen18,
|
||||
screen19,
|
||||
] as const
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerKbxScreens } from '@kbx/ui'
|
||||
import { generatedScreens } from './screens.generated'
|
||||
|
||||
export const appScreens = generatedScreens
|
||||
|
||||
export function installScreenRegistry() {
|
||||
registerKbxScreens(appScreens)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const appRoutes:RouteRecordRaw[]=[
|
||||
{path:'/',redirect:'/home'},
|
||||
{path:'/home',name:'home',component:()=>import('../modules/common/home/HomeRoutePage.vue'),meta:{shellHome:true}},
|
||||
{path:'/oms/orders',name:'oms-orders',component:()=>import('../modules/oms/orders/search/OrderListPage.vue'),meta:{screenId:'OMS-ORD-001'}},
|
||||
{path:'/oms/orders/new',name:'oms-order-new',component:()=>import('../modules/oms/orders/register/OrderRegisterPage.vue'),meta:{screenId:'OMS-ORD-002'}},
|
||||
{path:'/oms/orders/:orderId/edit',name:'oms-order-edit',component:()=>import('../modules/oms/orders/register/OrderRegisterPage.vue'),meta:{screenId:'OMS-ORD-002'}},
|
||||
{path:'/oms/orders/import',name:'oms-order-import',component:()=>import('../modules/oms/orders/import/OrderImportPage.vue'),meta:{screenId:'OMS-ORD-003'}},
|
||||
{path:'/oms/claims',name:'oms-claims',component:()=>import('../modules/oms/claims/ClaimsPage.vue'),meta:{screenId:'OMS-CLM-001'}},
|
||||
{path:'/erp/items',name:'erp-items',component:()=>import('../modules/erp/items/ItemMasterPage.vue'),meta:{screenId:'ERP-MST-ITEM-001'}},
|
||||
{path:'/erp/item-prices',name:'erp-item-prices',component:()=>import('../modules/erp/item-prices/ItemPriceFastEntryPage.vue'),meta:{screenId:'ERP-PRICE-001'}},
|
||||
{path:'/erp/purchases/new',name:'erp-purchase-new',component:()=>import('../modules/erp/purchases/PurchasePage.vue'),meta:{screenId:'ERP-PUR-001'}},
|
||||
{path:'/erp/inventory',name:'erp-inventory',component:()=>import('../modules/erp/inventory/InventoryPage.vue'),meta:{screenId:'ERP-INV-001'}},
|
||||
{path:'/erp/inventory-moves/new',name:'erp-inventory-move-new',component:()=>import('../modules/erp/inventory-move/InventoryMovePage.vue'),meta:{screenId:'ERP-INV-MOVE-001'}},
|
||||
{path:'/wms/work',name:'wms-work',component:()=>import('../modules/wms/work/WmsWorkPage.vue'),meta:{screenId:'WMS-WORK-001'}},
|
||||
{path:'/wms/receiving/:taskId',name:'wms-receiving',component:()=>import('../modules/wms/receiving/WmsReceivingPage.vue'),props:true,meta:{screenId:'WMS-REC-001'}},
|
||||
{path:'/wms/putaway/:taskId',name:'wms-putaway',component:()=>import('../modules/wms/putaway/WmsPutawayPage.vue'),props:true,meta:{screenId:'WMS-PUT-001'}},
|
||||
{path:'/wms/picking/:taskId',name:'wms-picking',component:()=>import('../modules/wms/picking/WmsPickingPage.vue'),props:true,meta:{screenId:'WMS-PICK-001'}},
|
||||
{path:'/wms/counting/:taskId',name:'wms-counting',component:()=>import('../modules/wms/counting/WmsCountingPage.vue'),props:true,meta:{screenId:'WMS-COUNT-001'}},
|
||||
{path:'/operations/exceptions',name:'common-operations-exceptions',component:()=>import('../modules/common/operations/OperationsQueuePage.vue'),meta:{screenId:'COMMON-OPS-001'}},
|
||||
{path:'/operations/reconcile',name:'common-reconcile',component:()=>import('../modules/common/reconcile/ReconcilePage.vue'),meta:{screenId:'COMMON-REC-001'}},
|
||||
{path:'/internal/kbx/experiments',name:'kbx-experiments',component:()=>import('../modules/common/experiments/ExperimentsPage.vue'),meta:{screenId:'COMMON-EXP-001'}},
|
||||
{path:'/internal/kbx/ux-metrics',name:'kbx-ux-metrics',component:()=>import('../modules/common/ux-metrics/UxMetricsPage.vue'),meta:{screenId:'COMMON-UX-001'}},
|
||||
{path:'/internal/kbx/external-data',name:'kbx-external-data',component:()=>import('../modules/common/external-data/ExternalDataStatusPage.vue'),meta:{screenId:'COMMON-DATA-001'}},
|
||||
{path:'/internal/kbx/catalog',name:'kbx-component-catalog',component:()=>import('../modules/common/design-system/ComponentCatalogPage.vue'),meta:{screenId:'COMMON-DS-001'}},
|
||||
{path:'/:pathMatch(.*)*',name:'not-found',component:()=>import('../modules/common/not-found/NotFoundRoutePage.vue'),meta:{shellRecovery:true}},
|
||||
]
|
||||
|
||||
|
||||
/** Canonical route capability map used to validate workspace/deep-link paths before navigation. */
|
||||
export const screenRoutePatterns = appRoutes.reduce<Record<string,string[]>>((acc,route)=>{
|
||||
const screenId=typeof route.meta?.screenId==='string'?route.meta.screenId:''
|
||||
if(!screenId)return acc
|
||||
;(acc[screenId]??=[]).push(String(route.path))
|
||||
return acc
|
||||
},{})
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { KbxNotificationCenter, KbxOperationCenter, KbxRuntimeBanner } from '@kbx/ui'
|
||||
import { ref } from 'vue'
|
||||
import { useKbxRuntime } from './useKbxRuntime'
|
||||
|
||||
const { notice, operations, notifications, unreadCount, refresh, markRead } = useKbxRuntime()
|
||||
const panel = ref<'notifications' | 'operations' | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kbx-runtime-shell">
|
||||
<KbxRuntimeBanner :notice="notice" @retry="refresh" />
|
||||
<header class="tools">
|
||||
<button type="button" @click="panel = panel === 'operations' ? null : 'operations'">작업 {{ operations.filter(x => x.status === 'running' || x.status === 'queued').length }}</button>
|
||||
<button type="button" @click="panel = panel === 'notifications' ? null : 'notifications'">알림 {{ unreadCount }}</button>
|
||||
</header>
|
||||
<aside v-if="panel" class="panel">
|
||||
<KbxOperationCenter v-if="panel === 'operations'" :operations="operations" />
|
||||
<KbxNotificationCenter v-else :notifications="notifications" @read="markRead" />
|
||||
</aside>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-runtime-shell { position:relative; min-height:100%; }
|
||||
.tools { display:flex; justify-content:flex-end; gap:4px; min-height:32px; align-items:center; }
|
||||
.tools button { min-height:30px; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface); }
|
||||
.panel { position:absolute; z-index:50; right:8px; top:76px; width:min(420px, calc(100vw - 24px)); max-height:70vh; overflow:auto; padding:8px; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-md); background:var(--kbx-color-surface); box-shadow:0 10px 30px rgba(16,24,40,.16); }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { KbxOperationRun, KbxRuntimeNotice, KbxUserNotification } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
|
||||
export const runtimeApi = {
|
||||
getNotice() {
|
||||
return kbxApi.request<KbxRuntimeNotice | null>('common.runtime.notice')
|
||||
},
|
||||
getOperations() {
|
||||
return kbxApi.request<KbxOperationRun[]>('common.runtime.operations', { query: { limit: 20 } })
|
||||
},
|
||||
getNotifications() {
|
||||
return kbxApi.request<KbxUserNotification[]>('common.runtime.notifications', { query: { limit: 30 } })
|
||||
},
|
||||
markNotificationRead(id: string) {
|
||||
return kbxApi.request<void>('common.runtime.notificationRead', { path: { Id: id } })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { KbxOperationRun, KbxRuntimeNotice, KbxUserNotification } from '@kbx/contracts'
|
||||
import { runtimeApi } from './runtimeApi'
|
||||
|
||||
export function useKbxRuntime() {
|
||||
const notice = ref<KbxRuntimeNotice | null>(null)
|
||||
const operations = ref<KbxOperationRun[]>([])
|
||||
const notifications = ref<KbxUserNotification[]>([])
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
async function refresh() {
|
||||
const [nextNotice, nextOperations, nextNotifications] = await Promise.all([
|
||||
runtimeApi.getNotice(),
|
||||
runtimeApi.getOperations(),
|
||||
runtimeApi.getNotifications(),
|
||||
])
|
||||
notice.value = nextNotice
|
||||
operations.value = nextOperations
|
||||
notifications.value = nextNotifications
|
||||
}
|
||||
|
||||
async function markRead(id: string) {
|
||||
await runtimeApi.markNotificationRead(id)
|
||||
const item = notifications.value.find(x => x.id === id)
|
||||
if (item && !item.readAt) item.readAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
timer = setInterval(() => void refresh(), 30_000)
|
||||
})
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
|
||||
return {
|
||||
notice,
|
||||
operations,
|
||||
notifications,
|
||||
unreadCount: computed(() => notifications.value.filter(x => !x.readAt).length),
|
||||
refresh,
|
||||
markRead,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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,
|
||||
} 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'
|
||||
|
||||
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 { notice, operations, notifications, unreadCount, refresh, markRead }=useKbxRuntime()
|
||||
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){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)))
|
||||
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;runtimePanel.value=null;if(previous!==undefined&&scope!==previous&&route.path!=='/home')void router.replace('/home')},{immediate:true})
|
||||
watch(()=>props.grantedPermissions.join('|'),()=>{utilityTab.value=null;utilityScreen.value=null;runtimePanel.value=null})
|
||||
watch(()=>route.fullPath,()=>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})
|
||||
|
||||
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){runtimePanel.value='operations';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='알림에 포함된 허용되지 않은 이동 경로를 차단했습니다.';runtimePanel.value='notifications';return}
|
||||
await router.push(safe);return
|
||||
}
|
||||
}
|
||||
runtimePanel.value='notifications'
|
||||
}
|
||||
}
|
||||
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:()=>{store.menuSearchOpen=true}}])
|
||||
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" :home-active="isHome" :workspace-max-tabs="store.preference.workspaceMaxTabs" :shell-notice="store.shellNotice"
|
||||
@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="store.menuSearchOpen=true" @menu-search-close="store.menuSearchOpen=false" @operations="runtimePanel=runtimePanel==='operations'?null:'operations'" @notifications="runtimePanel=runtimePanel==='notifications'?null:'notifications'" @profile="frameEmit('profile')" @dismiss-shell-notice="store.dismissShellNotice"
|
||||
>
|
||||
<template #runtime><KbxRuntimeBanner :notice="notice" @retry="refresh" /><aside v-if="runtimePanel" class="runtime-panel"><KbxOperationCenter v-if="runtimePanel==='operations'" :operations="operations" /><KbxNotificationCenter v-else :notifications="notifications" @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="store.menuSearchOpen=true" @operations="runtimePanel='operations'" @notifications="runtimePanel='notifications'" />
|
||||
<KbxAccessDenied v-else-if="!activeScreenAllowed" @home="openHome" @menu-search="store.menuSearchOpen=true" />
|
||||
<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{position:fixed;z-index:var(--kbx-z-runtime-panel);right:var(--kbx-space-2);top:var(--kbx-runtime-panel-top);width:min(var(--kbx-runtime-panel-width),calc(100vw - var(--kbx-space-6)));max-height:70vh;overflow:auto;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-surface);box-shadow:var(--kbx-shadow-overlay)}</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { KbxNavigationEntry, KbxNavigationResolvedEntry, KbxNavigationSection, KbxScreenDefinition } from '@kbx/contracts'
|
||||
import { generatedScreens } from '../registry/screens.generated'
|
||||
|
||||
export const navigationCatalog: KbxNavigationEntry[] = [
|
||||
{ screenId:'OMS-ORD-001', path:'/oms/orders', section:'주문', keywords:['주문조회','주문관리','출고대기'], order:10, homePriority:10, homeGroup:'주문 처리' },
|
||||
{ screenId:'OMS-ORD-002', path:'/oms/orders/new', section:'주문', keywords:['주문등록','신규주문'], order:20, homePriority:20, homeGroup:'주문 처리', permissions:['oms.order.create','erp.item.read'] },
|
||||
{ screenId:'OMS-ORD-003', path:'/oms/orders/import', section:'주문', keywords:['엑셀','업로드','대량주문'], order:30, homePriority:30, homeGroup:'대량 업무' },
|
||||
{ screenId:'OMS-CLM-001', path:'/oms/claims', section:'CS/클레임', keywords:['반품','교환','취소','클레임'], order:40 },
|
||||
{ screenId:'ERP-MST-ITEM-001', path:'/erp/items', section:'기준정보', keywords:['품목','상품','SKU','마스터'], order:10, homePriority:10, homeGroup:'기준정보' },
|
||||
{ screenId:'ERP-PRICE-001', path:'/erp/item-prices', section:'기준정보', keywords:['품목단가','단가','가격','일괄입력','Fast Entry'], order:15, homePriority:20, homeGroup:'대량 업무' },
|
||||
{ screenId:'ERP-PUR-001', path:'/erp/purchases/new', section:'구매', keywords:['구매','발주','매입'], order:20 },
|
||||
{ screenId:'ERP-INV-001', path:'/erp/inventory', section:'재고', keywords:['재고','현재고','가용재고','로케이션'], order:30, homePriority:30, homeGroup:'재고' },
|
||||
{ screenId:'ERP-INV-MOVE-001', path:'/erp/inventory-moves/new', section:'재고', keywords:['재고이동','창고이동'], order:40 },
|
||||
{ screenId:'WMS-WORK-001', path:'/wms/work', section:'작업관리', keywords:['입고','적치','피킹','실사','작업'], order:10, homePriority:10, homeGroup:'현장 운영' },
|
||||
{ screenId:'COMMON-OPS-001', path:'/operations/exceptions', section:'운영', keywords:['예외','오류','실패','미처리'], order:10, homePriority:5, homeGroup:'예외 처리' },
|
||||
{ screenId:'COMMON-REC-001', path:'/operations/reconcile', section:'운영', keywords:['대사','정합성','불일치','비교'], order:20, homePriority:15, homeGroup:'예외 처리' },
|
||||
{ screenId:'COMMON-EXP-001', path:'/internal/kbx/experiments', section:'KBX', keywords:['UX','실험','A/B','점진배포','롤백'], order:75, menuVisible:false, favoriteAllowed:false },
|
||||
{ screenId:'COMMON-UX-001', path:'/internal/kbx/ux-metrics', section:'KBX', keywords:['UX','지표','텔레메트리','자동화','개입률'], order:80, menuVisible:false, favoriteAllowed:false },
|
||||
{ screenId:'COMMON-DATA-001', path:'/internal/kbx/external-data', section:'KBX', keywords:['외부데이터','KRX','OPENDART','KIS','신선도','출처'], order:85, menuVisible:false, favoriteAllowed:false },
|
||||
{ screenId:'COMMON-DS-001', path:'/internal/kbx/catalog', section:'KBX', keywords:['컴포넌트','디자인시스템','카탈로그'], order:90, menuVisible:false, favoriteAllowed:false },
|
||||
]
|
||||
|
||||
const screenById = new Map<string,KbxScreenDefinition>(generatedScreens.map(screen => [screen.id, screen]))
|
||||
export const resolvedNavigationEntries: KbxNavigationResolvedEntry[] = navigationCatalog.map(entry => {
|
||||
const screen=screenById.get(entry.screenId)
|
||||
if(!screen) throw new Error(`Navigation references unknown screen: ${entry.screenId}`)
|
||||
return { ...entry, title:screen.title, module:screen.module, permissions:entry.permissions ?? screen.permissions, launchMode:entry.launchMode ?? 'tab', menuVisible:entry.menuVisible ?? true, favoriteAllowed:entry.favoriteAllowed ?? true, recentPolicy:entry.recentPolicy ?? 'screen' }
|
||||
})
|
||||
|
||||
const moduleLabel:Record<string,string>={OMS:'OMS',ERP:'ERP',WMS:'WMS',COMMON:'공통 운영'}
|
||||
export const navigationSections:KbxNavigationSection[] = ['OMS','ERP','WMS','COMMON'].flatMap(module => {
|
||||
const moduleEntries=resolvedNavigationEntries.filter(x=>x.module===module && x.menuVisible!==false)
|
||||
const groups=[...new Set(moduleEntries.map(x=>x.section))]
|
||||
return groups.map(group=>({ key:`${module}:${group}`, label:`${moduleLabel[module]} · ${group}`, module:module as KbxNavigationSection['module'], entries:moduleEntries.filter(x=>x.section===group).sort((a,b)=>(a.order??0)-(b.order??0)) }))
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useKbxWorkspaceStore } from './workspaceStore'
|
||||
import { registerWorkspaceLifecycle } from './workspaceLifecycleRegistry'
|
||||
export function useKbxWorkspaceBinding(dirty:Ref<boolean>, save?:()=>boolean|Promise<boolean>){const route=useRoute();const store=useKbxWorkspaceStore();let unregister:(()=>void)|undefined;onMounted(()=>{unregister=registerWorkspaceLifecycle(route.fullPath,{save});store.setActiveDirty(dirty.value)});watch(dirty,value=>store.setActiveDirty(value));onBeforeUnmount(()=>unregister?.())}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface KbxWorkspaceLifecycle {
|
||||
save?: () => boolean | Promise<boolean>
|
||||
}
|
||||
const registry = new Map<string,KbxWorkspaceLifecycle>()
|
||||
export function registerWorkspaceLifecycle(path:string, lifecycle:KbxWorkspaceLifecycle){ registry.set(path,lifecycle); return () => registry.delete(path) }
|
||||
export async function requestWorkspaceSave(path:string):Promise<boolean>{ const handler=registry.get(path)?.save; return handler ? await handler() : false }
|
||||
export function canWorkspaceSave(path:string){ return Boolean(registry.get(path)?.save) }
|
||||
@@ -0,0 +1,111 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { KbxNavigationPreference, KbxNavigationResolvedEntry, KbxRecentNavigation, KbxWorkspaceTab } from '@kbx/contracts'
|
||||
|
||||
const STORAGE_PREFIX='kbx.navigation.preference.v3'
|
||||
const LEGACY_STORAGE_PREFIX='kbx.navigation.preference.v2'
|
||||
const MAX_STORED_PREFERENCE_BYTES=64_000
|
||||
const MAX_SCOPE_LENGTH=160
|
||||
const MAX_ID_LENGTH=128
|
||||
const MAX_TITLE_LENGTH=120
|
||||
const MAX_RECENT_PATH_LENGTH=1024
|
||||
const defaultPreference=():KbxNavigationPreference=>({favorites:[],recents:[],sideNavCollapsed:false,workspaceMaxTabs:10})
|
||||
function boundedString(value:unknown,max:number,truncate=false){if(typeof value!=='string')return '';const clean=value.trim();if(clean.length<=max)return clean;return truncate?clean.slice(0,max):''}
|
||||
function normalizeScope(value?:string|null){const scope=boundedString(value,MAX_SCOPE_LENGTH);return scope||null}
|
||||
function storageKey(scope:string,prefix=STORAGE_PREFIX){return `${prefix}:${encodeURIComponent(scope)}`}
|
||||
function uniqueStrings(values:unknown[],limit:number){return [...new Set(values.map(value=>boundedString(value,MAX_ID_LENGTH)).filter(Boolean))].slice(0,limit)}
|
||||
function normalizePreference(value:unknown,scope:string|null):KbxNavigationPreference{
|
||||
const raw=(value&&typeof value==='object'?value:{}) as Partial<KbxNavigationPreference>
|
||||
const favorites=Array.isArray(raw.favorites)?uniqueStrings(raw.favorites,50):[]
|
||||
const recentsRaw=Array.isArray(raw.recents)?raw.recents.flatMap(item=>{
|
||||
if(!item||typeof item!=='object')return []
|
||||
const candidate=item as Partial<KbxRecentNavigation>
|
||||
const screenId=boundedString(candidate.screenId,MAX_ID_LENGTH)
|
||||
const title=boundedString(candidate.title,MAX_TITLE_LENGTH,true)
|
||||
const path=boundedString(candidate.path,MAX_RECENT_PATH_LENGTH)
|
||||
const visitedAt=boundedString(candidate.visitedAt,64)
|
||||
if(!screenId||!title||!path.startsWith('/')||!visitedAt||Number.isNaN(Date.parse(visitedAt)))return []
|
||||
return [{screenId,title,path,visitedAt} satisfies KbxRecentNavigation]
|
||||
}):[]
|
||||
const recents=[...new Map(recentsRaw.map(item=>[item.screenId,item])).values()].sort((a,b)=>b.visitedAt.localeCompare(a.visitedAt)).slice(0,12)
|
||||
const workspaceMaxTabs=Math.min(12,Math.max(8,Number.isFinite(Number(raw.workspaceMaxTabs))?Number(raw.workspaceMaxTabs):10))
|
||||
return {favorites,recents,sideNavCollapsed:Boolean(raw.sideNavCollapsed),workspaceMaxTabs,...(scope?{scopeKey:scope}:{})}
|
||||
}
|
||||
function parseStoredPreference(raw:string|null,scope:string){
|
||||
if(!raw||raw.length>MAX_STORED_PREFERENCE_BYTES)return null
|
||||
try{return normalizePreference(JSON.parse(raw),scope)}catch{return null}
|
||||
}
|
||||
function loadPreference(scope:string|null):KbxNavigationPreference{
|
||||
if(!scope || typeof localStorage==='undefined')return defaultPreference()
|
||||
const current=parseStoredPreference(localStorage.getItem(storageKey(scope)),scope)
|
||||
if(current)return current
|
||||
const legacy=parseStoredPreference(localStorage.getItem(storageKey(scope,LEGACY_STORAGE_PREFIX)),scope)
|
||||
if(legacy){try{localStorage.setItem(storageKey(scope),JSON.stringify(legacy))}catch{};return legacy}
|
||||
return normalizePreference({},scope)
|
||||
}
|
||||
|
||||
export const useKbxWorkspaceStore=defineStore('kbx-workspace',()=>{
|
||||
const scopeKey=ref<string|null>(null)
|
||||
const preference=ref<KbxNavigationPreference>(defaultPreference())
|
||||
const tabs=ref<KbxWorkspaceTab[]>([])
|
||||
const activeKey=ref<string|null>(null)
|
||||
const menuSearchOpen=ref(false)
|
||||
const shellNotice=ref<string|null>(null)
|
||||
|
||||
function persist(){
|
||||
if(!scopeKey.value||typeof localStorage==='undefined')return
|
||||
try{localStorage.setItem(storageKey(scopeKey.value),JSON.stringify(normalizePreference(preference.value,scopeKey.value)))}
|
||||
catch{shellNotice.value='개인화 설정을 저장하지 못했습니다. 현재 세션에서는 계속 사용할 수 있습니다.'}
|
||||
}
|
||||
function clearWorkspaceSession(){tabs.value=[];activeKey.value=null;menuSearchOpen.value=false;shellNotice.value=null}
|
||||
function setPreferenceScope(value?:string|null){
|
||||
const next=normalizeScope(value)
|
||||
if(next===scopeKey.value)return
|
||||
clearWorkspaceSession()
|
||||
scopeKey.value=next
|
||||
preference.value=loadPreference(next)
|
||||
}
|
||||
function key(screenId:string,path:string){return `${screenId}:${path}`}
|
||||
function evictForCapacity(){
|
||||
if(tabs.value.length<preference.value.workspaceMaxTabs)return true
|
||||
const candidate=[...tabs.value]
|
||||
.filter(tab=>!tab.dirty&&!tab.pinned&&tab.key!==activeKey.value)
|
||||
.sort((a,b)=>a.lastActivatedAt.localeCompare(b.lastActivatedAt))[0]
|
||||
if(!candidate){shellNotice.value=`열린 업무가 최대 ${preference.value.workspaceMaxTabs}개입니다. 미저장 또는 고정된 업무를 정리한 후 다시 여세요.`;return false}
|
||||
close(candidate.key)
|
||||
return true
|
||||
}
|
||||
function syncRoute(screenId:string,title:string,path:string,recent?:KbxRecentNavigation|null){
|
||||
const now=new Date().toISOString();const tabKey=key(screenId,path);let tab=tabs.value.find(x=>x.key===tabKey)
|
||||
if(!tab){if(!evictForCapacity())return false;tab={key:tabKey,screenId,title,path,openedAt:now,lastActivatedAt:now,dirty:false};tabs.value.push(tab)}else{tab.title=title;tab.path=path;tab.lastActivatedAt=now}
|
||||
activeKey.value=tabKey;shellNotice.value=null
|
||||
if(recent)recordRecent(recent)
|
||||
return true
|
||||
}
|
||||
function setDirty(tabKey:string,dirty:boolean){const tab=tabs.value.find(x=>x.key===tabKey);if(tab)tab.dirty=dirty}
|
||||
function setActiveDirty(dirty:boolean){if(activeKey.value)setDirty(activeKey.value,dirty)}
|
||||
function togglePinned(tabKey:string){const tab=tabs.value.find(x=>x.key===tabKey);if(tab)tab.pinned=!tab.pinned}
|
||||
function close(tabKey:string){const index=tabs.value.findIndex(x=>x.key===tabKey);if(index<0)return;const wasActive=activeKey.value===tabKey;tabs.value.splice(index,1);if(wasActive){const next=tabs.value[Math.min(index,tabs.value.length-1)]??tabs.value.at(-1);activeKey.value=next?.key??null}}
|
||||
function activate(tabKey:string){const tab=tabs.value.find(x=>x.key===tabKey);if(tab){activeKey.value=tabKey;tab.lastActivatedAt=new Date().toISOString()}}
|
||||
function deactivate(){activeKey.value=null}
|
||||
function pruneTabs(allowedScreenIds:Set<string>){
|
||||
const active=activeKey.value;const before=tabs.value.length
|
||||
tabs.value=tabs.value.filter(tab=>allowedScreenIds.has(tab.screenId))
|
||||
if(active&&!tabs.value.some(tab=>tab.key===active))activeKey.value=tabs.value.at(-1)?.key??null
|
||||
return before-tabs.value.length
|
||||
}
|
||||
function reconcilePreference(allowedScreenIds:Set<string>){
|
||||
const favorites=preference.value.favorites.filter(id=>allowedScreenIds.has(id))
|
||||
const recents=preference.value.recents.filter(item=>allowedScreenIds.has(item.screenId))
|
||||
const changed=favorites.length!==preference.value.favorites.length||recents.length!==preference.value.recents.length
|
||||
if(changed){preference.value={...preference.value,favorites,recents};persist()}
|
||||
return changed
|
||||
}
|
||||
function toggleFavorite(screenId:string){const normalized=boundedString(screenId,MAX_ID_LENGTH);if(!normalized)return;const list=preference.value.favorites;const index=list.indexOf(normalized);if(index>=0)list.splice(index,1);else list.unshift(normalized);preference.value.favorites=uniqueStrings(list,50);persist()}
|
||||
function recordRecent(recent:KbxRecentNavigation){const normalized=normalizePreference({...preference.value,recents:[recent,...preference.value.recents.filter(x=>x.screenId!==recent.screenId)]},scopeKey.value).recents;preference.value.recents=normalized;persist()}
|
||||
function toggleSideNav(){preference.value.sideNavCollapsed=!preference.value.sideNavCollapsed;persist()}
|
||||
function favoriteEntries(entries:KbxNavigationResolvedEntry[]){const map=new Map(entries.filter(x=>x.favoriteAllowed!==false).map(x=>[x.screenId,x]));return preference.value.favorites.map(id=>map.get(id)).filter((x):x is KbxNavigationResolvedEntry=>Boolean(x))}
|
||||
function dismissShellNotice(){shellNotice.value=null}
|
||||
const activeTab=computed(()=>tabs.value.find(x=>x.key===activeKey.value)??null)
|
||||
return {scopeKey,preference,tabs,activeKey,activeTab,menuSearchOpen,shellNotice,setPreferenceScope,clearWorkspaceSession,syncRoute,setDirty,setActiveDirty,togglePinned,close,activate,deactivate,pruneTabs,reconcilePreference,toggleFavorite,recordRecent,toggleSideNav,favoriteEntries,dismissShellNotice}
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
import { kbxTelemetryCatalog, type KbxApiOperationId, type KbxTelemetryEventName, type KbxUxEvent } from '@kbx/contracts'
|
||||
|
||||
const forbiddenKeyPattern = /(phone|address|name|orderNo|customer|itemCode|barcode|keyword|query|email|businessNumber|entityId)/i
|
||||
const MAX_BUFFER = 50
|
||||
const FLUSH_INTERVAL_MS = 5000
|
||||
|
||||
function sessionId() {
|
||||
const key='kbx.telemetry.session-id'
|
||||
const current=sessionStorage.getItem(key)
|
||||
if(current) return current
|
||||
const value=crypto.randomUUID();sessionStorage.setItem(key,value);return value
|
||||
}
|
||||
|
||||
function sanitizeAttributes(eventName:KbxTelemetryEventName, attributes:Record<string,string>|undefined){
|
||||
if(!attributes) return undefined
|
||||
const allowed=new Set<string>(kbxTelemetryCatalog[eventName].allowedAttributes as readonly string[])
|
||||
const result:Record<string,string>={}
|
||||
for(const [key,value] of Object.entries(attributes)){
|
||||
if(!allowed.has(key)) throw new Error(`Telemetry attribute '${key}' is not allowed for ${eventName}.`)
|
||||
if(forbiddenKeyPattern.test(key)) throw new Error(`Telemetry attribute '${key}' may contain business/sensitive data.`)
|
||||
const normalized=String(value).slice(0,80)
|
||||
if(normalized.length) result[key]=normalized
|
||||
}
|
||||
return Object.keys(result).length?result:undefined
|
||||
}
|
||||
|
||||
class KbxTelemetryClient {
|
||||
private buffer:KbxUxEvent[]=[]
|
||||
private timer:number|undefined
|
||||
private experimentByScreen=new Map<string,{experimentId:string;experimentVariant:string}>()
|
||||
|
||||
bindExperiment(screenId:string,experimentId:string,experimentVariant:string){this.experimentByScreen.set(screenId,{experimentId,experimentVariant})}
|
||||
clearExperiment(screenId:string){this.experimentByScreen.delete(screenId)}
|
||||
|
||||
start(){if(typeof window==='undefined'||this.timer)return;this.timer=window.setInterval(()=>void this.flush(),FLUSH_INTERVAL_MS)}
|
||||
stop(){if(this.timer){window.clearInterval(this.timer);this.timer=undefined}void this.flush()}
|
||||
|
||||
track(eventName:KbxTelemetryEventName,input:Omit<KbxUxEvent,'eventName'|'sessionId'|'occurredAt'>={}){
|
||||
try {
|
||||
const def=kbxTelemetryCatalog[eventName]
|
||||
if(def.requiresDuration && input.durationMs==null) return false
|
||||
const experiment=input.screenId?this.experimentByScreen.get(input.screenId):undefined
|
||||
this.buffer.push({eventName,sessionId:sessionId(),occurredAt:new Date().toISOString(),...experiment,...input,attributes:sanitizeAttributes(eventName,input.attributes)})
|
||||
if(this.buffer.length>=MAX_BUFFER) void this.flush()
|
||||
return true
|
||||
} catch {
|
||||
// Telemetry is never allowed to fail or block a business command.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async flush(){
|
||||
if(!this.buffer.length)return
|
||||
const events=this.buffer.splice(0,MAX_BUFFER)
|
||||
try{await kbxApi.request('common.uxTelemetry.ingest' as KbxApiOperationId,{body:{events}})}
|
||||
catch{this.buffer.unshift(...events);if(this.buffer.length>MAX_BUFFER*4)this.buffer.splice(0,this.buffer.length-MAX_BUFFER*4)}
|
||||
}
|
||||
}
|
||||
export const kbxTelemetry=new KbxTelemetryClient()
|
||||
|
||||
export function startKbxTask(screenId:string,screenVersion:string,taskType:string){
|
||||
const id=crypto.randomUUID(), started=performance.now()
|
||||
kbxTelemetry.track('task.start',{screenId,screenVersion,taskSessionId:id,attributes:{taskType}})
|
||||
return {
|
||||
id,
|
||||
complete(result='success'){kbxTelemetry.track('task.complete',{screenId,screenVersion,taskSessionId:id,durationMs:Math.round(performance.now()-started),attributes:{taskType,result}})},
|
||||
abandon(reasonCode='cancelled'){kbxTelemetry.track('task.abandon',{screenId,screenVersion,taskSessionId:id,durationMs:Math.round(performance.now()-started),attributes:{taskType,reasonCode}})},
|
||||
interaction(interactionType:string,commandId?:string){kbxTelemetry.track('interaction.execute',{screenId,screenVersion,taskSessionId:id,attributes:{interactionType,...(commandId?{commandId}:{})}})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { KbxAiAnswer, KbxAiAskRequest } from '@kbx/contracts'
|
||||
import { kbxApi } from '../http/generated/kbxApiClient'
|
||||
|
||||
export function askKbxAssistant(request: KbxAiAskRequest): Promise<KbxAiAnswer> {
|
||||
return kbxApi.request<KbxAiAnswer, KbxAiAskRequest>('common.ai.ask', { body: request })
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user