V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
# KBX Design Philosophy — Reference Index
이 디렉터리의 4개 문서는 K-ArtSell Aegis 프론트엔드가 채택하는 **디자인 철학 소스**다. `docs/Design/kbx-foundation-v36/`은 이 문서를 구현한 참조 코드(도메인은 OMS/WMS/ERP로 다르지만 UX 계약은 동일)이며, 이식 대상이 아니라 구현 참고용이다.
## 문서 역할
| 문서 | 역할 |
|---|---|
| `KBX Design System v1.0.md` | Primitive → Business Component → Screen Template 3계층, Design Token 체계(spacing/typography/color/density), 컴포넌트별 API 계약 |
| `KBX Business UX-AX Standard v1.0.md` | 7대 설계원칙(Familiar First, Keyboard Accelerated, Grid First, Exception Driven, Predictable Layout, Explicit State, Audit by Default), Desktop 표준 화면 구조, T01~T09 표준 Screen Template, Workspace Tabs/Unsaved Changes 규칙 |
| `KBX Reference Screens v1.0.md` | Global Application Shell 치수(Header 56px / Side Nav 220px / Workspace Tabs 40px / Page Header 48px / Command Bar 44px), 화면별 목업 |
| `KBX Implementation Contract v1.0.md` | Screen ID 규칙, Command/Lookup/Grid 계약, Keyboard Manager 상세 API |
## K-ArtSell 적용 원칙
- Vertical Slice(업무 모듈)는 업무를 구현하고, 공통 계층(`frontend/src/shared/`)은 UX를 구현한다 (Design System §1).
- 신규 공통 컴포넌트는 `Kbx*`가 아니라 이미 채택된 `Ks*` 접두사를 따른다(`V13-FE-005`).
- Design Token은 `--kbx-*`가 아니라 기존 `--ks-*` 네임스페이스에 통합한다(`design-system/tokens.css`).
## Screen Template 재매핑 (KBX T01~T09 ↔ K-ArtSell T01~T12)
K-ArtSell은 `frontend/src/shared/ui/screen-types/catalogue.ts`에서 KBX 템플릿을 금융 자문 도메인 의미로 재정의했다. 번호는 KBX 원본과 일치하지 않는다.
| KBX (OMS/WMS/ERP 의미) | K-ArtSell 번호 | K-ArtSell 의미 |
|---|---|---|
| T01 Search/List | T01 | 검색·목록형 CRUD |
| T02 Master CRUD | T03 | 등록·편집 Form |
| T03 Header+Detail Transaction | T04 / T06 | Master-Detail / 단계 Wizard |
| T04 Fast Grid Entry | **T11 (신규)** | 대량 입력 |
| T05 Master/Detail Explorer | T04 | Master-Detail |
| T06 Work Queue | **T12 (신규)** | 작업 큐 |
| T07 Reconcile/Verification | T09 | 대사·예외 처리 |
| T08 Excel Import | — (범위 밖) | 데이터 유입이 KRX/OpenDart/KIS API 중심이라 미채택 |
| T09 WMS Mobile | — (해당 없음) | 물류 현장 업무 없음 |
| — | T02 | 상세 조회형 (KBX에 없는 K-ArtSell 고유 타입) |
| — | T05 | 검토·승인 Workbench (maker-checker) |
| — | T07 | Dashboard·Scorecard |
| — | T08 | Batch·데이터 운영 |
| — | T10 | 버전 비교·거버넌스 (모델/정책 승격) |
## 셸/홈/워크스페이스 이식 현황
`frontend/src/shared/shell/`에 Global Header · Side Navigation · Workspace Tabs · Ctrl+K 메뉴검색을 Business UX-AX Standard §3~8, §58~59 규격대로 구현한다. 세부 계획은 이식 작업 당시의 계획 문서를 참고(리포지토리 커밋 이력의 `V13-FE-007~010` 참고).
@@ -0,0 +1,91 @@
## KBX UX/AX Review
- [ ] 기존 9개 Screen Type 중 하나를 사용했다.
- [ ] 신규 Component보다 기존 KBX Component 재사용을 먼저 검토했다.
- [ ] 업무 모듈에서 PrimeVue/AG Grid를 직접 import하지 않는다.
- [ ] 주요 명령 위치가 KBX Command Bar 규칙과 같다.
- [ ] Keyboard 흐름(F2/F3/F8/Tab/Enter/Esc)을 검토했다.
- [ ] 입력 가능한 대량 데이터 화면의 Excel 정책을 정의했다.
- [ ] 정상 건을 사용자가 불필요하게 확인하는 단계가 없는지 검토했다.
- [ ] 오류 메시지가 원인과 다음 행동을 설명한다.
- [ ] Client validation을 업무 정합성의 최종 방어선으로 사용하지 않는다.
- [ ] 상태 변경, Audit, Concurrency, Idempotency 영향도를 검토했다.
- [ ] AI가 없어도 동일 업무를 수행할 수 있다.
- [ ] AI Action은 Proposal → Validation → Command 경계를 지킨다.
- [ ] 현장 화면은 실제 장비/네트워크 조건의 수용시험 항목을 정의했다.
## Architecture
- [ ] `node scripts/validate-kbx.mjs` 통과
- [ ] Screen ID / Version 갱신 여부 검토
- [ ] 기술부채 또는 KBX 예외가 있다면 이유와 재검토 시점을 기록했다.
## Production readiness
- [ ] Long operations do not block the page with a modal spinner
- [ ] Mutation retries are idempotent or explicitly disabled
- [ ] 409/version conflicts cannot silently overwrite newer data
- [ ] Stale operational data exposes freshness/reload where material
- [ ] Unexpected errors expose a correlation reference, not stack traces
- [ ] Degraded/read-only behavior is defined for affected workflows
## Component verification
- [ ] 공통 Component 변경이면 `COMMON-DS-001`에서 Default/Readonly/Disabled/Error/Loading 상태를 확인했다.
- [ ] Keyboard/Focus 계약 변경이면 E2E scenario를 갱신했다.
- [ ] 의도된 시각 변경이면 Compact/Comfortable/Touch baseline 변경 사유를 기록했다.
- [ ] 공통 Component의 동작/표현 변경이면 Component Version을 검토했다.
- [ ] 색상만으로 상태를 전달하거나 Focus Indicator를 제거하지 않았다.
## Design ↔ Code parity / release
- [ ] Design Token 변경은 `packages/kbx-ui/src/tokens/source/kbx.tokens.json`에서 시작했다.
- [ ] Semantic/Component Token 값 변경이면 Visual Regression 영향도를 검토했다.
- [ ] Core Component 상태가 `COMMON-DS-001`과 Figma contract에 모두 존재한다.
- [ ] 새 raw color/px literal을 추가하지 않았다. 필요한 경우 먼저 Token 승격을 검토했다.
- [ ] Component/Screen/Token 공개 계약 변경이면 `generated/release-impact.json`의 요구 bump를 확인했다.
- [ ] Breaking 변경이면 Migration Guide를 작성했다.
## API contract / Problem governance
- [ ] 업무 모듈에서 raw `/api/...`, `axios`, `fetch`를 직접 사용하지 않는다.
- [ ] 신규/변경 Endpoint는 `contracts/api/kbx.api.json`과 동일한 Method/Route/Permission을 가진다.
- [ ] Mutation Retry 가능 여부와 Idempotency 정책을 정의했다.
- [ ] Validation/Business/Conflict/Permission/NotFound/Integration/System 오류를 KBX Problem으로 표현한다.
- [ ] API 공개 계약 변경이면 `generated/release-impact.json`의 SemVer 요구수준을 확인했다.
- [ ] Host에서 실제 Swashbuckle OpenAPI snapshot diff를 수행했다.
## Authorization / Sensitive Data
- [ ] 신규 Permission은 `contracts/authorization/kbx.authorization.json`에 등록했습니다.
- [ ] Frontend 숨김/Disabled만으로 보안을 처리하지 않고 Backend 최종 검증이 있습니다.
- [ ] Sensitive Field는 기본 Masking이며 전체보기/비마스킹 Export 권한을 구분했습니다.
- [ ] Sensitive 원문을 Telemetry/AI Context에 넣지 않았습니다.
- [ ] 전체보기 또는 민감 데이터 공개가 필요한 경우 Audit 경로를 정의했습니다.
## KBX v18 Scenario / Test Data
- [ ] 변경된 Golden Screen/업무 경계의 canonical scenario를 갱신했다.
- [ ] Fixture는 synthetic-only이며 Production dump를 포함하지 않는다.
- [ ] `idempotency=required` Command의 replay scenario가 있다.
- [ ] Host에서 실행한 경우 Scenario Evidence/Correlation ID를 남겼다.
## External integration / resilience
- [ ] Business State와 Integration State를 분리했다.
- [ ] 새 외부연계는 `contracts/integrations/kbx.integrations.json`에 등록했다.
- [ ] at-least-once 전달은 idempotency 경계를 가진다.
- [ ] 짧은 transient retry는 bounded Polly pipeline이고 장기 retry는 Hangfire가 소유한다.
- [ ] permanent failure는 사용자 업무 재실행이 아니라 Operations Exception으로 노출된다.
- [ ] 실패주입 Scenario가 있다.
## External data / provenance
- [ ] 외부 Provider 응답을 화면이 직접 해석하지 않고 canonical normalizer를 거칩니다.
- [ ] providerObservedAt / receivedAt / ingestedAt 의미를 혼합하지 않았습니다.
- [ ] Stale/Expired 데이터가 최신값처럼 보이지 않습니다.
- [ ] request_descriptor에는 Secret/Token이 없고 raw payload 저장은 명시적 검토 없이는 금지합니다.
- [ ] KRX 승인 서비스의 TTL/Schema를 공식 서비스 명세 없이 추정하지 않았습니다.
## Configuration / deployment governance
- [ ] 신규 설정은 `contracts/configuration/kbx.configuration.json`에 등록했고 직접 `Environment.GetEnvironmentVariable()`을 사용하지 않았다.
- [ ] Secret에는 기본값/예제값을 넣지 않았고 generated env example도 빈 값이다.
- [ ] Production은 `predeploy` migration + HTTPS 원칙을 유지한다.
- [ ] 환경별 다른 바이너리를 다시 빌드하지 않고 동일 Release Artifact를 승격한다.
- [ ] destructive migration이 필요하다면 명시적 승인 marker와 Migration Guide가 있다.
- [ ] 배포 전 Configuration Validation / Migration Dry Run / Release Governance evidence를 확인했다.
@@ -0,0 +1,54 @@
name: KBX Quality Gate
on:
push:
pull_request:
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate KBX architecture
run: node scripts/validate-kbx.mjs
- name: Ensure generated manifests are committed
run: git diff --exit-code -- generated/ apps/web/src/registry/screens.generated.ts packages/kbx-ui/src/tokens/kbx.css packages/kbx-contracts/src/generated/ backend/Shared/Contracts/Generated/ backend/Shared/Authorization/Generated/ backend/Shared/Telemetry/Generated/ backend/Shared/Experiments/Generated/ backend/Shared/Testing/Generated/ backend/Shared/Integrations/Generated/ backend/Shared/Providers/Generated/ backend/Shared/ExternalData/Generated/ backend/Shared/Configuration/Generated/ design/figma/ contracts/api/openapi.kbx.json deploy/kbx/
frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Install and test when lockfile exists
shell: bash
run: |
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
pnpm -r --if-present run typecheck
pnpm -r --if-present run test
pnpm -r --if-present run build
echo "KBX canonical scenario contract validated statically; Playwright host run belongs to product repository."
else
echo "Starter has no pnpm-lock.yaml yet; dependency build gate is intentionally deferred."
fi
backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build/test when solution exists
shell: bash
run: |
shopt -s nullglob
solutions=( *.sln *.slnx )
if [ ${#solutions[@]} -gt 0 ]; then
dotnet restore "${solutions[0]}"
dotnet build "${solutions[0]}" --no-restore -c Release
dotnet test "${solutions[0]}" --no-build -c Release
else
echo "Reference starter has no .NET solution file; backend compile gate is deferred to host repository."
fi
@@ -0,0 +1,38 @@
name: KBX Release Readiness
on:
workflow_dispatch:
inputs:
environment:
description: Target environment (Staging or Production)
required: true
default: Staging
jobs:
release-readiness:
runs-on: ubuntu-latest
steps:
- name: Checkout immutable candidate
uses: actions/checkout@v4
- name: Validate KBX contracts and configuration governance
run: node scripts/validate-kbx.mjs
- name: Verify generated configuration/deployment artifacts
run: git diff --exit-code -- generated/configuration-manifest.json packages/kbx-contracts/src/generated/configurationCatalog.ts backend/Shared/Configuration/Generated/KbxConfigurationCatalog.g.cs deploy/kbx/
- name: Build/test when host repository is available
shell: bash
run: |
shopt -s nullglob
solutions=( *.sln *.slnx )
if [ ${#solutions[@]} -gt 0 ]; then
dotnet restore "${solutions[0]}"
dotnet build "${solutions[0]}" --no-restore -c Release
dotnet test "${solutions[0]}" --no-build -c Release
else
echo "Starter: host build is deferred."
fi
- name: Configuration validation gate
run: echo "Host must bind target-environment secrets/config and call KbxConfigurationStartupValidator.ValidateOrThrow before promotion."
- name: Database migration dry-run gate
run: echo "Host must execute DbUp validation/dry-run against an environment-equivalent database before applying migrations."
- name: Migration application policy
run: echo "Production migrations are pre-deploy; application startup schema mutation is forbidden."
+50
View File
@@ -0,0 +1,50 @@
# KBX Foundation v36 — Recipe Verification · Generated Test Plan · Deterministic Home Workbench
KBX v36는 v35의 Screen Recipe / Secure Scaffolder 기반을 실제 검증계약까지 닫는 Foundation iteration이다. 목표는 화면·컴포넌트 수를 더 늘리는 것이 아니라, T01~T09를 선택한 순간부터 표준 UX, 보안, 복구, 테스트 증적이 함께 따라오도록 만드는 것이다.
## v36 핵심
- T01~T09 `testProfile` 추가
- 필수 Scenario Kind
- 필수 Behavioral Tag
- 필수 Evidence
- 필수 Recovery/Interaction Check
- `generated/screen-recipe-verification-manifest.json` 추가
- T01~T09 Recipe Verification **9/9 자동 폐쇄성**
- 각 Recipe는 실제 해당 Template을 사용하는 Screen의 Canonical E2E Scenario를 최소 1개 요구
- T02 Master / T06 Queue / T07 Reconcile Canonical E2E Recovery Scenario 추가
- Scaffolder가 신규 Vertical Slice에 `*.test-plan.ts` 자동 생성
- 생성 Test Plan은 Canonical Scenario / Required Check / Required Evidence를 타입 계약으로 보존
- 기존 Master / Transaction / Fast Entry의 명시적 `--write-permission` fail-closed 정책 유지
- Home Attention Queue를 동일 우선순위 내 최신 발생 순으로 정렬
- Home은 표시 상한과 별개로 권한 필터 후 실제 전체 건수 및 overflow를 정확히 표시
- `buildKbxHomeAttentionQueue()` public API 추가, 기존 `buildKbxHomeAttention()` 호환 유지
- Screen Recipe `testProfile` / `canonicalScenarioIds` 변경을 Release Impact에서 추적
- Design Debt ratchet **131 유지**
## 검증
```bash
node scripts/validate-kbx.mjs
```
최종 검증 기준:
- 299 TS/Vue script units
- 20 Screen Definitions
- 84 Component Definitions
- 66 Component Catalog entries
- 153 Design Tokens
- 9 Screen Recipes
- 30 Canonical Test Scenarios
- Recipe Verification 9/9
- 16 Navigation Entries
- Design Debt 131 <= 131
- Release Impact major / major
## 문서
- `docs/screen-recipe-verification-home-attention-v36.md`
- `docs/kbx-v36-standard-traceability.md`
- `docs/validation-report-v36.md`
- `docs/release/migration-guide.md`
@@ -0,0 +1,10 @@
import '@kbx/ui/tokens.css'
import type { App } from 'vue'
import { kbxLookupRegistryKey } from '@kbx/ui'
import { lookupRegistry } from '../lookups/lookupRegistry'
import { installScreenRegistry } from '../registry/screens'
export function installKbx(app: App) {
app.provide(kbxLookupRegistryKey, lookupRegistry)
installScreenRegistry()
}
@@ -0,0 +1,6 @@
import * as signalR from '@microsoft/signalr'
export interface KbxExperimentChangedEvent { experimentId:string; screenId:string; changedAt:string }
export function createExperimentRolloutConnection(url='/hubs/kbx-experiments'){
const hub=new signalR.HubConnectionBuilder().withUrl(url).withAutomaticReconnect().build()
return {start:()=>hub.state===signalR.HubConnectionState.Disconnected?hub.start():Promise.resolve(),stop:()=>hub.stop(),onChanged:(callback:(event:KbxExperimentChangedEvent)=>void)=>hub.on('ExperimentChanged',callback)}
}
@@ -0,0 +1,22 @@
import { ref, onMounted, onUnmounted } from 'vue'
import type { KbxApiOperationId, KbxExperimentAssignmentsResponse, KbxExperimentId } from '@kbx/contracts'
import { kbxApi } from '../http/generated/kbxApiClient'
import { kbxTelemetry } from '../telemetry/kbxTelemetryClient'
import { createExperimentRolloutConnection } from './createExperimentRolloutConnection'
const screenCache=new Map<string,Promise<KbxExperimentAssignmentsResponse>>()
const listeners=new Map<string,Set<()=>void>>()
let realtimeStarted=false
const realtime=createExperimentRolloutConnection()
realtime.onChanged(event=>{screenCache.delete(event.screenId);for(const refresh of listeners.get(event.screenId)??[])void refresh()})
async function ensureRealtime(){if(realtimeStarted)return;realtimeStarted=true;try{await realtime.start()}catch{realtimeStarted=false}}
function assignments(screenId:string,force=false){if(force)screenCache.delete(screenId);if(!screenCache.has(screenId))screenCache.set(screenId,kbxApi.request<KbxExperimentAssignmentsResponse>('common.experiments.assignments' as KbxApiOperationId,{query:{screenId}}));return screenCache.get(screenId)!}
export function useKbxExperiment(screenId:string,screenVersion:string,experimentId:KbxExperimentId,surface:string){
const variant=ref('control'),enrolled=ref(false);let lastExposure=''
const refresh=async()=>{
try{const result=await assignments(screenId,true);const assignment=result.assignments.find(x=>x.experimentId===experimentId);if(!assignment||!assignment.enrolled){enrolled.value=false;variant.value='control';lastExposure='';kbxTelemetry.clearExperiment(screenId);return}enrolled.value=true;variant.value=assignment.variant;kbxTelemetry.bindExperiment(screenId,assignment.experimentId,assignment.variant);const exposure=`${assignment.experimentId}:${assignment.variant}`;if(lastExposure!==exposure){lastExposure=exposure;kbxTelemetry.track('experiment.exposed',{screenId,screenVersion,attributes:{surface}})}}catch{/* experiment infrastructure must not block business UI */}
}
onMounted(async()=>{const set=listeners.get(screenId)??new Set<()=>void>();set.add(refresh);listeners.set(screenId,set);await ensureRealtime();await refresh()})
onUnmounted(()=>{listeners.get(screenId)?.delete(refresh);kbxTelemetry.clearExperiment(screenId)})
return {variant,enrolled,is:(value:string)=>variant.value===value,refresh}
}
@@ -0,0 +1,78 @@
import type { KbxHelpContent } from '@kbx/contracts'
export const helpRegistry: Record<string, KbxHelpContent> = {
'OMS-ORD-001': {
key: 'OMS-ORD-001', title: '주문관리', purpose: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
steps: ['조회조건을 입력합니다.', 'F3 또는 조회를 실행합니다.', '처리할 주문을 선택한 뒤 업무 명령을 실행합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
},
'OMS-ORD-002': {
key: 'OMS-ORD-002', title: '주문등록', purpose: '거래처 주문을 Header/Detail 방식으로 등록합니다.',
steps: ['거래처와 출고창고를 선택합니다.', '품목코드 또는 F2로 상품을 입력합니다.', '수량·단가를 확인하고 F8로 저장합니다.'],
shortcuts: [{ key: 'F2', description: '코드 조회' }, { key: 'F8', description: '저장' }],
},
'ERP-MST-ITEM-001': {
key: 'ERP-MST-ITEM-001', title: '품목관리', purpose: '품목 기준정보와 물류 속성을 관리합니다.',
steps: ['왼쪽 목록에서 품목을 선택하거나 신규를 누릅니다.', '기준정보와 물류정보를 입력합니다.', 'F8로 저장합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }, { key: 'F8', description: '저장' }, { key: 'F2', description: '창고 조회' }],
cautions: ['사용된 품목은 삭제보다 사용중지를 우선합니다.'],
},
'ERP-INV-001': {
key: 'ERP-INV-001', title: '재고현황', purpose: '품목별 현재고에서 창고·로케이션 근거와 재고이력까지 같은 조회 맥락에서 확인합니다.',
steps: ['조회조건을 입력하고 F3을 누릅니다.', '품목을 선택하면 오른쪽 위치재고와 아래 재고이력이 함께 바뀝니다.', '현재고 또는 가용수량을 눌러 산정 근거를 확인합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
cautions: ['화면 수량은 조회 Projection이며 실제 변경·확정 시 서버 재고 정책이 다시 검증합니다.'],
},
'ERP-PRICE-001': {
key: 'ERP-PRICE-001', title: '품목 단가 일괄등록', purpose: '품목별 적용일과 단가를 Excel 방식으로 빠르게 입력하고 일괄 저장합니다.',
steps: ['품목코드를 입력하거나 F2로 품목을 선택합니다.', '적용일과 단가를 입력하고 필요하면 Excel 영역을 붙여넣습니다.', '오류가 없으면 F8로 입력 건을 한 번에 저장합니다.'],
shortcuts: [{ key: 'F2', description: '품목 조회' }, { key: 'F8', description: '저장' }],
cautions: ['같은 품목과 적용일을 요청 안에서 중복 입력할 수 없습니다.', '서버가 품목 존재·권한·단가 범위를 다시 검증하고 저장 이력을 남깁니다.'],
},
'WMS-PICK-001': {
key: 'WMS-PICK-001', title: '출고 피킹', purpose: '위치와 상품을 스캔하여 출고 피킹을 수행합니다.',
cautions: ['서버 성공 응답 전에 같은 상품을 다시 스캔하지 마세요. 네트워크 오류는 동일 Idempotency Key로 자동 재시도합니다.'],
},
'OMS-ORD-003': {
key: 'OMS-ORD-003', title: '주문 Excel 업로드', purpose: '주문 데이터를 공통 매핑·검증 절차로 대량 등록하거나 수정합니다.',
steps: ['업로드 양식 또는 기존 Excel 파일을 선택합니다.', '자동 매핑 결과를 확인하고 필요한 열만 수정합니다.', '검증 결과에서 오류행을 확인한 뒤 정상 데이터만 반영합니다.'],
cautions: ['검증되지 않은 데이터는 반영할 수 없습니다.', '기존 주문은 수정 가능한 업무상태에서만 업데이트됩니다.'],
},
'COMMON-OPS-001': {
key: 'COMMON-OPS-001', title: '업무 예외 센터', purpose: '정상 업무가 아니라 사람이 판단하거나 복구해야 할 예외만 모아 처리합니다.',
steps: ['미처리 예외를 조회합니다.', '중요도와 경과시간을 기준으로 우선순위를 확인합니다.', '원 업무 보기 또는 등록된 안전한 재처리 기능으로 해결합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
cautions: ['예외 센터는 원천 업무 데이터의 Source of Truth가 아닙니다.', '원천 조건이 해결되지 않은 예외를 단순 숨김 처리하지 않습니다.'],
},
'COMMON-REC-001': {
key: 'COMMON-REC-001', title: '업무 데이터 대사', purpose: 'OMS·WMS·ERP 사이의 기대값과 실제값 차이를 시스템이 계산해 보여줍니다.',
steps: ['대사유형과 결과를 선택합니다.', '불일치 건의 기대값·실제값·원인을 확인합니다.', '조사가 필요한 건만 예외 센터에 등록합니다.'],
shortcuts: [{ key: 'F3', description: '조회' }],
cautions: ['대사 화면에서 원천 데이터를 직접 수정하지 않습니다.'],
},
'OMS-CLM-001': {
key:'OMS-CLM-001', title:'반품·클레임 관리', purpose:'반품·교환·취소 요청을 기존 주문 맥락에서 조회하고 처리합니다.',
steps:['F3으로 미처리 요청을 조회합니다.','주문번호와 사유를 확인합니다.','허용된 상태전이만 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['완료된 클레임은 원 주문·물류 이력과 함께 추적합니다.'],
},
'ERP-PUR-001': {
key:'ERP-PUR-001', title:'구매등록', purpose:'거래처 구매를 Header/Detail 방식으로 입력하고 입고 흐름과 연결합니다.',
steps:['거래처와 입고창고를 선택합니다.','품목·수량·단가·납기일을 입력합니다.','F8 저장 후 구매확정합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['확정 후에는 입고 실적과 연결되므로 직접 수정 범위를 제한합니다.'],
},
'ERP-INV-MOVE-001': {
key:'ERP-INV-MOVE-001', title:'재고이동', purpose:'창고 간 재고이동을 등록하고 이동중·입고완료 상태를 추적합니다.',
steps:['출발·도착창고를 선택합니다.','품목과 이동수량을 입력합니다.','이동확정 후 WMS/입고 흐름에서 완료합니다.'], shortcuts:[{key:'F2',description:'코드 조회'},{key:'F8',description:'저장'}], cautions:['출발창고와 도착창고는 같을 수 없고 가용재고보다 많이 이동할 수 없습니다.'],
},
'WMS-REC-001': { key:'WMS-REC-001', title:'입고 검수', purpose:'입고예정/ASN과 실물을 스캔해 수량·품목을 검수합니다.', cautions:['예정과 다른 상품·수량은 자동 확정하지 않고 예외로 남깁니다.'] },
'WMS-PUT-001': { key:'WMS-PUT-001', title:'입고 적치', purpose:'검수 완료 상품을 추천 로케이션에 적치합니다.', cautions:['추천 위치와 다른 위치는 정상 흐름에서 자동 승인하지 않습니다.'] },
'WMS-COUNT-001': { key:'WMS-COUNT-001', title:'재고실사', purpose:'위치·상품을 스캔해 실물수량을 기록하고 장부수량 차이를 예외로 보냅니다.', cautions:['실사 차이는 현장에서 장부수량을 직접 덮어쓰지 않습니다.'] },
'WMS-WORK-001': { key:'WMS-WORK-001', title:'물류 작업', purpose:'입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회하고 시작합니다.', steps:['F3으로 내 작업을 조회합니다.','SLA·진행률·예외를 확인합니다.','작업을 선택해 작업 시작을 실행합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['현장 실행은 각 PDA 작업화면의 서버 검증과 Idempotency 규칙을 따릅니다.'] },
'COMMON-EXP-001': { key:'COMMON-EXP-001', title:'UX 실험·점진배포', purpose:'되돌릴 수 있는 UX 변경을 제한된 사용자에게 점진 배포하고 기존 UX 지표와 Guardrail을 비교합니다.', steps:['Draft 상태에서 변경 범위와 기준 지표를 검토합니다.','소규모 Rollout으로 시작하고 표본·P95·오류율을 확인합니다.','Guardrail 위반 시 즉시 롤백하고 원인을 기록합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['권한·민감정보·Domain Rule·Validation·Idempotency·WMS Scan 규칙은 실험 대상이 아닙니다.','운영 Threshold는 통계적 유의성을 의미하지 않습니다.'] },
'COMMON-UX-001': { key:'COMMON-UX-001', title:'UX 품질 지표', purpose:'Task 시간·의미 조작수·Manual Intervention Rate·검증/Import 실패율·예외 해결시간·AI 제안 수용률을 확인합니다.', steps:['기간을 선택하고 F3으로 조회합니다.','표본 수와 P50/P95를 함께 확인합니다.','지표 변화는 위험도·업무오류·현장 VOC와 같이 판단합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['DOM 전체 클릭이나 고객·주문 원문 데이터를 수집하지 않습니다.','Manual Intervention Rate를 낮추기 위해 위험 업무를 무리하게 자동화하지 않습니다.'] },
'COMMON-DATA-001': { key:'COMMON-DATA-001', title:'외부 데이터 상태', purpose:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.', steps:['F3으로 현재 상태를 조회합니다.','Stale/Expired 데이터셋의 최근 수신시각과 정책을 확인합니다.','허용된 데이터셋만 재수집합니다.'], shortcuts:[{key:'F3',description:'조회'}], cautions:['외부 데이터는 Domain 업무원장이 아닙니다.','KRX 승인 서비스는 서비스별 명시적 신선도 정책이 없으면 범용 TTL을 추정하지 않습니다.'] },
'COMMON-DS-001': { key:'COMMON-DS-001', title:'KBX 컴포넌트 카탈로그', purpose:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 독립적으로 재현합니다.', steps:['밀도를 선택합니다.','컴포넌트의 기본·오류·비활성 상태를 비교합니다.','Visual/Keyboard Regression 기준으로 사용합니다.'], cautions:['업무 사용자를 위한 메뉴가 아니라 KBX 개발·검증 화면입니다.'] },
}
@@ -0,0 +1,38 @@
import { kbxApiCatalog, type KbxApiOperationDefinition, type KbxApiOperationId, type KbxApiRequestOptions } from '@kbx/contracts'
import { kbxHttp } from '../kbxHttpClient'
function buildPath(template: string, values: Record<string, string | number> = {}) {
return template.replace(/\{([^}:]+)(?::[^}]+)?\}/g, (_, name: string) => {
const key = Object.keys(values).find(x => x.toLowerCase() === name.toLowerCase())
if (!key) throw new Error(`API path parameter '${name}' is required.`)
return encodeURIComponent(String(values[key]))
})
}
export async function requestKbxApi<TResponse = unknown, TBody = unknown>(
operationId: KbxApiOperationId,
options: KbxApiRequestOptions<TBody> = {},
): Promise<TResponse> {
const operation: KbxApiOperationDefinition = kbxApiCatalog[operationId]
if (operation.idempotency === 'required' && !options.idempotencyKey) {
throw new Error(`Operation '${operationId}' requires a stable idempotency key.`)
}
const headers: Record<string, string> = { ...(options.headers ?? {}) }
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
const response = await kbxHttp.request<TResponse>({
method: operation.method,
url: buildPath(operation.path, options.path),
params: options.query,
data: options.body,
headers,
responseType: options.responseType ?? operation.responseType ?? 'json',
validateStatus: status => operation.successStatuses.some(value => value === status),
})
return response.data
}
export const kbxApi = {
request: requestKbxApi,
}
@@ -0,0 +1,63 @@
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
import { isKbxProblem, kbxUnexpectedProblem, type KbxProblem, type KbxSystemProblem } from '@kbx/contracts'
export const kbxHttp = axios.create({
timeout: 30_000,
headers: { 'X-KBX-Client': 'web' },
})
kbxHttp.interceptors.request.use(config => {
config.headers['X-Correlation-Id'] ??= crypto.randomUUID()
return config
})
kbxHttp.interceptors.response.use(
response => response,
(error: AxiosError) => {
const correlationId = String(error.config?.headers?.['X-Correlation-Id'] ?? crypto.randomUUID())
if (!error.response) {
const problem: KbxSystemProblem = {
type: 'system',
code: 'NETWORK_UNAVAILABLE',
title: '서버와 연결할 수 없습니다.',
detail: '입력한 내용은 유지됩니다. 네트워크 상태를 확인한 후 다시 시도하세요.',
correlationId,
retryable: true,
}
return Promise.reject(problem)
}
const data = error.response.data
if (isKbxProblem(data)) {
const problem: KbxProblem = {
...data,
correlationId: data.correlationId ?? correlationId,
}
return Promise.reject(problem)
}
if (error.response.status === 403) {
return Promise.reject({
type: 'permission', code: 'PERMISSION_DENIED', title: '이 작업을 수행할 권한이 없습니다.', correlationId,
} satisfies KbxProblem)
}
if (error.response.status === 404) {
return Promise.reject({
type: 'not-found', code: 'RESOURCE_NOT_FOUND', title: '요청한 데이터를 찾을 수 없습니다.', correlationId,
} satisfies KbxProblem)
}
return Promise.reject(kbxUnexpectedProblem(correlationId, `HTTP ${error.response.status}`, error.response.status >= 500))
},
)
export function withIdempotency(config: AxiosRequestConfig = {}, key = crypto.randomUUID()): AxiosRequestConfig {
return {
...config,
headers: {
...(config.headers ?? {}),
'Idempotency-Key': key,
},
}
}
@@ -0,0 +1,29 @@
import { ref } from 'vue'
import type { KbxConflictSnapshot, KbxProblem, KbxSystemProblem } from '@kbx/contracts'
export function useKbxProblemHandler() {
const conflict = ref<KbxConflictSnapshot | null>(null)
const systemProblem = ref<KbxSystemProblem | null>(null)
function handle(problem: KbxProblem) {
if (problem.type === 'conflict') {
conflict.value = {
code: problem.code,
title: problem.title,
currentVersion: problem.currentVersion,
correlationId: problem.correlationId,
}
return
}
if (problem.type === 'system') {
systemProblem.value = problem
}
}
function clear() {
conflict.value = null
systemProblem.value = null
}
return { conflict, systemProblem, handle, clear }
}
@@ -0,0 +1,22 @@
import * as signalR from '@microsoft/signalr'
import type { KbxImportProgressEvent } from '@kbx/contracts'
import type { KbxImportProgressConnection } from '@kbx/ui'
export function createImportProgressConnection(url = '/hubs/import-progress'): KbxImportProgressConnection {
const hub = new signalR.HubConnectionBuilder()
.withUrl(url)
.withAutomaticReconnect()
.build()
return {
start: () => hub.state === signalR.HubConnectionState.Disconnected ? hub.start() : Promise.resolve(),
stop: () => hub.stop(),
subscribe: sessionId => hub.invoke('Subscribe', sessionId),
unsubscribe: sessionId => hub.state === signalR.HubConnectionState.Connected
? hub.invoke('Unsubscribe', sessionId)
: Promise.resolve(),
onProgress(callback) {
hub.on('ImportProgress', (event: KbxImportProgressEvent) => callback(event))
},
}
}
@@ -0,0 +1,33 @@
import { isKbxProblem, type KbxLookupProvider } from '@kbx/contracts'
import { kbxApi } from '../http/generated/kbxApiClient'
const lookupOperations = {
customer: { search: 'lookup.customers.search', byId: 'lookup.customers.resolveById', byCode: 'lookup.customers.resolveByCode' },
item: { search: 'lookup.items.search', byId: 'lookup.items.resolveById', byCode: 'lookup.items.resolveByCode' },
warehouse: { search: 'lookup.warehouses.search', byId: 'lookup.warehouses.resolveById', byCode: 'lookup.warehouses.resolveByCode' },
} as const
type LookupEntity = keyof typeof lookupOperations
function provider(entity: LookupEntity): KbxLookupProvider<string> {
const ops = lookupOperations[entity]
return {
search(request) {
return kbxApi.request(ops.search, { query: request })
},
async resolveById(id) {
try { return await kbxApi.request(ops.byId, { path: { id } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
async resolveByCode(code) {
try { return await kbxApi.request(ops.byCode, { path: { code } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
} as KbxLookupProvider<string>
}
export const lookupRegistry = {
customer: provider('customer'),
item: provider('item'),
warehouse: provider('warehouse'),
}
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
KbxAuditTrail, KbxBulkActionBar, KbxButton, KbxCommandBar, KbxDateField, KbxDateRange,
KbxInput, KbxFormGrid, KbxFormSection, KbxFormSpan, KbxMoneyField, KbxNumberField, KbxPageHeader, KbxProposalPanel, KbxQuantityField,
KbxSelect, KbxStatus, KbxToast, KbxDataState, KbxTabs, KbxDataGrid, KbxSearchPanel, kbxComponentCatalog, kbxTemplateManifest,
} from '@kbx/ui'
import type { KbxAiProposal, KbxAuditEntry, KbxCommandDefinition, KbxDensity, KbxGridColumn, KbxSearchField, KbxValidationError } from '@kbx/contracts'
const density=ref<KbxDensity>((new URLSearchParams(location.search).get('density') as KbxDensity) || 'compact')
const text=ref('대한상사')
const number=ref<number|null>(1200)
const money=ref<number|null>(1250000)
const qty=ref<number|null>(10)
const date=ref('2026-08-08')
const from=ref('2026-08-01')
const to=ref('2026-08-08')
const status=ref('READY')
const tab=ref('basic')
const search=ref<Record<string,unknown>>({keyword:'',status:null,mismatchOnly:false})
const rememberSearch=ref(true)
const searchFields:KbxSearchField[]=[
{key:'keyword',label:'통합검색',type:'text',width:'lg',placeholder:'주문번호/주문자'},
{key:'status',label:'상태',type:'select',options:[{value:'READY',label:'출고대기'},{value:'ERROR',label:'오류'}]},
{key:'mismatchOnly',label:'오류만 보기',type:'checkbox',primary:false,defaultValue:false},
]
type DemoRow={id:string;orderNo:string;customerName:string;orderQty:number;amount:number;status:string}
const gridRows=ref<DemoRow[]>([
{id:'1',orderNo:'TEST-ORD-001',customerName:'대한상사',orderQty:10,amount:128000,status:'출고대기'},
{id:'2',orderNo:'TEST-ORD-002',customerName:'서울유통',orderQty:8,amount:96000,status:'재고부족'},
{id:'3',orderNo:'TEST-ORD-003',customerName:'부산물류',orderQty:4,amount:52000,status:'신규'},
])
const gridColumns:KbxGridColumn<DemoRow>[]=[
{field:'orderNo',header:'주문번호',type:'link',width:150,pinned:'left'},
{field:'customerName',header:'거래처',width:160},
{field:'orderQty',header:'수량',type:'quantity',width:100,editable:true},
{field:'amount',header:'금액',type:'money',width:130},
{field:'status',header:'상태',type:'status',width:110},
]
const gridErrors:KbxValidationError[]=[{rowKey:'2',field:'orderQty',code:'INSUFFICIENT_STOCK',message:'출고 가능 수량은 6개입니다.'}]
const fastRows=ref<DemoRow[]>([
{id:'F1',orderNo:'TEST-FAST-001',customerName:'대한상사',orderQty:1,amount:12000,status:'작성'},
{id:'F2',orderNo:'TEST-FAST-002',customerName:'대한상사',orderQty:2,amount:24000,status:'작성'},
{id:'F3',orderNo:'TEST-FAST-003',customerName:'대한상사',orderQty:3,amount:36000,status:'작성'},
])
let fastSeq=4
function addFastRow(){fastRows.value.push({id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`,customerName:'',orderQty:1,amount:0,status:'작성'})}
function duplicateFastRows(rows:DemoRow[]){for(const row of rows)fastRows.value.push({...row,id:`F${fastSeq}`,orderNo:`TEST-FAST-${String(fastSeq++).padStart(3,'0')}`})}
const commands:KbxCommandDefinition[]=[
{id:'search',label:'조회',group:'query',shortcut:'F3'},
{id:'save',label:'저장',group:'edit',shortcut:'F8',variant:'primary'},
{id:'ship',label:'출고지시',group:'workflow',requiresSelection:true},
]
const proposal:KbxAiProposal={id:'catalog-proposal',type:'warehouse-change',title:'출고창고 변경 제안',explanation:'가용재고가 있는 창고를 제안한 예시입니다.',capability:'draft',confidence:.87,proposedChanges:[{field:'warehouseId',label:'출고창고',before:'서울센터',after:'인천센터'}],evidence:[{label:'재고 Read Model',sourceType:'domain'}]}
const audit:KbxAuditEntry[]=[{id:'1',occurredAt:'2026-08-08 15:32',actor:{type:'user',displayName:'홍길동'},action:'수량 변경',changes:[{field:'qty',label:'출고수량',before:10,after:8}],reason:'고객 요청'}]
const groupCounts=computed(()=>Object.fromEntries([...new Set(kbxComponentCatalog.map(x=>x.group))].map(g=>[g,kbxComponentCatalog.filter(x=>x.group===g).length])))
</script>
<template>
<section class="catalog" :data-kbx-density="density">
<KbxPageHeader title="KBX 컴포넌트 카탈로그" breadcrumb="COMMON > KBX" description="Default / Changed / Warning / AI Suggested / Readonly / Disabled / Error / Loading / Empty / Keyboard 상태를 독립적으로 재현합니다." />
<nav class="density" aria-label="밀도 선택"><strong>Density</strong><button v-for="d in ['compact','comfortable','touch']" :key="d" type="button" :aria-pressed="density===d" @click="density=d as KbxDensity">{{d}}</button></nav>
<section class="catalog__summary" aria-label="카탈로그 요약"><span v-for="(count,group) in groupCounts" :key="group"><strong>{{group}}</strong> {{count}}</span></section>
<section class="catalog__block"><h2>입력</h2><div class="catalog__grid"><div><h3>KbxInput 상태</h3><KbxInput v-model="text" label="거래처명" required/><KbxInput model-value="변경한 값" label="Changed" state="changed" help-text="저장 전 변경값입니다."/><KbxInput model-value="확인 필요" label="Warning" state="warning" warning="업무 규칙을 다시 확인하세요."/><KbxInput model-value="인천센터" label="AI Suggested" state="ai-suggested" help-text="AI 추천값이며 아직 반영되지 않았습니다."/><KbxInput model-value="" label="오류" error="거래처명을 입력하세요."/><KbxInput model-value="읽기전용" label="Readonly" readonly/></div><div><h3>Number / Money / Quantity</h3><KbxNumberField v-model="number" label="일반 숫자" state="changed"/><KbxMoneyField v-model="money" label="단가"/><KbxQuantityField v-model="qty" label="출고수량" :available-quantity="8"/></div><div><h3>Date / Range / Select</h3><KbxDateField v-model="date" label="주문일"/><KbxDateRange v-model:from="from" v-model:to="to" label="주문기간"/><KbxSelect v-model="status" label="상태" :options="[{value:'READY',label:'준비'},{value:'DONE',label:'완료'}]"/><KbxTabs v-model="tab" :items="[{key:'basic',label:'기본'},{key:'audit',label:'변경이력',badge:3}]"/></div></div></section>
<section class="catalog__block"><h2>Form Composition</h2><p class="catalog__hint">T02/T03 Desktop Form은 KbxFormSection + KbxFormGrid의 2-column을 기본으로 하고 주소·비고처럼 관계상 넓게 보여야 하는 항목만 명시적으로 full span 합니다.</p><KbxFormSection title="기본정보" description="Label 폭과 행/열 간격은 Design Token에서 통제합니다."><KbxFormGrid><KbxInput v-model="text" label="품목명"/><KbxDateField v-model="date" label="적용일"/><KbxFormSpan span="full"><KbxInput model-value="서울특별시 강남구 ..." label="주소" readonly/></KbxFormSpan></KbxFormGrid></KbxFormSection></section>
<section class="catalog__block"><h2>Search / Grid Runtime</h2><KbxSearchPanel v-model="search" v-model:remember-checked="rememberSearch" :fields="searchFields" remember saved-search/><div class="catalog__grid-demo"><KbxDataGrid :rows="gridRows" :columns="gridColumns" row-key="id" selection="multiple" editable :errors="gridErrors" :changed-cells="[{rowKey:'1',field:'orderQty'}]" :total-count="82415" allow-all-filtered-selection personalization exportable :summary="[{key:'count',label:'조회',kind:'count'},{key:'amount',label:'금액 합계',kind:'sum',field:'amount'}]"/></div></section>
<section class="catalog__block"><h2>Fast Entry Grid</h2><p class="catalog__hint"> 추가/복제, 선택 기준 Fill Down, Excel 다중 붙여넣기 정규화, 오류 탐색을 동일 Grid 계약으로 재현합니다.</p><div class="catalog__grid-demo"><KbxDataGrid :rows="fastRows" :columns="gridColumns" row-key="id" selection="multiple" editable :editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}" @row-add-requested="addFastRow" @row-duplicate-requested="duplicateFastRows"/></div></section>
<section class="catalog__block"><h2>Loading / Empty / Error</h2><div class="catalog__grid"><KbxDataState state="loading"/><KbxDataState state="empty" title="조회된 주문이 없습니다." detail="조회조건을 변경해 보세요."/><KbxDataState state="error" detail="네트워크 연결을 확인한 후 다시 시도하세요." action-label="다시 조회"/></div></section>
<section class="catalog__block"><h2>Command / 상태</h2><KbxCommandBar :commands="commands" :selection-count="0"/><KbxBulkActionBar :selection-count="17" :actions="[{id:'ship',label:'출고지시',group:'workflow'},{id:'hold',label:'보류',group:'workflow'}]"/><div class="status-row"><KbxStatus label="작성" semantic="draft"/><KbxStatus label="진행" semantic="processing"/><KbxStatus label="완료" semantic="completed"/><KbxStatus label="재고부족" semantic="warning"/><KbxStatus label="오류" semantic="error"/></div><KbxToast message="저장했습니다."/><KbxToast message="일부 항목을 확인하세요." kind="warning"/></section>
<section class="catalog__block"><h2>AI / Audit</h2><div class="catalog__grid"><KbxProposalPanel :proposal="proposal"/><KbxAuditTrail :entries="audit"/></div></section>
<section class="catalog__block"><h2>T01~T09 Template Contract</h2><p class="catalog__hint">Screen Type을 선택하면 필수 Surface·Core Component·Keyboard·완료 점검까지 함께 결정됩니다. 화면별 임의 Layout은 예외입니다.</p><table><thead><tr><th>Type</th><th>Component</th><th>필수 Surface</th><th>Core Components</th><th>완료 점검</th><th>Reference</th></tr></thead><tbody><tr v-for="template in kbxTemplateManifest" :key="template.code"><td><strong>{{template.code}}</strong><br><small>{{template.type}}</small></td><td>{{template.component}}<br><small>{{template.keyboard.join(' · ')}}</small></td><td>{{template.requiredSurfaces.join(' · ')}}</td><td>{{template.coreComponents.join(' · ')}}</td><td>{{template.completionChecks.join(' · ')}}</td><td>{{template.referenceScreens.join(', ')}}</td></tr></tbody></table></section>
<section class="catalog__block"><h2>검증 계약</h2><table><thead><tr><th>Component</th><th>Group</th><th>States</th><th>Keyboard</th><th>Focus</th></tr></thead><tbody><tr v-for="entry in kbxComponentCatalog" :key="entry.component"><td>{{entry.component}}</td><td>{{entry.group}}</td><td>{{entry.scenarios.map(x=>x.label).join(', ')}}</td><td>{{entry.accessibility.keyboard?'✓':'-'}}</td><td>{{entry.accessibility.focusVisible?'✓':'-'}}</td></tr></tbody></table></section>
</section>
</template>
<style scoped>
.catalog{display:grid;gap:var(--kbx-space-4);padding:var(--kbx-space-4);background:var(--kbx-color-surface);color:var(--kbx-color-text)}.density,.catalog__summary,.status-row{display:flex;align-items:center;gap:var(--kbx-space-2);flex-wrap:wrap}.density button{height:var(--kbx-control-height);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm);padding:0 10px}.density button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}.catalog__summary span{padding:6px 8px;background:var(--kbx-color-surface-muted);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}.catalog__block{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-3);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}h2,h3{margin:0}.catalog__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--kbx-space-4)}.catalog__grid>div{display:grid;gap:var(--kbx-space-2)}.catalog__grid-demo{height:360px}.catalog__hint{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}th,td{padding:7px 8px;border-bottom:1px solid var(--kbx-color-border);text-align:left}th{background:var(--kbx-color-surface-muted);font-weight:600}
</style>
@@ -0,0 +1,13 @@
import { defineKbxScreen } from '@kbx/ui'
export const designSystemCatalogScreen = defineKbxScreen({
id:'COMMON-DS-001',
version:'1.0.0',
module:'COMMON',
type:'list', templateCode:'T01',
title:'KBX 컴포넌트 카탈로그',
description:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 재현합니다.',
permissions:['kbx.design.read'],
helpKey:'COMMON-DS-001',
telemetry:{enabled:true},
})
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxListPage, KbxDataGrid, KbxDialog, KbxInput, KbxNumberField, KbxSelect, KbxButton } from '@kbx/ui'
import type { KbxExperimentOverviewRow, KbxGridColumn } from '@kbx/contracts'
import { experimentsScreen } from './experiments.definition'
import { experimentApi } from './experimentApi'
const selected=ref<KbxExperimentOverviewRow[]>([])
const query=useQuery({queryKey:['kbx','experiments'],queryFn:experimentApi.overview})
const rows=computed(()=>query.data.value?.items??[])
const columns:KbxGridColumn<KbxExperimentOverviewRow>[]=[
{field:'experimentId',header:'실험',width:280},{field:'screenId',header:'화면',width:140},{field:'state',header:'상태',type:'status',width:110},{field:'rolloutPercent',header:'배포 %',type:'integer',width:90},{field:'decision',header:'판정',width:150},{field:'updatedAt',header:'변경일시',type:'datetime',width:170},
]
const dialogOpen=ref(false),mode=ref<'rollout'|'rollback'>('rollout'),reason=ref(''),rolloutPercent=ref<number|null>(0),rolloutState=ref<string|null>('running'),error=ref('')
function openManage(next:'rollout'|'rollback'){const item=selected.value[0];if(!item)return;mode.value=next;reason.value='';error.value='';rolloutPercent.value=item.rolloutPercent;rolloutState.value=item.state==='paused'?'paused':'running';dialogOpen.value=true}
async function apply(){const item=selected.value[0];if(!item)return;if(!reason.value.trim()){error.value='변경 사유를 입력하세요.';return}if(mode.value==='rollout'){const percent=rolloutPercent.value??0;if(percent<0||percent>100){error.value='배포 비율은 0~100 사이여야 합니다.';return}await experimentApi.rollout(item.experimentId,{rolloutPercent:percent,state:(rolloutState.value==='paused'?'paused':'running'),reason:reason.value.trim()})}else await experimentApi.rollback(item.experimentId,{reason:reason.value.trim()});dialogOpen.value=false;selected.value=[];await query.refetch()}
async function command(id:string){if(id==='search')await query.refetch();if(id==='rollout')openManage('rollout');if(id==='rollback')openManage('rollback')}
</script>
<template><KbxListPage :screen="experimentsScreen" :selection-count="selected.length" @command="command">
<template #quick-filter><div class="notice"><strong>안전 범위</strong> Layout·정보강조·기본필터·Navigation·Copy만 실험합니다. 권한·민감정보·Domain Rule·Validation·WMS Scan Rule은 실험하지 않습니다.</div></template>
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="experimentId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
<template #summary>판정은 운영 Threshold 기반입니다. 통계적 유의성을 자동 주장하지 않으며, Guardrail 위반은 즉시 롤백 대상으로 봅니다.</template>
</KbxListPage>
<KbxDialog :open="dialogOpen" :title="mode==='rollback'?'UX 변경 즉시 롤백':'점진배포 설정'" size="md" @update:open="dialogOpen=$event">
<div class="dialog-fields">
<template v-if="mode==='rollout'">
<KbxNumberField v-model="rolloutPercent" label="배포 비율" suffix="%" :min="0" :max="100" />
<KbxSelect v-model="rolloutState" label="상태" :options="[{value:'running',label:'실행'},{value:'paused',label:'일시중지'}]" />
</template>
<KbxInput v-model="reason" label="변경 사유" required :maxlength="500" :error="error" placeholder="지표·장애·VOC 변경 근거를 입력하세요." @enter="apply" />
<p v-if="mode==='rollback'">롤백하면 Kill Switch가 활성화되고 모든 사용자는 즉시 Control UX로 돌아갑니다. 기존 Assignment와 Audit 이력은 유지됩니다.</p>
</div>
<template #footer><KbxButton label="취소" @click="dialogOpen=false"/><KbxButton :label="mode==='rollback'?'즉시 롤백':'적용'" :variant="mode==='rollback'?'danger':'primary'" @click="apply"/></template>
</KbxDialog>
</template>
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.dialog-fields{display:grid;gap:var(--kbx-space-3)}.dialog-fields p{color:var(--kbx-color-text-muted)}</style>
@@ -0,0 +1,7 @@
import type { KbxExperimentOverviewResponse, KbxExperimentRolloutRequest, KbxExperimentRollbackRequest } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export const experimentApi={
overview:()=>kbxApi.request<KbxExperimentOverviewResponse>('common.experiments.overview'),
rollout:(experimentId:string,body:KbxExperimentRolloutRequest)=>kbxApi.request('common.experiments.rollout',{path:{experimentId},body}),
rollback:(experimentId:string,body:KbxExperimentRollbackRequest)=>kbxApi.request('common.experiments.rollback',{path:{experimentId},body}),
}
@@ -0,0 +1,7 @@
import { defineKbxScreen } from '@kbx/ui'
export const experimentsScreen=defineKbxScreen({
id:'COMMON-EXP-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 실험·점진배포',
description:'안전한 UX 변경만 점진 배포하고 가드레일 지표와 즉시 롤백 상태를 확인합니다.',
permissions:['common.experiment.read'],helpKey:'COMMON-EXP-001',telemetry:{enabled:true},
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'},{id:'rollout',label:'배포 설정',group:'workflow',permission:'common.experiment.manage',requiresSelection:true,minSelection:1,maxSelection:1},{id:'rollback',label:'즉시 롤백',group:'workflow',variant:'danger',permission:'common.experiment.manage',requiresSelection:true,minSelection:1,maxSelection:1}],
})
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxListPage, KbxDataGrid } from '@kbx/ui'
import type { KbxExternalDataStatusRow, KbxGridColumn } from '@kbx/contracts'
import { externalDataScreen } from './external-data.definition'
import { externalDataApi } from './externalDataApi'
const selected=ref<KbxExternalDataStatusRow[]>([])
const query=useQuery({queryKey:['kbx','external-data','status'],queryFn:externalDataApi.status})
const rows=computed(()=>query.data.value?.items??[])
const columns:KbxGridColumn<KbxExternalDataStatusRow>[]=[
{field:'sourceLabel',header:'출처',width:110,pinned:'left'},
{field:'datasetId',header:'데이터셋',width:320},
{field:'state',header:'신선도',type:'status',width:130},
{field:'cacheEntries',header:'캐시',type:'integer',width:90},
{field:'staleEntries',header:'Stale',type:'integer',width:90},
{field:'unavailableEntries',header:'사용불가',type:'integer',width:100},
{field:'lastReceivedAt',header:'최근 수신',type:'datetime',width:170},
{field:'oldestFreshUntil',header:'최초 만료',type:'datetime',width:170},
]
async function command(id:string){if(id==='search')await query.refetch();if(id==='refresh'){const row=selected.value[0];if(!row)return;await externalDataApi.refresh(row.datasetId);selected.value=[];await query.refetch()}}
</script>
<template><KbxListPage :screen="externalDataScreen" :selection-count="selected.length" @command="command">
<template #quick-filter><div class="notice"><strong>외부 데이터는 업무 원장이 아닙니다.</strong> 화면에는 정규화된 Projection과 출처·신선도를 함께 표시하고, 만료된 데이터를 최신값처럼 사용하지 않습니다.</div></template>
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="datasetId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
<template #summary>KRX의 승인 서비스별 TTL은 별도 정책 없이는 재수집할 없습니다. OPENDART/KIS의 캐시 시간은 KBX 운영정책이며 공급자 공식 호출한도와 구분합니다.</template>
</KbxListPage></template>
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
@@ -0,0 +1,10 @@
import { defineKbxScreen } from '@kbx/ui'
export const externalDataScreen=defineKbxScreen({
id:'COMMON-DATA-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'외부 데이터 상태',
description:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.',
permissions:['common.external-data.read'],helpKey:'COMMON-DATA-001',telemetry:{enabled:true},
commands:[
{id:'search',label:'조회',group:'query',shortcut:'F3'},
{id:'refresh',label:'재수집',group:'workflow',permission:'common.external-data.refresh',requiresSelection:true,minSelection:1,maxSelection:1},
],
})
@@ -0,0 +1,6 @@
import type { KbxExternalDataStatusResponse } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export const externalDataApi={
status:()=>kbxApi.request<KbxExternalDataStatusResponse>('common.externalData.status'),
refresh:(datasetId:string)=>kbxApi.request('common.externalData.refresh',{path:{datasetId}}),
}
@@ -0,0 +1 @@
<template><div aria-hidden="true" /></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { KbxRouteNotFound } from '@kbx/ui'
import { useRouter } from 'vue-router'
import { useKbxWorkspaceStore } from '../../../shell/workspaceStore'
const router=useRouter();const store=useKbxWorkspaceStore()
function home(){void router.replace('/home')}
</script>
<template><KbxRouteNotFound @home="home" @menu-search="store.menuSearchOpen=true"/></template>
@@ -0,0 +1,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>
@@ -0,0 +1,46 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkItem } from '@kbx/ui'
export const operationsQueueScreen = defineKbxScreen({
id: 'COMMON-OPS-001',
version: '1.0.0',
module: 'COMMON',
type: 'queue', templateCode:'T06',
title: '업무 예외 센터',
description: '정상 건이 아니라 사람이 판단하거나 복구해야 할 업무만 모아 처리합니다.',
permissions: ['common.operations.read'],
helpKey: 'COMMON-OPS-001',
telemetry: { enabled: true },
commands: [
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
{ id: 'claim', label: '내가 처리', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.claim' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
})
export const operationsSearchFields: KbxSearchField[] = [
{ key: 'module', label: '모듈', type: 'select', primary: true, options: [
{ value: 'OMS', label: 'OMS' }, { value: 'WMS', label: 'WMS' }, { value: 'ERP', label: 'ERP' },
] },
{ key: 'severity', label: '심각도', type: 'select', primary: true, options: [
{ value: 'critical', label: '긴급' }, { value: 'warning', label: '주의' }, { value: 'info', label: '정보' },
] },
{ key: 'status', label: '상태', type: 'select', primary: true, options: [
{ value: 'open', label: '미처리' }, { value: 'claimed', label: '처리중' }, { value: 'resolved', label: '해결' },
] },
{ key: 'owner', label: '담당', type: 'select', primary: false, options: [
{ value: 'mine', label: '내 업무' }, { value: 'unassigned', label: '미지정' },
] },
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 작업번호, 오류명' },
]
export const operationsColumns: KbxGridColumn<KbxWorkItem>[] = [
{ field: 'severity', header: '중요도', type: 'status', width: 82, pinned: 'left' },
{ field: 'sourceModule', header: '모듈', width: 76 },
{ field: 'title', header: '확인할 업무', width: 250, pinned: 'left' },
{ field: 'referenceNo', header: '대상번호', type: 'code', width: 150 },
{ field: 'detail', header: '원인/안내', width: 300 },
{ field: 'ownerName', header: '담당자', width: 100 },
{ field: 'occurredAt', header: '발생시각', type: 'datetime', width: 160 },
{ field: 'ageMinutes', header: '경과(분)', type: 'integer', width: 90 },
{ field: 'status', header: '처리상태', type: 'status', width: 100 },
]
@@ -0,0 +1,28 @@
import type { KbxWorkQueueResult } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface OperationsFilter {
module?: string | null
severity?: string | null
status?: string | null
owner?: string | null
code?: string | null
keyword?: string | null
page: number
pageSize: number
}
export const operationsApi = {
search(filter: OperationsFilter) {
return kbxApi.request<KbxWorkQueueResult>('common.operations.search', { query: filter })
},
claim(ids: string[]) {
return kbxApi.request<unknown, { ids: string[] }>('common.operations.claim', { body: { ids } })
},
resolve(ids: string[], reason = '사용자 확인 완료') {
return kbxApi.request<unknown, { ids: string[]; reason: string }>('common.operations.resolve', { body: { ids, reason } })
},
retry(id: string) {
return kbxApi.request<unknown>('common.operations.retry', { path: { id } })
},
}
@@ -0,0 +1,3 @@
export const operationsRoutes = [
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
]
@@ -0,0 +1,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>
@@ -0,0 +1,42 @@
import { defineKbxScreen, type KbxGridColumn, type KbxReconcileItem, type KbxSearchField } from '@kbx/ui'
export const reconcileScreen = defineKbxScreen({
id: 'COMMON-REC-001',
version: '1.0.0',
module: 'COMMON',
type: 'reconcile', templateCode:'T07',
title: '업무 데이터 대사',
description: '원천·대상 시스템의 기대값과 실제값을 비교하고 불일치 원인을 추적합니다.',
permissions: ['common.reconcile.read'],
helpKey: 'COMMON-REC-001',
telemetry: { enabled: true },
commands: [
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
{ id: 'createException', label: '예외 등록', group: 'workflow', requiresSelection: true, minSelection: 1, permission: 'common.operations.create' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
})
export const reconcileSearchFields: KbxSearchField[] = [
{ key: 'reconcileType', label: '대사유형', type: 'select', primary: true, options: [
{ value: 'OMS_WMS_OUTBOUND_QTY', label: 'OMS↔WMS 출고수량' },
{ value: 'ORDER_PICK_QTY', label: '주문↔피킹수량' },
{ value: 'INVENTORY_SNAPSHOT', label: '재고 스냅샷' },
] },
{ key: 'status', label: '결과', type: 'select', primary: true, options: [
{ value: 'mismatch', label: '불일치' }, { value: 'pending', label: '확인중' }, { value: 'matched', label: '정상' }, { value: 'resolved', label: '해결' },
] },
{ key: 'keyword', label: '검색', type: 'text', primary: true, width: 'lg', placeholder: '주문번호, 품목, 참조번호' },
]
export const reconcileColumns: KbxGridColumn<KbxReconcileItem>[] = [
{ field: 'referenceNo', header: '참조번호', type: 'code', width: 160, pinned: 'left' },
{ field: 'sourceLabel', header: '기준', width: 130 },
{ field: 'expectedValue', header: '기대값', width: 130 },
{ field: 'targetLabel', header: '비교대상', width: 130 },
{ field: 'actualValue', header: '실제값', width: 130 },
{ field: 'differenceValue', header: '차이', width: 110 },
{ field: 'reasonText', header: '원인', width: 280 },
{ field: 'status', header: '상태', type: 'status', width: 100 },
{ field: 'occurredAt', header: '확인시각', type: 'datetime', width: 160 },
]
@@ -0,0 +1,19 @@
import type { KbxReconcileResult } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface ReconcileFilter {
reconcileType?: string | null
status?: string | null
keyword?: string | null
page: number
pageSize: number
}
export const reconcileApi = {
search(filter: ReconcileFilter) {
return kbxApi.request<KbxReconcileResult>('common.reconcile.search', { query: filter })
},
createExceptions(ids: string[]) {
return kbxApi.request<unknown, { ids: string[] }>('common.reconcile.createExceptions', { body: { ids } })
},
}
@@ -0,0 +1,3 @@
export const reconcileRoutes = [
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
]
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxListPage, KbxSearchPanel, KbxDataGrid } from '@kbx/ui'
import type { KbxGridColumn, KbxSearchField, KbxUxMetricRow } from '@kbx/contracts'
import { uxMetricsScreen } from './ux-metrics.definition'
import { uxMetricsApi } from './uxMetricsApi'
const today=new Date();const fromDate=new Date(today);fromDate.setDate(today.getDate()-6)
const iso=(d:Date)=>d.toISOString().slice(0,10)
const search=reactive({from:iso(fromDate),to:iso(today),screenId:''})
const fields:KbxSearchField[]=[{key:'from',label:'시작일',type:'date',primary:true},{key:'to',label:'종료일',type:'date',primary:true},{key:'screenId',label:'화면코드',type:'text',primary:true,width:'md'}]
const query=useQuery({queryKey:['kbx','ux-metrics',search],queryFn:()=>uxMetricsApi.get({...search,screenId:search.screenId||undefined}),enabled:false})
const columns:KbxGridColumn<KbxUxMetricRow>[]=[
{field:'label',header:'지표',width:240},{field:'value',header:'현재값',type:'decimal',width:140},{field:'unit',header:'단위',width:100},{field:'sampleCount',header:'표본',type:'integer',width:110},{field:'p50',header:'P50',type:'decimal',width:110},{field:'p95',header:'P95',type:'decimal',width:110},
]
async function command(id:string){if(id==='search')await query.refetch()}
</script>
<template>
<KbxListPage :screen="uxMetricsScreen" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.metrics.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="search" :fields="fields" @search="query.refetch()" /></template>
<template #quick-filter>
<div class="notice"><strong>해석 기준</strong> DOM 전체 클릭이나 검색어 원문을 수집하지 않습니다. 낮은 수치 자체보다 업무 위험·오류 감소와 함께 판단합니다.</div>
</template>
<template #content><KbxDataGrid :rows="query.data.value?.metrics ?? []" :columns="columns" row-key="metricKey" :loading="query.isFetching.value" /></template>
<template #summary>{{ search.from }} ~ {{ search.to }} · Manual Intervention Rate를 자동화 개선의 핵심 추세로 봅니다.</template>
</KbxListPage>
</template>
<style scoped>.notice{padding:8px 12px;border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:13px}</style>
@@ -0,0 +1,7 @@
import { defineKbxScreen } from '@kbx/ui'
export const uxMetricsScreen=defineKbxScreen({
id:'COMMON-UX-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 품질 지표',
description:'KBX 의미 이벤트와 업무 결과를 이용해 수작업 개입·업무시간·오류율을 검토합니다.',
permissions:['common.ux.read'],helpKey:'COMMON-UX-001',telemetry:{enabled:true},
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'}],
})
@@ -0,0 +1,5 @@
import type { KbxUxMetricsResponse } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export const uxMetricsApi={
get:(query:{from:string;to:string;screenId?:string})=>kbxApi.request<KbxUxMetricsResponse>('common.uxTelemetry.metrics',{query}),
}
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
import { inventoryMoveColumns, inventoryMoveScreen, inventoryMoveWorkflow, type InventoryMoveLine } from './inventory-move.definition'
const status=ref('DRAFT'); const header=reactive({moveDate:new Date().toISOString().slice(0,10),fromWarehouseId:null as string|null,toWarehouseId:null as string|null}); const lines=ref<InventoryMoveLine[]>([])
function command(id:string){ const t=inventoryMoveWorkflow.transitions.find(x=>x.id===id && x.from.includes(status.value)); if(t) status.value=t.to }
</script>
<template><KbxTransactionPage :screen="inventoryMoveScreen" :status="status" :summary-items="[{key:'items',label:'품목',value:`${lines.length}종`}]" @command="command"><template #header><KbxWorkflowBar :workflow="inventoryMoveWorkflow" :current="status" @transition="command"/><KbxFormSection title="이동정보"><KbxFormGrid><KbxDateField v-model="header.moveDate" label="이동일" required/><KbxLookup v-model="header.fromWarehouseId" entity="warehouse" label="출발창고" required/><KbxLookup v-model="header.toWarehouseId" entity="warehouse" label="도착창고" required/></KbxFormGrid></KbxFormSection></template><template #detail><KbxDataGrid :rows="lines" :columns="inventoryMoveColumns" row-key="clientId" editable clipboard/></template></KbxTransactionPage></template>
@@ -0,0 +1,8 @@
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
export interface InventoryMoveLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number; moveQty:number; lotNo:string; remark:string }
export const inventoryMoveScreen=defineKbxScreen({ id:'ERP-INV-MOVE-001',version:'1.0.0',module:'ERP',type:'transaction', templateCode:'T03',title:'재고이동',helpKey:'ERP-INV-MOVE-001',permissions:['erp.inventory.move.read'],telemetry:{enabled:true}, description:'창고 간 재고이동을 등록하고 출고·입고 상태를 추적합니다.', commands:[
{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.inventory.move.write'},{id:'confirm',label:'이동확정',group:'workflow',variant:'primary',permission:'erp.inventory.move.confirm'},{id:'excel',label:'엑셀',group:'output'}] })
export const inventoryMoveColumns:KbxGridColumn<InventoryMoveLine>[]=[
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'},{field:'itemName',header:'품목명',width:200},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'moveQty',header:'이동수량',type:'quantity',width:100,editable:true},{field:'lotNo',header:'LOT',type:'code',width:120,editable:true},{field:'remark',header:'비고',width:220,editable:true}]
export const inventoryMoveWorkflow:KbxWorkflowDefinition={id:'erp.inventory-move',version:'1.0.0',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'CONFIRMED',label:'확정',semantic:'pending'},{value:'IN_TRANSIT',label:'이동중',semantic:'processing'},{value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true},{value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}],transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'이동확정',permission:'erp.inventory.move.confirm',confirm:true},{id:'ship',from:['CONFIRMED'],to:'IN_TRANSIT',label:'출고',permission:'erp.inventory.move.ship'},{id:'receive',from:['IN_TRANSIT'],to:'RECEIVED',label:'입고완료',permission:'erp.inventory.move.receive',confirm:true}]}
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
]
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
import { inventoryApi } from './inventoryApi'
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
const appliedKeyword=ref('')
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
const selectedItemId=ref<string|null>(null)
const selectedItem=ref<InventoryItemRow|null>(null)
const breakdownOpen=ref(false)
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
async function search(){
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
}
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
function command(id:string){if(id==='search')return search()}
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
const summaryItems=computed(()=>[
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
])
const drawerSummary=computed(()=>selectedItem.value?[
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
]:[])
</script>
<template>
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
<template #master>
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
</template>
<template #detail>
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
</template>
<template #bottom>
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
</template>
</KbxMasterDetailPage>
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
<div v-if="selectedItem" class="inventory-breakdown">
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
<KbxSummaryBar :items="drawerSummary" />
<dl><dt>현재고</dt><dd>물리적으로 기록된 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 있는 수량</dd></dl>
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
</div>
</KbxDrawer>
</template>
<style scoped>
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>
@@ -0,0 +1,49 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
export interface InventoryItemRow {
itemId: string; itemCode: string; itemName: string; specification: string
totalQty: number; availableQty: number; allocatedQty: number; holdQty: number
}
export interface InventoryLocationRow {
key: string; warehouseName: string; locationCode: string
onHandQty: number; allocatedQty: number; availableQty: number; holdQty: number
}
export interface InventoryHistoryRow {
entryId: string; occurredAt: string; businessType: string; referenceNo: string
warehouseName: string; locationCode: string; inboundQty: number; outboundQty: number; balanceQty: number; actor: string
}
export const inventoryScreen = defineKbxScreen({
id: 'ERP-INV-001', version: '1.1.0', module: 'ERP', type: 'master-detail', templateCode:'T05', title: '재고현황',
description: '품목 현재고에서 창고·로케이션 근거와 재고이력까지 조회 Context를 유지해 탐색합니다.',
helpKey: 'ERP-INV-001', permissions: ['erp.inventory.read'], telemetry: { enabled: true },
commands: [{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' }, { id: 'excel', label: '엑셀', group: 'output' }],
})
export const inventoryItemColumns: KbxGridColumn<InventoryItemRow>[] = [
{ field: 'itemCode', header: '품목코드', type: 'code', width: 120, pinned: 'left' },
{ field: 'itemName', header: '품목명', width: 190 },
{ field: 'specification', header: '규격', width: 140 },
{ field: 'totalQty', header: '현재고', type: 'quantity', width: 100, drilldown:true },
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100, drilldown:true },
]
export const inventoryLocationColumns: KbxGridColumn<InventoryLocationRow>[] = [
{ field: 'warehouseName', header: '창고', width: 150 },
{ field: 'locationCode', header: '로케이션', type: 'code', width: 120 },
{ field: 'onHandQty', header: '현재고', type: 'quantity', width: 100 },
{ field: 'allocatedQty', header: '할당', type: 'quantity', width: 90 },
{ field: 'holdQty', header: '보류', type: 'quantity', width: 90 },
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100 },
]
export const inventoryHistoryColumns: KbxGridColumn<InventoryHistoryRow>[] = [
{ field:'occurredAt', header:'일시', type:'datetime', width:165 },
{ field:'businessType', header:'업무', width:110 },
{ field:'referenceNo', header:'참조번호', type:'code', width:150 },
{ field:'warehouseName', header:'창고', width:130 },
{ field:'locationCode', header:'로케이션', type:'code', width:110 },
{ field:'inboundQty', header:'입고', type:'quantity', width:90 },
{ field:'outboundQty', header:'출고', type:'quantity', width:90 },
{ field:'balanceQty', header:'잔량', type:'quantity', width:100 },
{ field:'actor', header:'처리자', width:110 },
]
export const inventorySearchFields: KbxSearchField[] = [{ key: 'keyword', label: '품목', type: 'text', width: 'lg', placeholder: '품목코드/품목명' }]
@@ -0,0 +1,8 @@
import { kbxApi } from '../../../http/generated/kbxApiClient'
import type { InventoryHistoryRow, InventoryItemRow, InventoryLocationRow } from './inventory.definition'
export interface InventorySearchResponse { items: InventoryItemRow[]; totalCount: number }
export const inventoryApi = {
search(keyword = '') { return kbxApi.request<InventorySearchResponse>('erp.inventory.search', { query: { keyword, page: 1, pageSize: 200 } }) },
locations(itemId: string) { return kbxApi.request<{ items: InventoryLocationRow[] }>('erp.inventory.locations', { path: { itemId } }) },
history(itemId: string) { return kbxApi.request<{ items: InventoryHistoryRow[] }>('erp.inventory.history', { path: { itemId }, query:{ pageSize:100 } }) },
}
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxLookupItem } from '@kbx/contracts'
import { KbxDataGrid, KbxFastEntryPage, KbxLookupDialog, useKbxPageShortcuts } from '@kbx/ui'
import { itemPriceColumns, itemPriceScreen, type ItemPriceRow } from './item-price.definition'
import { useItemPriceFastEntry } from './useItemPriceFastEntry'
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
const vm=useItemPriceFastEntry()
useKbxWorkspaceBinding(vm.dirty, async()=>await vm.save())
const lookupOpen=ref(false); const activeRow=ref<ItemPriceRow|null>(null)
function openLookup(event:{row:ItemPriceRow;entity:string}){ if(event.entity!=='item')return;activeRow.value=event.row;lookupOpen.value=true }
function selectItem(item:KbxLookupItem<string>){ if(activeRow.value)vm.applyItemLookup(activeRow.value,item) }
const pageContext=computed(()=>({label:'품목 단가 입력',hint:'저장 전 오류를 모두 해소한 뒤 F8로 일괄 저장합니다.',metrics:[{key:'rows',label:'입력',value:vm.enteredCount.value},{key:'errors',label:'오류',value:vm.errors.value.length,tone:vm.errors.value.length?'danger' as const:'default' as const,emphasis:vm.errors.value.length>0}]}))
const summary=computed(()=>[
{key:'rows',label:'입력',value:`${vm.enteredCount.value.toLocaleString('ko-KR')}`},
{key:'errors',label:'오류',value:`${vm.errors.value.length.toLocaleString('ko-KR')}`},
...(vm.lastResult.value?[{key:'saved',label:'최근 저장',value:`${vm.lastResult.value.saved.toLocaleString('ko-KR')}`,emphasis:true}]:[]),
])
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
async function command(id:string){ if(id==='save')await vm.save(); if(id==='new')vm.clear() }
</script>
<template>
<KbxFastEntryPage :screen="itemPriceScreen" :selection-count="0" :dirty="vm.dirty.value" :context="pageContext" :errors="vm.errors.value" :summary-items="summary" 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>
@@ -0,0 +1,36 @@
import { defineKbxScreen, type KbxGridColumn } from '@kbx/ui'
export interface ItemPriceRow {
clientId: string
itemId: string | null
itemCode: string
itemName: string
effectiveDate: string
unitPrice: number
remark: string
}
export const itemPriceScreen = defineKbxScreen({
id: 'ERP-PRICE-001',
version: '1.0.0',
module: 'ERP',
type: 'fast-entry', templateCode:'T04',
title: '품목 단가 일괄등록',
description: '품목별 적용일과 표준단가를 Excel처럼 연속 입력하고 한 번에 저장합니다.',
helpKey: 'ERP-PRICE-001',
permissions: ['erp.item.price.read', 'erp.item.read'],
commands: [
{ id: 'new', label: '신규', group: 'edit' },
{ id: 'save', label: '저장', group: 'edit', variant: 'primary', shortcut: 'F8', permission: 'erp.item.price.write' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
telemetry: { enabled: true },
})
export const itemPriceColumns: KbxGridColumn<ItemPriceRow>[] = [
{ field:'itemCode', header:'품목코드', type:'lookup', width:140, pinned:'left', editable:true, lookup:{entity:'item'} },
{ field:'itemName', header:'품목명', width:220 },
{ field:'effectiveDate', header:'적용일', type:'date', width:120, editable:true },
{ field:'unitPrice', header:'단가', type:'money', width:130, editable:true },
{ field:'remark', header:'비고', width:260, editable:true },
]
@@ -0,0 +1,25 @@
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface SaveItemPriceRow {
clientId: string
itemId: string
effectiveDate: string
unitPrice: number
remark?: string
}
export interface SaveItemPricesRequest { rows: SaveItemPriceRow[] }
export interface SaveItemPricesResponse { requested: number; saved: number; created: number; updated: number }
export interface ItemLookupResult { id: string; code: string; displayName: string; status?: string }
export function toKbxProblem(error: unknown): KbxProblem | null { return isKbxProblem(error) ? error : null }
export const itemPriceApi = {
async resolveItemByCode(code: string) {
try { return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode', { path:{ code } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
save(request: SaveItemPricesRequest, idempotencyKey: string) {
return kbxApi.request<SaveItemPricesResponse, SaveItemPricesRequest>('erp.itemPrices.bulkSave', { body:request, idempotencyKey })
},
}
@@ -0,0 +1,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>
@@ -0,0 +1,17 @@
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
export interface PurchaseLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; quantity:number; unitPrice:number; amount:number; dueDate:string }
export const purchaseScreen = defineKbxScreen({
id:'ERP-PUR-001', version:'1.0.0', module:'ERP', type:'transaction', templateCode:'T03', title:'구매등록', helpKey:'ERP-PUR-001', permissions:['erp.purchase.read'], telemetry:{enabled:true},
description:'거래처 구매를 Header/Detail 방식으로 입력하고 확정합니다.',
commands:[{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.purchase.write'},{id:'confirm',label:'구매확정',group:'workflow',variant:'primary',permission:'erp.purchase.confirm'},{id:'excel',label:'엑셀',group:'output'}]
})
export const purchaseColumns: KbxGridColumn<PurchaseLine>[] = [
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'}, {field:'itemName',header:'품목명',width:210},
{field:'quantity',header:'수량',type:'quantity',width:95,editable:true}, {field:'unitPrice',header:'단가',type:'money',width:120,editable:true}, {field:'amount',header:'금액',type:'money',width:130}, {field:'dueDate',header:'납기일',type:'date',width:110,editable:true}
]
export const purchaseWorkflow: KbxWorkflowDefinition = { id:'erp.purchase', version:'1.0.0', states:[
{value:'DRAFT',label:'작성',semantic:'draft'}, {value:'CONFIRMED',label:'확정',semantic:'processing'}, {value:'PARTIALLY_RECEIVED',label:'부분입고',semantic:'processing'}, {value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true}, {value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}
], transitions:[
{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'구매확정',permission:'erp.purchase.confirm',confirm:true}, {id:'cancel',from:['DRAFT','CONFIRMED'],to:'CANCELLED',label:'구매취소',permission:'erp.purchase.cancel',reasonRequired:true}
] }
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const erpPurchaseRoutes: RouteRecordRaw[] = [
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
]
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxDataGrid, KbxListPage, KbxSearchPanel, KbxWorkflowBar } from '@kbx/ui'
import { claimColumns, claimScreen, claimSearchFields, claimWorkflow, type ClaimRow } from './claims.definition'
import { claimsApi } from './claimsApi'
const search = reactive<Record<string,unknown>>({ status:'REQUESTED' })
const selected = ref<ClaimRow[]>([])
const q = useQuery({ queryKey:['oms','claims',search], queryFn:()=>claimsApi.search(search), enabled:false })
async function run(){ selected.value=[]; await q.refetch() }
async function command(id:string){ if(id==='search') return run(); if(['approve','hold'].includes(id)) for(const row of selected.value) await claimsApi.transition(row.id,id); await run() }
</script>
<template>
<KbxListPage :screen="claimScreen" :content-state="q.error.value?'error':q.isFetching.value&&!q.data.value?'loading':!q.data.value?'idle':q.data.value.totalCount===0?'empty':'ready'" :refreshing="q.isFetching.value&&Boolean(q.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="search" :fields="claimSearchFields" @search="run" /></template>
<template #content>
<KbxWorkflowBar v-if="selected.length === 1" :workflow="claimWorkflow" :current="selected[0].status" @transition="command" />
<KbxDataGrid v-model:selection="selected" :rows="q.data.value?.items ?? []" :columns="claimColumns" row-key="id" selection="multiple" :loading="q.isFetching.value" personalization exportable />
</template>
<template #summary>클레임 {{ q.data.value?.totalCount ?? 0 }} · 선택 {{ selected.length }}</template>
</KbxListPage>
</template>
@@ -0,0 +1,39 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkflowDefinition } from '@kbx/ui'
export interface ClaimRow {
id: string; claimNo: string; orderNo: string; channelName: string; type: string; reason: string; requestedQty: number; status: string; ownerName: string; requestedAt: string
}
export const claimScreen = defineKbxScreen({
id:'OMS-CLM-001', version:'1.0.0', module:'OMS', type:'list', templateCode:'T01', title:'반품·클레임 관리', helpKey:'OMS-CLM-001', permissions:['oms.claim.read'], telemetry:{ enabled:true },
description:'반품·교환·취소 요청을 조회하고 정상 건은 표준 상태전이로 처리합니다.',
commands:[
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
{ id:'approve', label:'승인', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, permission:'oms.claim.approve' },
{ id:'hold', label:'보류', group:'workflow', requiresSelection:true, minSelection:1, permission:'oms.claim.hold' },
{ id:'excel', label:'엑셀', group:'output' },
]
})
export const claimSearchFields: KbxSearchField[] = [
{ key:'period', label:'요청기간', type:'date-range', range:{ from:'from', to:'to' } },
{ key:'type', label:'유형', type:'select', options:[{value:'RETURN',label:'반품'},{value:'EXCHANGE',label:'교환'},{value:'CANCEL',label:'취소'}] },
{ key:'status', label:'상태', type:'select', options:[{value:'REQUESTED',label:'접수'},{value:'APPROVED',label:'승인'},{value:'IN_PROGRESS',label:'처리중'},{value:'COMPLETED',label:'완료'},{value:'HOLD',label:'보류'}] },
{ key:'keyword', label:'검색', type:'text', width:'lg', placeholder:'클레임번호/주문번호/고객' },
]
export const claimColumns: KbxGridColumn<ClaimRow>[] = [
{ field:'claimNo', header:'클레임번호', type:'code', width:150, pinned:'left' },
{ field:'orderNo', header:'주문번호', type:'code', width:150 },
{ field:'channelName', header:'채널', width:100 }, { field:'type', header:'유형', width:90 }, { field:'reason', header:'사유', width:220 },
{ field:'requestedQty', header:'수량', type:'quantity', width:90 }, { field:'status', header:'상태', type:'status', width:100 }, { field:'ownerName', header:'담당', width:100 }, { field:'requestedAt', header:'요청일시', type:'datetime', width:160 },
]
export const claimWorkflow: KbxWorkflowDefinition = {
id:'oms.claim', version:'1.0.0',
states:[
{value:'REQUESTED',label:'접수',semantic:'pending'}, {value:'APPROVED',label:'승인',semantic:'processing'}, {value:'IN_PROGRESS',label:'처리중',semantic:'processing'}, {value:'COMPLETED',label:'완료',semantic:'completed',terminal:true}
],
transitions:[
{id:'approve',from:['REQUESTED'],to:'APPROVED',label:'승인',permission:'oms.claim.approve'},
{id:'start',from:['APPROVED'],to:'IN_PROGRESS',label:'처리 시작',permission:'oms.claim.process'},
{id:'complete',from:['IN_PROGRESS'],to:'COMPLETED',label:'처리 완료',permission:'oms.claim.process',confirm:true},
]
}
@@ -0,0 +1,22 @@
import type { ClaimRow } from './claims.definition'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface ClaimSearch { from?: string; to?: string; type?: string; status?: string; keyword?: string }
const transitionOperations = {
approve: 'oms.claims.approve',
hold: 'oms.claims.hold',
start: 'oms.claims.start',
complete: 'oms.claims.complete',
} as const
export const claimsApi = {
search(filter: ClaimSearch) {
return kbxApi.request<{ items: ClaimRow[]; totalCount: number }>('oms.claims.search', { query: filter })
},
transition(id: string, transition: string) {
const operation = transitionOperations[transition as keyof typeof transitionOperations]
if (!operation) throw new Error(`Unsupported claim transition: ${transition}`)
return kbxApi.request<{ id: string; status: string; version: number }>(operation, { path: { id } })
},
}
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const omsClaimRoutes: RouteRecordRaw[] = [
{ path:'/oms/claims', name:'oms-claims', component:()=>import('./ClaimsPage.vue'), meta:{ screenId:'OMS-CLM-001' } },
]
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { watch } from 'vue'
import { KbxExcelImport, KbxImportPage, useKbxImportProgress } from '@kbx/ui'
import { orderImportDefinition, orderImportScreen } from './order-import.definition'
import { useOrderExcelImport } from './useOrderExcelImport'
import { createImportProgressConnection } from '../../../../imports/createImportProgressConnection'
const vm = useOrderExcelImport()
const realtime = useKbxImportProgress(createImportProgressConnection())
watch(vm.sessionId, id => { if (id) void realtime.watch(id) })
</script>
<template>
<KbxImportPage :screen="orderImportScreen" :refreshing="vm.busy.value">
<KbxExcelImport
:definition="orderImportDefinition"
:session="vm.session.value"
:busy="vm.busy.value"
:progress="realtime.lastEvent.value"
@upload="vm.upload"
@save-mapping="vm.saveMapping"
@save-named-mapping="vm.saveNamedMapping"
@validate="vm.validate"
@commit="vm.commit"
@download-template="vm.downloadTemplate"
@download-errors="vm.downloadErrors"
@cancel="vm.reset"
/>
</KbxImportPage>
</template>
@@ -0,0 +1,40 @@
import { defineKbxScreen } from '@kbx/ui'
import { kbxImportField, type KbxImportDefinition } from '@kbx/contracts'
export const orderImportScreen = defineKbxScreen({
id: 'OMS-ORD-003',
version: '1.1.0',
module: 'OMS',
type: 'import', templateCode:'T08',
title: '주문 Excel 업로드',
helpKey: 'OMS-ORD-003',
permissions: ['oms.order.import'],
commands: [],
telemetry: { enabled: true },
})
export const orderImportDefinition: KbxImportDefinition = {
id: 'oms.orders.v1',
screenId: 'OMS-ORD-003',
entity: 'order',
title: '주문 Excel 업로드',
allowCreate: true,
allowUpdate: true,
maxFileSizeBytes: 20 * 1024 * 1024,
maxRows: 100_000,
fields: [
kbxImportField('orderNo'),
kbxImportField('orderDate'),
kbxImportField('customerCode'),
kbxImportField('warehouseCode'),
kbxImportField('receiverName'),
kbxImportField('phone'),
kbxImportField('postalCode'),
kbxImportField('address1'),
kbxImportField('address2'),
kbxImportField('itemCode'),
kbxImportField('orderQty'),
kbxImportField('unitPrice'),
kbxImportField('remark'),
],
}
@@ -0,0 +1,58 @@
import type { KbxImportMapping, KbxImportSession } from '@kbx/contracts'
import { kbxApi } from '../../../../http/generated/kbxApiClient'
export const orderImportApi = {
createSession(file: File) {
const form = new FormData()
form.append('file', file)
form.append('importType', 'oms.orders.v1')
return kbxApi.request<KbxImportSession, FormData>('common.imports.createSession', { body: form })
},
getSession(id: string) {
return kbxApi.request<KbxImportSession>('common.imports.getSession', { path: { sessionId: id } })
},
saveMapping(id: string, mappings: KbxImportMapping[]) {
return kbxApi.request<KbxImportSession, { mappings: KbxImportMapping[] }>('common.imports.saveMapping', {
path: { sessionId: id }, body: { mappings },
})
},
saveNamedMapping(id: string, name: string, mappings: KbxImportMapping[]) {
return kbxApi.request<void, { name: string; mappings: KbxImportMapping[] }>('common.imports.saveNamedMapping', {
path: { sessionId: id }, body: { name, mappings },
})
},
validate(id: string) {
return kbxApi.request<KbxImportSession>('common.imports.validate', { path: { sessionId: id } })
},
commit(id: string) {
return kbxApi.request<KbxImportSession>('common.imports.commit', { path: { sessionId: id } })
},
async downloadTemplate() {
const value = await kbxApi.request<Blob>('common.imports.template', {
path: { importType: 'oms.orders.v1' }, responseType: 'blob',
})
downloadBlob(value, 'OMS_주문_업로드_양식.xlsx')
},
async downloadErrors(id: string) {
const value = await kbxApi.request<Blob>('common.imports.errorWorkbook', {
path: { sessionId: id }, responseType: 'blob',
})
downloadBlob(value, 'OMS_주문_업로드_오류.xlsx')
},
}
function downloadBlob(value: BlobPart, fileName: string) {
const url = URL.createObjectURL(new Blob([value]))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
@@ -0,0 +1,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 },
}
}
@@ -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>
@@ -0,0 +1,22 @@
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
export interface OrderLineForm { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number|null; orderQty:number; unitPrice:number; amount:number; remark:string }
export const orderWorkflow:KbxWorkflowDefinition={id:'oms.order.lifecycle',version:'1.0.0',states:[
{value:'신규',label:'신규',semantic:'draft'},{value:'작성',label:'작성',semantic:'draft'},{value:'확정',label:'확정',semantic:'pending'},{value:'할당',label:'할당',semantic:'processing'},{value:'피킹',label:'피킹',semantic:'processing'},{value:'검수',label:'검수',semantic:'processing'},{value:'출고완료',label:'출고완료',semantic:'completed',terminal:true}],
transitions:[{id:'confirm',from:['작성'],to:'확정',label:'주문확정',permission:'oms.order.confirm',confirm:true}]}
export const orderStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'작성',editability:'editable'},{status:'확정',editability:'readonly',message:'확정된 주문은 직접 수정할 수 없습니다.'},{status:'할당',editability:'readonly'},{status:'피킹',editability:'readonly'},{status:'검수',editability:'readonly'},{status:'출고완료',editability:'readonly'}]
export const orderRegisterScreen = defineKbxScreen({
id:'OMS-ORD-002',version:'1.3.0',module:'OMS',type:'transaction', templateCode:'T03',title:'주문등록',helpKey:'OMS-ORD-002',permissions:['oms.order.read','erp.item.read'],
commands:[
{id:'new',label:'신규',group:'edit'},
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'oms.order.create','작성':'oms.order.write'},allowedStatuses:['신규','작성'],requiresDirty:true},
{id:'confirm',label:'주문확정',group:'workflow',permission:'oms.order.confirm',allowedStatuses:['작성'],requiresClean:true,disabledReason:'변경사항을 저장한 뒤 주문을 확정하세요.',confirm:{title:'주문을 확정하시겠습니까?',detail:'확정 후 주문의 일반 입력 필드는 직접 수정할 수 없습니다.',level:'high',confirmLabel:'주문확정'}},
{id:'copy',label:'주문복사',group:'edit'},
{id:'excel',label:'엑셀',group:'output'},
],telemetry:{enabled:true},
})
export const orderLineColumns:KbxGridColumn<OrderLineForm>[]=[
{field:'itemCode',header:'품목코드',type:'lookup',width:130,editable:true,pinned:'left',lookup:{entity:'item'}},{field:'itemName',header:'품목명',width:220},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'orderQty',header:'수량',type:'quantity',width:95,editable:true},{field:'unitPrice',header:'단가',type:'money',width:120,editable:true},{field:'amount',header:'금액',type:'money',width:130},{field:'remark',header:'비고',width:220,editable:true},
]
@@ -0,0 +1,31 @@
import { z } from 'zod'
import {
kbxDecimalSchema,
kbxNullableEntityIdSchema,
kbxRequiredTextSchema,
kbxTextSchema,
} from '../../../../validation/kbxFieldSchema'
export const orderHeaderSchema = z.object({
orderDate: kbxRequiredTextSchema('orderDate', '주문일을 입력하세요.'),
customerId: kbxNullableEntityIdSchema('customerId', '거래처를 선택하세요.'),
warehouseId: kbxNullableEntityIdSchema('warehouseId', '출고창고를 선택하세요.'),
receiverName: kbxRequiredTextSchema('receiverName'),
phone: kbxRequiredTextSchema('phone'),
postalCode: kbxTextSchema('postalCode').optional(),
address1: kbxRequiredTextSchema('address1'),
address2: kbxTextSchema('address2').optional(),
})
export const orderLineSchema = z.object({
clientId: z.string(),
itemId: kbxNullableEntityIdSchema('itemId', '품목을 선택하세요.'),
// Positive quantity is an order rule, so the field dictionary only supplies numeric structure.
orderQty: kbxDecimalSchema('orderQty').positive('수량은 0보다 커야 합니다.'),
unitPrice: kbxDecimalSchema('unitPrice').nonnegative('단가는 0 이상이어야 합니다.'),
})
export const orderRegisterSchema = z.object({
header: orderHeaderSchema,
lines: z.array(orderLineSchema).min(1, '주문 품목을 한 건 이상 입력하세요.'),
})
@@ -0,0 +1,14 @@
import { isKbxProblem, type KbxAuditEntry, type KbxProblem } from '@kbx/contracts'
import { kbxApi } from '../../../../http/generated/kbxApiClient'
export interface RegisterOrderRequest { orderId?:string; version?:number; orderDate:string; customerId:string; warehouseId:string; receiverName:string; phone:string; postalCode?:string; address1:string; address2?:string; lines:Array<{clientId:string;itemId:string;orderQty:number;unitPrice:number;remark?:string}> }
export interface RegisterOrderResponse { orderId:string; orderNo:string; version:number; status:string }
export interface OrderEditResponse extends RegisterOrderResponse { orderDate:string; customerId:string; warehouseId:string; receiverName:string; phone:string; postalCode?:string; address1:string; address2?:string; lines:Array<{id:string;clientId:string;itemId:string;itemCode:string;itemName:string;orderQty:number;unitPrice:number;amount:number;remark:string}> }
export interface ItemLookupResult { id:string; code:string; displayName:string; status?:string }
export function toKbxProblem(error:unknown):KbxProblem|null{return isKbxProblem(error)?error:null}
export const orderRegisterApi={
async resolveItemByCode(code:string){try{return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode',{path:{code}})}catch(error){if(isKbxProblem(error)&&error.type==='not-found')return null;throw error}},
save(request:RegisterOrderRequest){return request.orderId?kbxApi.request<RegisterOrderResponse,RegisterOrderRequest>('oms.orders.update',{path:{id:request.orderId},body:request}):kbxApi.request<RegisterOrderResponse,RegisterOrderRequest>('oms.orders.register',{body:request})},
get(orderId:string){return kbxApi.request<OrderEditResponse>('oms.orders.get',{path:{id:orderId}})},
confirm(orderId:string,version:number){return kbxApi.request<RegisterOrderResponse>('oms.orders.confirm',{path:{id:orderId},body:{version},idempotencyKey:`order-confirm:${orderId}:${version}`})},
audit(orderId:string){return kbxApi.request<KbxAuditEntry[]>('oms.orders.audit',{path:{id:orderId}})},
}
@@ -0,0 +1,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}
}
@@ -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>
@@ -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(),
})
},
}
@@ -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>
@@ -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' } },
]
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { KbxWmsExceptionType } from '@kbx/contracts'
import { KbxWmsActionButton } from '@kbx/ui'
defineProps<{ busy?: boolean }>()
const emit = defineEmits<{
close: []
submit: [KbxWmsExceptionType, string]
}>()
const type = ref<KbxWmsExceptionType>('no-stock')
const memo = ref('')
const options: { value: KbxWmsExceptionType; label: string }[] = [
{ value: 'no-stock', label: '실물 없음' },
{ value: 'short-quantity', label: '수량 부족' },
{ value: 'wrong-location', label: '위치 오류' },
{ value: 'damaged-item', label: '상품 이상' },
{ value: 'barcode-issue', label: '바코드 문제' },
{ value: 'other', label: '기타' },
]
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="exception-title">
<h2 id="exception-title">피킹 문제 신고</h2>
<p>가장 가까운 사유 하나만 선택하세요.</p>
<label v-for="option in options" :key="option.value" class="reason">
<input v-model="type" type="radio" :value="option.value">
<span>{{ option.label }}</span>
</label>
<label class="memo">메모 <small>선택 입력</small>
<textarea v-model="memo" rows="3" maxlength="300" />
</label>
<div class="actions">
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
<KbxWmsActionButton label="문제 등록" :busy="busy" @click="$emit('submit', type, memo)" />
</div>
</section>
</div>
</template>
<style scoped>
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:30; }
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; max-height:85dvh; overflow:auto; }
h2 { margin:0 0 4px; font-size:20px; }
p { margin:0 0 14px; color:var(--kbx-color-text-muted); }
.reason { min-height:48px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--kbx-color-border); }
.reason input { width:20px; height:20px; }
.memo { display:block; margin-top:16px; font-weight:600; }
.memo small { font-weight:400; color:var(--kbx-color-text-muted); }
textarea { width:100%; margin-top:6px; border:1px solid var(--kbx-color-border); border-radius:6px; padding:10px; font:inherit; box-sizing:border-box; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxWmsActionButton } from '@kbx/ui'
const props = defineProps<{
current: number
required: number
busy?: boolean
}>()
const emit = defineEmits<{ close: []; submit: [number] }>()
const quantity = ref(props.current)
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="quantity-title">
<h2 id="quantity-title">피킹 수량 입력</h2>
<p>상품 바코드를 최소 1 확인한 사용합니다.</p>
<label>
피킹 수량
<input v-model.number="quantity" type="number" inputmode="decimal" :min="1" :max="required">
</label>
<div class="hint">필요수량 {{ required }}</div>
<div class="actions">
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
<KbxWmsActionButton
label="수량 적용"
:busy="busy"
:disabled="quantity <= 0 || quantity > required"
@click="$emit('submit', quantity)"
/>
</div>
</section>
</div>
</template>
<style scoped>
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:31; }
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; }
h2 { margin:0 0 4px; font-size:20px; }
p { margin:0 0 16px; color:var(--kbx-color-text-muted); }
label { display:block; font-weight:600; }
input { width:100%; min-height:52px; margin-top:6px; padding:0 12px; box-sizing:border-box; font:inherit; font-size:22px; text-align:right; border:1px solid var(--kbx-color-border-strong); border-radius:6px; }
.hint { margin-top:6px; text-align:right; color:var(--kbx-color-text-muted); font-size:13px; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; }
</style>
@@ -0,0 +1,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' } },
]
@@ -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>
@@ -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>

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