V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
+92
@@ -0,0 +1,92 @@
|
||||
<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'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
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:'READY'},
|
||||
{id:'2',orderNo:'TEST-ORD-002',customerName:'서울유통',orderQty:8,amount:96000,status:'ERROR'},
|
||||
{id:'3',orderNo:'TEST-ORD-003',customerName:'부산물류',orderQty:4,amount:52000,status:'NEW'},
|
||||
])
|
||||
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:120,statusMap:{definitions:kbxStatusCatalog.orderShipment}},
|
||||
]
|
||||
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:'NEW'},
|
||||
{id:'F2',orderNo:'TEST-FAST-002',customerName:'대한상사',orderQty:2,amount:24000,status:'NEW'},
|
||||
{id:'F3',orderNo:'TEST-FAST-003',customerName:'대한상사',orderQty:3,amount:36000,status:'NEW'},
|
||||
])
|
||||
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:'NEW'})}
|
||||
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="ready"/><KbxStatus label="진행" semantic="processing"/><KbxStatus label="완료" semantic="completed"/><KbxStatus label="재고부족" semantic="warning"/><KbxStatus label="오류" semantic="error"/><KbxStatus label="정의되지 않음 · NEW_STATE" semantic="warning" unknown raw-value="NEW_STATE"/></div><KbxToast message="저장했습니다."/><KbxToast message="일부 항목을 확인하세요." kind="warning"/></section>
|
||||
|
||||
<section class="catalog__block"><h2>AI / Audit</h2><div class="catalog__grid"><KbxProposalPanel :proposal="proposal"/><KbxAuditTrail :entries="audit"/></div></section>
|
||||
|
||||
<section class="catalog__block"><h2>T01~T09 Template Contract</h2><p class="catalog__hint">Screen Type을 선택하면 필수 Surface·Core Component·Keyboard·완료 점검까지 함께 결정됩니다. 화면별 임의 Layout은 예외입니다.</p><table><thead><tr><th>Type</th><th>Component</th><th>필수 Surface</th><th>Core Components</th><th>완료 점검</th><th>Reference</th></tr></thead><tbody><tr v-for="template in kbxTemplateManifest" :key="template.code"><td><strong>{{template.code}}</strong><br><small>{{template.type}}</small></td><td>{{template.component}}<br><small>{{template.keyboard.join(' · ')}}</small></td><td>{{template.requiredSurfaces.join(' · ')}}</td><td>{{template.coreComponents.join(' · ')}}</td><td>{{template.completionChecks.join(' · ')}}</td><td>{{template.referenceScreens.join(', ')}}</td></tr></tbody></table></section>
|
||||
|
||||
<section class="catalog__block"><h2>검증 계약</h2><table><thead><tr><th>Component</th><th>Group</th><th>States</th><th>Keyboard</th><th>Focus</th></tr></thead><tbody><tr v-for="entry in kbxComponentCatalog" :key="entry.component"><td>{{entry.component}}</td><td>{{entry.group}}</td><td>{{entry.scenarios.map(x=>x.label).join(', ')}}</td><td>{{entry.accessibility.keyboard?'✓':'-'}}</td><td>{{entry.accessibility.focusVisible?'✓':'-'}}</td></tr></tbody></table></section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog{display:grid;gap:var(--kbx-space-4);padding:var(--kbx-space-4);background:var(--kbx-color-surface);color:var(--kbx-color-text)}.density,.catalog__summary,.status-row{display:flex;align-items:center;gap:var(--kbx-space-2);flex-wrap:wrap}.density button{height:var(--kbx-control-height);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-sm);padding:0 10px}.density button[aria-pressed="true"]{border-color:var(--kbx-color-primary);color:var(--kbx-color-primary);font-weight:600}.catalog__summary span{padding:6px 8px;background:var(--kbx-color-surface-muted);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}.catalog__block{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-3);border:1px solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm)}h2,h3{margin:0}.catalog__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--kbx-space-4)}.catalog__grid>div{display:grid;gap:var(--kbx-space-2)}.catalog__grid-demo{height:360px}.catalog__hint{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}th,td{padding:7px 8px;border-bottom:1px solid var(--kbx-color-border);text-align:left}th{background:var(--kbx-color-surface-muted);font-weight:600}
|
||||
</style>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const designSystemCatalogScreen = defineKbxScreen({
|
||||
id:'COMMON-DS-001',
|
||||
version:'1.0.0',
|
||||
module:'COMMON',
|
||||
type:'list', templateCode:'T01',
|
||||
title:'KBX 컴포넌트 카탈로그',
|
||||
description:'공통 컴포넌트의 상태·밀도·키보드·접근성 계약을 재현합니다.',
|
||||
permissions:['kbx.design.read'],
|
||||
helpKey:'COMMON-DS-001',
|
||||
telemetry:{enabled:true},
|
||||
})
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<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'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
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:120,statusMap:{definitions:kbxStatusCatalog.experiment}},{field:'rolloutPercent',header:'배포 %',type:'integer',width:90},{field:'decision',header:'판정',width:150},{field:'updatedAt',header:'변경일시',type:'datetime',width:170},
|
||||
]
|
||||
const dialogOpen=ref(false),mode=ref<'rollout'|'rollback'>('rollout'),reason=ref(''),rolloutPercent=ref<number|null>(0),rolloutState=ref<string|null>('running'),error=ref('')
|
||||
function openManage(next:'rollout'|'rollback'){const item=selected.value[0];if(!item)return;mode.value=next;reason.value='';error.value='';rolloutPercent.value=item.rolloutPercent;rolloutState.value=item.state==='paused'?'paused':'running';dialogOpen.value=true}
|
||||
async function apply(){const item=selected.value[0];if(!item)return;if(!reason.value.trim()){error.value='변경 사유를 입력하세요.';return}if(mode.value==='rollout'){const percent=rolloutPercent.value??0;if(percent<0||percent>100){error.value='배포 비율은 0~100 사이여야 합니다.';return}await experimentApi.rollout(item.experimentId,{rolloutPercent:percent,state:(rolloutState.value==='paused'?'paused':'running'),reason:reason.value.trim()})}else await experimentApi.rollback(item.experimentId,{reason:reason.value.trim()});dialogOpen.value=false;selected.value=[];await query.refetch()}
|
||||
async function command(id:string){if(id==='search')await query.refetch();if(id==='rollout')openManage('rollout');if(id==='rollback')openManage('rollback')}
|
||||
</script>
|
||||
<template><KbxListPage :screen="experimentsScreen" :selection-count="selected.length" @command="command">
|
||||
<template #quick-filter><div class="notice"><strong>안전 범위</strong> — Layout·정보강조·기본필터·Navigation·Copy만 실험합니다. 권한·민감정보·Domain Rule·Validation·WMS Scan Rule은 실험하지 않습니다.</div></template>
|
||||
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="experimentId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
|
||||
<template #summary>판정은 운영 Threshold 기반입니다. 통계적 유의성을 자동 주장하지 않으며, Guardrail 위반은 즉시 롤백 대상으로 봅니다.</template>
|
||||
</KbxListPage>
|
||||
<KbxDialog :open="dialogOpen" :title="mode==='rollback'?'UX 변경 즉시 롤백':'점진배포 설정'" size="md" @update:open="dialogOpen=$event">
|
||||
<div class="dialog-fields">
|
||||
<template v-if="mode==='rollout'">
|
||||
<KbxNumberField v-model="rolloutPercent" label="배포 비율" suffix="%" :min="0" :max="100" />
|
||||
<KbxSelect v-model="rolloutState" label="상태" :options="[{value:'running',label:'실행'},{value:'paused',label:'일시중지'}]" />
|
||||
</template>
|
||||
<KbxInput v-model="reason" label="변경 사유" required :maxlength="500" :error="error" placeholder="지표·장애·VOC 등 변경 근거를 입력하세요." @enter="apply" />
|
||||
<p v-if="mode==='rollback'">롤백하면 Kill Switch가 활성화되고 모든 사용자는 즉시 Control UX로 돌아갑니다. 기존 Assignment와 Audit 이력은 유지됩니다.</p>
|
||||
</div>
|
||||
<template #footer><KbxButton label="취소" @click="dialogOpen=false"/><KbxButton :label="mode==='rollback'?'즉시 롤백':'적용'" :variant="mode==='rollback'?'danger':'primary'" @click="apply"/></template>
|
||||
</KbxDialog>
|
||||
</template>
|
||||
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.dialog-fields{display:grid;gap:var(--kbx-space-3)}.dialog-fields p{color:var(--kbx-color-text-muted)}</style>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { KbxExperimentOverviewResponse, KbxExperimentRolloutRequest, KbxExperimentRollbackRequest } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const experimentApi={
|
||||
overview:()=>kbxApi.request<KbxExperimentOverviewResponse>('common.experiments.overview'),
|
||||
rollout:(experimentId:string,body:KbxExperimentRolloutRequest)=>kbxApi.request('common.experiments.rollout',{path:{experimentId},body}),
|
||||
rollback:(experimentId:string,body:KbxExperimentRollbackRequest)=>kbxApi.request('common.experiments.rollback',{path:{experimentId},body}),
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const experimentsScreen=defineKbxScreen({
|
||||
id:'COMMON-EXP-001',version:'1.1.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}],
|
||||
})
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<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'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
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,statusMap:{definitions:kbxStatusCatalog.externalData}},
|
||||
{field:'cacheEntries',header:'캐시',type:'integer',width:90},
|
||||
{field:'staleEntries',header:'Stale',type:'integer',width:90},
|
||||
{field:'unavailableEntries',header:'사용불가',type:'integer',width:100},
|
||||
{field:'lastReceivedAt',header:'최근 수신',type:'datetime',width:170},
|
||||
{field:'oldestFreshUntil',header:'최초 만료',type:'datetime',width:170},
|
||||
]
|
||||
async function command(id:string){if(id==='search')await query.refetch();if(id==='refresh'){const row=selected.value[0];if(!row)return;await externalDataApi.refresh(row.datasetId);selected.value=[];await query.refetch()}}
|
||||
</script>
|
||||
<template><KbxListPage :screen="externalDataScreen" :selection-count="selected.length" @command="command">
|
||||
<template #quick-filter><div class="notice"><strong>외부 데이터는 업무 원장이 아닙니다.</strong> 화면에는 정규화된 Projection과 출처·신선도를 함께 표시하고, 만료된 데이터를 최신값처럼 사용하지 않습니다.</div></template>
|
||||
<template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="datasetId" selection="single" :loading="query.isFetching.value" @selection-changed="value=>selected=value" /></template>
|
||||
<template #summary>KRX의 승인 서비스별 TTL은 별도 정책 없이는 재수집할 수 없습니다. OPENDART/KIS의 캐시 시간은 KBX 운영정책이며 공급자 공식 호출한도와 구분합니다.</template>
|
||||
</KbxListPage></template>
|
||||
<style scoped>.notice{padding:var(--kbx-space-2);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}</style>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const externalDataScreen=defineKbxScreen({
|
||||
id:'COMMON-DATA-001',version:'1.1.0',module:'COMMON',type:'list', templateCode:'T01',title:'외부 데이터 상태',
|
||||
description:'KRX·OPENDART·KIS 외부 데이터의 출처·수신시각·신선도·캐시 상태를 확인합니다.',
|
||||
permissions:['common.external-data.read'],helpKey:'COMMON-DATA-001',telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'refresh',label:'재수집',group:'workflow',permission:'common.external-data.refresh',requiresSelection:true,minSelection:1,maxSelection:1},
|
||||
],
|
||||
})
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { KbxExternalDataStatusResponse } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const externalDataApi={
|
||||
status:()=>kbxApi.request<KbxExternalDataStatusResponse>('common.externalData.status'),
|
||||
refresh:(datasetId:string)=>kbxApi.request('common.externalData.refresh',{path:{datasetId}}),
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<template><div aria-hidden="true" /></template>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { KbxRouteNotFound } from '@kbx/ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useKbxWorkspaceStore } from '../../../shell/workspaceStore'
|
||||
const router=useRouter();const store=useKbxWorkspaceStore()
|
||||
function home(){void router.replace('/home')}
|
||||
</script>
|
||||
<template><KbxRouteNotFound @home="home" @menu-search="store.menuSearchOpen=true"/></template>
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxExceptionCenter,
|
||||
KbxSearchPanel,
|
||||
KbxWorkQueuePage,
|
||||
useKbxPageShortcuts,
|
||||
type KbxWorkItem,
|
||||
type KbxWorkItemAction,
|
||||
} from '@kbx/ui'
|
||||
import { operationsApi, type OperationsFilter } from './operationsApi'
|
||||
import { kbxTelemetry } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { operationsColumns, operationsQueueScreen, operationsSearchFields } from './operations.definition'
|
||||
|
||||
const searchModel = ref<Record<string, unknown>>({ status: 'open' })
|
||||
const applied = ref<OperationsFilter>({ status: 'open', page: 1, pageSize: 200 })
|
||||
const selectedRows = ref<KbxWorkItem[]>([])
|
||||
const detail = ref<KbxWorkItem | null>(null)
|
||||
const quickCode = ref<string | null>(null)
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
const actionReceipt=ref<{kind:'claim'|'resolve';requested:number;succeeded:number;skipped:number}|null>(null)
|
||||
const actionReceiptEl=ref<HTMLElement|null>(null)
|
||||
let retryAction:(()=>Promise<void>)|null=null
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['operations', 'work-items', applied.value]),
|
||||
queryFn: () => operationsApi.search(applied.value),
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => query.data.value?.items ?? [])
|
||||
const criticalVisible = computed(() => visibleItems.value.filter(item => item.severity === 'critical' && item.status !== 'resolved').length)
|
||||
const unassignedVisible = computed(() => visibleItems.value.filter(item => !item.ownerId && item.status !== 'resolved').length)
|
||||
const agedVisible = computed(() => visibleItems.value.filter(item => item.ageMinutes >= 60 && item.status !== 'resolved').length)
|
||||
const nextUrgent = computed(() => visibleItems.value.find(item => item.severity === 'critical' && item.status !== 'resolved') ?? null)
|
||||
const pageContext=computed(()=>({
|
||||
label:'예외 업무 Queue',
|
||||
hint:'정상 건은 제외하고 해결이 필요한 업무만 표시합니다.',
|
||||
metrics:[
|
||||
{key:'total',label:'대상',value:query.data.value?.totalCount??0},
|
||||
{key:'critical',label:'표시 긴급',value:criticalVisible.value,tone:'danger' as const,emphasis:criticalVisible.value>0},
|
||||
{key:'unassigned',label:'표시 미지정',value:unassignedVisible.value,tone:'warning' as const},
|
||||
{key:'aged',label:'표시 60분+',value:agedVisible.value,tone:'warning' as const},
|
||||
{key:'selected',label:'선택',value:selectedRows.value.length},
|
||||
],
|
||||
}))
|
||||
|
||||
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
|
||||
|
||||
async function search() {
|
||||
problem.value=null; retryAction=null
|
||||
selectedRows.value = []
|
||||
actionReceipt.value = null
|
||||
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 showMine() {
|
||||
searchModel.value = { ...searchModel.value, owner: 'mine', status: searchModel.value.status || 'open' }
|
||||
await search()
|
||||
}
|
||||
|
||||
function openNextUrgent() {
|
||||
if (nextUrgent.value) detail.value = nextUrgent.value
|
||||
}
|
||||
|
||||
async function runProblemAware(action:()=>Promise<void>){
|
||||
problem.value=null; retryAction=null
|
||||
try{await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return}throw error}
|
||||
}
|
||||
async function executeCommand(id: string) {
|
||||
const ids = selectedRows.value.map(x => x.id)
|
||||
if (id === 'search') return search()
|
||||
if (id === 'claim' && ids.length) return runProblemAware(async()=>{
|
||||
const result=await operationsApi.claim(ids)
|
||||
actionReceipt.value={kind:'claim',requested:ids.length,succeeded:result.claimedCount,skipped:result.skippedCount}
|
||||
selectedRows.value=[]
|
||||
await query.refetch()
|
||||
await nextTick(); actionReceiptEl.value?.focus({preventScroll:true})
|
||||
})
|
||||
}
|
||||
|
||||
async function executeAction(action: KbxWorkItemAction) {
|
||||
if (!detail.value) return
|
||||
const item=detail.value
|
||||
if(action.kind==='navigate'){window.dispatchEvent(new CustomEvent('kbx:navigate-source',{detail:item}));detail.value=null;return}
|
||||
await runProblemAware(async()=>{
|
||||
if (action.kind === 'claim') {
|
||||
const result=await operationsApi.claim([item.id])
|
||||
actionReceipt.value={kind:'claim',requested:1,succeeded:result.claimedCount,skipped:result.skippedCount}
|
||||
}
|
||||
if (action.kind === 'resolve') {
|
||||
const result=await operationsApi.resolve([item.id])
|
||||
actionReceipt.value={kind:'resolve',requested:1,succeeded:result.resolvedCount,skipped:result.rejectedCount}
|
||||
if(result.resolvedCount>0){
|
||||
kbxTelemetry.track('exception.resolved',{screenId:operationsQueueScreen.id,screenVersion:operationsQueueScreen.version,durationMs:Math.max(0,item.ageMinutes*60000),attributes:{exceptionType:item.code,resolutionType:'manual'}})
|
||||
}
|
||||
}
|
||||
if (action.kind === 'retry') await operationsApi.retry(item.id)
|
||||
detail.value = null
|
||||
await query.refetch()
|
||||
if(actionReceipt.value){await nextTick();actionReceiptEl.value?.focus({preventScroll:true})}
|
||||
})
|
||||
}
|
||||
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
|
||||
function dismissProblem(){problem.value=null;retryAction=null}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxWorkQueuePage
|
||||
:screen="operationsQueueScreen"
|
||||
:selection-count="selectedRows.length"
|
||||
:context="pageContext"
|
||||
:content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.totalCount===0?'empty':'ready'"
|
||||
:refreshing="query.isFetching.value&&Boolean(query.data.value)"
|
||||
:problem="problem"
|
||||
queue-title="예외 업무 Queue"
|
||||
queue-description="긴급도·담당자·경과시간을 먼저 확인하고 상세 Drawer에서 원인과 다음 행동을 처리합니다."
|
||||
:queue-count="query.data.value?.totalCount ?? 0"
|
||||
empty-title="처리할 예외 업무가 없습니다."
|
||||
empty-detail="현재 조건에는 사람이 확인해야 할 예외가 없습니다. 조건을 바꾸거나 전체 미처리 업무를 조회하세요."
|
||||
@command="executeCommand"
|
||||
@retry-problem="retryProblem"
|
||||
@dismiss-problem="dismissProblem"
|
||||
>
|
||||
<template #search>
|
||||
<KbxSearchPanel v-model="searchModel" :fields="operationsSearchFields" @search="search" />
|
||||
</template>
|
||||
|
||||
<template #queue-summary>
|
||||
<span class="queue-hint">정상 건은 표시하지 않습니다. 중요도와 경과시간이 높은 예외부터 처리합니다.</span>
|
||||
</template>
|
||||
|
||||
<template #queue-actions>
|
||||
<KbxButton label="내 업무" variant="secondary" @click="showMine" />
|
||||
<KbxButton label="다음 긴급건" variant="secondary" :disabled="!nextUrgent" @click="openNextUrgent" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<section v-if="actionReceipt" ref="actionReceiptEl" class="bulk-receipt" :data-result="actionReceipt.skipped>0?'partial':'success'" role="status" aria-live="polite" tabindex="-1" data-kbx-surface="authoritative-action-receipt">
|
||||
<div>
|
||||
<strong>{{ actionReceipt.kind==='claim'
|
||||
? (actionReceipt.skipped>0 ? '담당 지정이 일부 처리되었습니다.' : '담당 지정을 완료했습니다.')
|
||||
: (actionReceipt.skipped>0 ? '해결 처리가 완료되지 않았습니다.' : '해결 처리를 완료했습니다.') }}</strong>
|
||||
<span v-if="actionReceipt.kind==='claim'">요청 {{ actionReceipt.requested.toLocaleString('ko-KR') }}건 · 내 업무 전환 {{ actionReceipt.succeeded.toLocaleString('ko-KR') }}건 · 건너뜀 {{ actionReceipt.skipped.toLocaleString('ko-KR') }}건</span>
|
||||
<span v-else>요청 {{ actionReceipt.requested.toLocaleString('ko-KR') }}건 · 해결 {{ actionReceipt.succeeded.toLocaleString('ko-KR') }}건 · 거절 {{ actionReceipt.skipped.toLocaleString('ko-KR') }}건</span>
|
||||
<small v-if="actionReceipt.skipped>0">HTTP 성공 여부가 아니라 서버 업무 결과를 기준으로 표시합니다. 최신 상태를 다시 조회해 다음 행동을 결정하세요.</small>
|
||||
</div>
|
||||
<div class="bulk-receipt__actions">
|
||||
<KbxButton v-if="actionReceipt.kind==='claim' && actionReceipt.succeeded>0" label="내 업무 보기" variant="secondary" @click="showMine" />
|
||||
<KbxButton v-if="actionReceipt.skipped>0" label="최신 상태 조회" variant="secondary" @click="search" />
|
||||
<KbxButton label="결과 닫기" variant="ghost" @click="actionReceipt=null" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.bulk-receipt{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);margin-bottom:var(--kbx-space-3);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-success-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);font-size:var(--kbx-font-sm)}
|
||||
.bulk-receipt[data-result="partial"]{border-color:var(--kbx-color-warning-border);border-left-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}
|
||||
.bulk-receipt>div:first-child{display:grid;gap:var(--kbx-space-1)}.bulk-receipt span,.bulk-receipt small{color:var(--kbx-color-text-muted)}.bulk-receipt__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
@media(max-width:60rem){.bulk-receipt{align-items:stretch;flex-direction:column}.bulk-receipt__actions{justify-content:flex-start}}
|
||||
@media(forced-colors:active){.bulk-receipt,.bulk-receipt[data-result="partial"]{border-color:CanvasText;border-left-color:Highlight}}
|
||||
</style>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkItem } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
export const operationsQueueScreen = defineKbxScreen({
|
||||
id: 'COMMON-OPS-001',
|
||||
version: '1.3.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: 92, pinned: 'left', statusMap:{definitions:kbxStatusCatalog.workSeverity} },
|
||||
{ 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: 110, statusMap:{definitions:kbxStatusCatalog.workItem} },
|
||||
]
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { KbxWorkQueueResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ClaimWorkItemsResponse {
|
||||
claimedCount: number
|
||||
skippedCount: number
|
||||
}
|
||||
|
||||
export interface ResolveWorkItemsResponse {
|
||||
resolvedCount: number
|
||||
rejectedCount: number
|
||||
}
|
||||
|
||||
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<ClaimWorkItemsResponse, { ids: string[] }>('common.operations.claim', { body: { ids } })
|
||||
},
|
||||
resolve(ids: string[], reason = '사용자 확인 완료') {
|
||||
return kbxApi.request<ResolveWorkItemsResponse, { ids: string[]; reason: string }>('common.operations.resolve', { body: { ids, reason } })
|
||||
},
|
||||
retry(id: string) {
|
||||
return kbxApi.request<unknown>('common.operations.retry', { path: { id } })
|
||||
},
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const operationsRoutes = [
|
||||
{ path: '/operations/exceptions', name: 'common-operations-exceptions', component: () => import('./OperationsQueuePage.vue') },
|
||||
]
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, ref } from 'vue'
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDrawer,
|
||||
KbxPermissionHostKey,
|
||||
KbxQuickFilterBar,
|
||||
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 detail = ref<KbxReconcileItem | null>(null)
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
const exceptionReceipt=ref<{requested:number;created:number;skipped:number}|null>(null)
|
||||
const exceptionReceiptEl=ref<HTMLElement|null>(null)
|
||||
const router=useRouter()
|
||||
let retryAction:(()=>Promise<void>)|null=null
|
||||
const permissionHost=inject(KbxPermissionHostKey,null)
|
||||
const canCreateException=computed(()=>permissionHost?.has('common.operations.create')??true)
|
||||
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}]}))
|
||||
const quickFilters=computed(()=>{
|
||||
const summary=query.data.value?.summary
|
||||
const active=String(searchModel.value.status??'')
|
||||
return [
|
||||
{key:'all',label:'전체',count:summary?.totalCount??0,active:active===''},
|
||||
{key:'mismatch',label:'불일치',count:summary?.mismatchCount??0,active:active==='mismatch',tone:'danger' as const},
|
||||
{key:'pending',label:'확인중',count:summary?.pendingCount??0,active:active==='pending',tone:'warning' as const},
|
||||
{key:'resolved',label:'해결',count:summary?.resolvedCount??0,active:active==='resolved'},
|
||||
]
|
||||
})
|
||||
|
||||
useKbxPageShortcuts([{ key: 'F3', execute: () => search() }])
|
||||
|
||||
function stringOrNull(value: unknown) { return value == null || value === '' ? null : String(value) }
|
||||
async function search() {
|
||||
problem.value=null; retryAction=null
|
||||
selectedRows.value = []
|
||||
detail.value = null
|
||||
exceptionReceipt.value = null
|
||||
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 selectResultFilter(key:string){
|
||||
searchModel.value={...searchModel.value,status:key==='all'?null:key}
|
||||
await search()
|
||||
}
|
||||
async function runProblemAware(action:()=>Promise<void>){problem.value=null;retryAction=null;try{await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return}throw error}}
|
||||
async function createExceptions(ids:string[]){
|
||||
if(!ids.length)return
|
||||
return runProblemAware(async()=>{
|
||||
const result=await reconcileApi.createExceptions(ids)
|
||||
exceptionReceipt.value={requested:ids.length,created:result.createdOrUpdatedCount,skipped:result.skippedCount}
|
||||
selectedRows.value=[]
|
||||
detail.value=null
|
||||
await query.refetch()
|
||||
await nextTick();exceptionReceiptEl.value?.focus({preventScroll:true})
|
||||
})
|
||||
}
|
||||
async function command(id: string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'createException' && selectedRows.value.length) return createExceptions(selectedRows.value.map(x=>x.id))
|
||||
}
|
||||
async function createDetailException(){if(detail.value&&canCreateException.value)await createExceptions([detail.value.id])}
|
||||
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
|
||||
function dismissProblem(){problem.value=null;retryAction=null}
|
||||
async function openExceptionCenter(){await router.push('/operations/exceptions')}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxReconcilePage
|
||||
:screen="reconcileScreen"
|
||||
:summary="query.data.value?.summary"
|
||||
:selection-count="selectedRows.length"
|
||||
:context="pageContext"
|
||||
:content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':query.data.value?.summary.totalCount===0?'empty':'ready'"
|
||||
:refreshing="query.isFetching.value&&Boolean(query.data.value)"
|
||||
:problem="problem"
|
||||
comparison-title="OMS ↔ WMS 대사"
|
||||
comparison-description="Expected · Actual · Difference · Reason · Resolution 순서로 불일치 근거를 확인합니다. 행을 두 번 클릭하면 근거를 유지한 채 상세를 확인합니다."
|
||||
:comparison-count="query.data.value?.summary.totalCount ?? 0"
|
||||
@command="command"
|
||||
@retry-problem="retryProblem"
|
||||
@dismiss-problem="dismissProblem"
|
||||
>
|
||||
<template #search><KbxSearchPanel v-model="searchModel" :fields="reconcileSearchFields" @search="search" /></template>
|
||||
<template #filters><KbxQuickFilterBar :items="quickFilters" aria-label="대사 결과 빠른 필터" @select="selectResultFilter" /></template>
|
||||
<template #comparison-actions><KbxButton label="선택건 예외 등록" variant="secondary" :disabled="selectedRows.length===0 || !canCreateException" :title="!canCreateException?'예외 등록 권한이 없습니다.':undefined" @click="command('createException')" /></template>
|
||||
<template #content>
|
||||
<section v-if="exceptionReceipt" ref="exceptionReceiptEl" class="reconcile-receipt" :data-result="exceptionReceipt.skipped>0?'partial':'success'" role="status" aria-live="polite" tabindex="-1" data-kbx-surface="reconcile-authoritative-receipt">
|
||||
<div>
|
||||
<strong>{{ exceptionReceipt.skipped>0 ? '예외 업무 등록이 일부 처리되었습니다.' : '예외 업무 등록을 완료했습니다.' }}</strong>
|
||||
<span>요청 {{ exceptionReceipt.requested.toLocaleString('ko-KR') }}건 · 생성/갱신 {{ exceptionReceipt.created.toLocaleString('ko-KR') }}건 · 건너뜀 {{ exceptionReceipt.skipped.toLocaleString('ko-KR') }}건</span>
|
||||
<small v-if="exceptionReceipt.skipped>0">건너뜀은 실행 시점에 더 이상 불일치/확인중 상태가 아니었던 건입니다. 서버의 최신 대사 상태를 기준으로 다음 행동을 결정하세요.</small>
|
||||
</div>
|
||||
<div class="reconcile-receipt__actions">
|
||||
<KbxButton v-if="exceptionReceipt.created>0" label="예외 센터 보기" variant="secondary" @click="openExceptionCenter" />
|
||||
<KbxButton v-if="exceptionReceipt.skipped>0" label="최신 대사 조회" variant="secondary" @click="search" />
|
||||
<KbxButton label="결과 닫기" variant="ghost" @click="exceptionReceipt=null" />
|
||||
</div>
|
||||
</section>
|
||||
<KbxDataGrid
|
||||
:rows="query.data.value?.items ?? []"
|
||||
:columns="reconcileColumns"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:loading="query.isFetching.value"
|
||||
@selection-changed="selectedRows = $event"
|
||||
@row-double-clicked="detail = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDrawer :open="Boolean(detail)" :title="detail ? `대사 근거 · ${detail.referenceNo}` : '대사 근거'" @update:open="value=>{if(!value)detail=null}">
|
||||
<article v-if="detail" class="reconcile-detail" :data-status="detail.status">
|
||||
<header>
|
||||
<span>{{ detail.reconcileType }}</span>
|
||||
<strong>{{ detail.status==='mismatch'?'불일치':detail.status==='pending'?'확인중':detail.status==='resolved'?'해결':'정상' }}</strong>
|
||||
</header>
|
||||
<section class="reconcile-detail__comparison" aria-label="대사 비교 근거">
|
||||
<div><span>{{ detail.sourceLabel }}</span><small>Expected</small><strong>{{ detail.expectedValue }}</strong></div>
|
||||
<div class="difference"><span>Difference</span><small>차이</small><strong>{{ detail.differenceValue ?? '-' }}</strong></div>
|
||||
<div><span>{{ detail.targetLabel }}</span><small>Actual</small><strong>{{ detail.actualValue }}</strong></div>
|
||||
</section>
|
||||
<dl>
|
||||
<div><dt>참조번호</dt><dd>{{ detail.referenceNo }}</dd></div>
|
||||
<div><dt>원인</dt><dd>{{ detail.reasonText || '원인 미확인' }}</dd></div>
|
||||
<div><dt>확인시각</dt><dd>{{ detail.occurredAt }}</dd></div>
|
||||
<div><dt>처리상태</dt><dd>{{ detail.status }}</dd></div>
|
||||
</dl>
|
||||
<p class="reconcile-detail__policy">원천값을 화면에서 직접 덮어쓰지 않습니다. 불일치는 예외 업무로 등록한 뒤 담당자가 근거와 업무 규칙을 확인합니다.</p>
|
||||
</article>
|
||||
<template #footer>
|
||||
<div class="reconcile-detail__actions">
|
||||
<KbxButton label="닫기" variant="secondary" @click="detail=null" />
|
||||
<KbxButton v-if="detail && ['mismatch','pending'].includes(detail.status)" label="이 건 예외 등록" variant="primary" :disabled="!canCreateException" :title="!canCreateException?'예외 등록 권한이 없습니다.':undefined" @click="createDetailException" />
|
||||
</div>
|
||||
</template>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
<template #footer>불일치 건은 원천 데이터를 직접 수정하지 않고 예외 업무로 전환해 담당자가 원인을 확인합니다.</template>
|
||||
</KbxReconcilePage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reconcile-receipt{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);margin-bottom:var(--kbx-space-3);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-success-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);font-size:var(--kbx-font-sm)}.reconcile-receipt[data-result="partial"]{border-color:var(--kbx-color-warning-border);border-left-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.reconcile-receipt>div:first-child{display:grid;gap:var(--kbx-space-1)}.reconcile-receipt span,.reconcile-receipt small{color:var(--kbx-color-text-muted)}.reconcile-receipt__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
.reconcile-detail{display:grid;gap:var(--kbx-space-4)}.reconcile-detail>header{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.reconcile-detail[data-status="mismatch"]>header{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.reconcile-detail__comparison{display:grid;grid-template-columns:minmax(0,1fr) minmax(var(--kbx-summary-item-min-width),.7fr) minmax(0,1fr);border:var(--kbx-border-width) solid var(--kbx-color-border)}.reconcile-detail__comparison>div{display:grid;gap:var(--kbx-space-1);padding:var(--kbx-space-3);border-right:var(--kbx-border-width) solid var(--kbx-color-border)}.reconcile-detail__comparison>div:last-child{border-right:0}.reconcile-detail__comparison span,.reconcile-detail__comparison small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.reconcile-detail__comparison strong{font-size:var(--kbx-font-xl)}.reconcile-detail__comparison .difference strong{color:var(--kbx-color-danger)}.reconcile-detail dl{display:grid;gap:var(--kbx-space-2);margin:0}.reconcile-detail dl div{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2)}.reconcile-detail dt{color:var(--kbx-color-text-muted)}.reconcile-detail dd{margin:0}.reconcile-detail__policy{margin:0;padding:var(--kbx-space-3);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.reconcile-detail__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
@media(max-width:60rem){.reconcile-receipt{align-items:stretch;flex-direction:column}.reconcile-receipt__actions{justify-content:flex-start}}
|
||||
@media(max-width:40rem){.reconcile-detail__comparison{grid-template-columns:1fr}.reconcile-detail__comparison>div{border-right:0;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.reconcile-detail__comparison>div:last-child{border-bottom:0}}
|
||||
@media(forced-colors:active){.reconcile-receipt,.reconcile-receipt[data-result="partial"]{border-color:CanvasText;border-left-color:Highlight}}
|
||||
</style>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxReconcileItem, type KbxSearchField } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
export const reconcileScreen = defineKbxScreen({
|
||||
id: 'COMMON-REC-001',
|
||||
version: '1.3.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: 110, statusMap:{definitions:kbxStatusCatalog.reconcile} },
|
||||
{ field: 'occurredAt', header: '확인시각', type: 'datetime', width: 160 },
|
||||
]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { KbxReconcileResult } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface CreateReconcileExceptionsResponse {
|
||||
createdOrUpdatedCount: number
|
||||
skippedCount: number
|
||||
}
|
||||
|
||||
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<CreateReconcileExceptionsResponse, { ids: string[] }>('common.reconcile.createExceptions', { body: { ids } })
|
||||
},
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const reconcileRoutes = [
|
||||
{ path: '/operations/reconcile', name: 'common-reconcile', component: () => import('./ReconcilePage.vue') },
|
||||
]
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } 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=ref({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.value,screenId:search.value.screenId||undefined}),enabled:false})
|
||||
const columns:KbxGridColumn<KbxUxMetricRow>[]=[
|
||||
{field:'label',header:'지표',width:240},{field:'value',header:'현재값',type:'decimal',width:140},{field:'unit',header:'단위',width:100},{field:'sampleCount',header:'표본',type:'integer',width:110},{field:'p50',header:'P50',type:'decimal',width:110},{field:'p95',header:'P95',type:'decimal',width:110},
|
||||
]
|
||||
async function command(id:string){if(id==='search')await query.refetch()}
|
||||
</script>
|
||||
<template>
|
||||
<KbxListPage :screen="uxMetricsScreen" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.metrics.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="search" :fields="fields" @search="query.refetch()" /></template>
|
||||
<template #quick-filter>
|
||||
<div class="notice"><strong>해석 기준</strong> — DOM 전체 클릭이나 검색어 원문을 수집하지 않습니다. 낮은 수치 자체보다 업무 위험·오류 감소와 함께 판단합니다.</div>
|
||||
</template>
|
||||
<template #content><KbxDataGrid :rows="query.data.value?.metrics ?? []" :columns="columns" row-key="metricKey" :loading="query.isFetching.value" /></template>
|
||||
<template #summary>{{ search.from }} ~ {{ search.to }} · Manual Intervention Rate를 자동화 개선의 핵심 추세로 봅니다.</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
<style scoped>.notice{padding:8px 12px;border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:13px}</style>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const uxMetricsScreen=defineKbxScreen({
|
||||
id:'COMMON-UX-001',version:'1.0.0',module:'COMMON',type:'list', templateCode:'T01',title:'UX 품질 지표',
|
||||
description:'KBX 의미 이벤트와 업무 결과를 이용해 수작업 개입·업무시간·오류율을 검토합니다.',
|
||||
permissions:['common.ux.read'],helpKey:'COMMON-UX-001',telemetry:{enabled:true},
|
||||
commands:[{id:'search',label:'조회',group:'query',shortcut:'F3'}],
|
||||
})
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { KbxUxMetricsResponse } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
export const uxMetricsApi={
|
||||
get:(query:{from:string;to:string;screenId?:string})=>kbxApi.request<KbxUxMetricsResponse>('common.uxTelemetry.metrics',{query}),
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import type { KbxLookupItem, KbxValidationError } from '@kbx/contracts'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDateField,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxLookup,
|
||||
KbxLookupDialog,
|
||||
KbxTransactionPage,
|
||||
KbxUnsavedChangesDialog,
|
||||
KbxWorkflowBar,
|
||||
useKbxDirtyState,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
import { lookupRegistry } from '../../../lookups/lookupRegistry'
|
||||
import { inventoryMoveColumns, inventoryMoveScreen, inventoryMoveWorkflow, type InventoryMoveLine } from './inventory-move.definition'
|
||||
|
||||
function today(){const value=new Date();return `${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`}
|
||||
function newLine():InventoryMoveLine{return{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',availableQty:0,moveQty:1,lotNo:'',remark:''}}
|
||||
|
||||
const status=ref('DRAFT')
|
||||
const header=reactive({moveDate:today(),fromWarehouseId:null as string|null,toWarehouseId:null as string|null})
|
||||
const lines=ref<InventoryMoveLine[]>([newLine()])
|
||||
const errors=ref<KbxValidationError[]>([])
|
||||
const pageNotice=ref('')
|
||||
const detailGrid=ref<any>(null)
|
||||
const itemLookupOpen=ref(false)
|
||||
const activeLine=ref<InventoryMoveLine|null>(null)
|
||||
const activeLookupField=ref<keyof InventoryMoveLine & string>('itemCode')
|
||||
const pendingReset=ref(false)
|
||||
const {dirty,touch,reset:resetDirty}=useKbxDirtyState()
|
||||
useKbxWorkspaceBinding(dirty,async()=>false)
|
||||
|
||||
const readonly=computed(()=>status.value!=='DRAFT')
|
||||
const totalQty=computed(()=>lines.value.reduce((sum,line)=>sum+Number(line.moveQty||0),0))
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'품목',value:`${lines.value.length.toLocaleString('ko-KR')}종`},
|
||||
{key:'qty',label:'총 이동수량',value:totalQty.value,emphasis:true},
|
||||
])
|
||||
const pageContext=computed(()=>({
|
||||
label:'신규 재고이동',
|
||||
hint:dirty.value?'저장하지 않은 입력이 있습니다.':'작성 상태 · 서버 반영 전',
|
||||
metrics:[
|
||||
{key:'status',label:'상태',value:'작성'},
|
||||
{key:'lines',label:'품목',value:lines.value.length},
|
||||
{key:'errors',label:'오류',value:errors.value.length,tone:'danger' as const,emphasis:errors.value.length>0},
|
||||
],
|
||||
}))
|
||||
|
||||
function fieldError(field:string){return errors.value.find(error=>!error.rowKey&&error.field===field)?.message}
|
||||
function clearError(rowKey:string|undefined,field:string){errors.value=errors.value.filter(error=>!(error.rowKey===rowKey&&error.field===field))}
|
||||
function addLine(){if(readonly.value)return;lines.value.push(newLine());touch()}
|
||||
async function addLineAndFocus(){addLine();const line=lines.value.at(-1);if(!line)return;await nextTick();detailGrid.value?.focusCell?.(line.clientId,'itemCode')}
|
||||
function duplicateLines(selected:InventoryMoveLine[]){if(readonly.value||!selected.length)return;lines.value.push(...selected.map(line=>({...line,clientId:crypto.randomUUID()})));touch()}
|
||||
|
||||
function validateDraft(){
|
||||
const next:KbxValidationError[]=[]
|
||||
if(!header.fromWarehouseId)next.push({field:'fromWarehouseId',code:'FROM_WAREHOUSE_REQUIRED',message:'출발창고를 선택하세요.'})
|
||||
if(!header.toWarehouseId)next.push({field:'toWarehouseId',code:'TO_WAREHOUSE_REQUIRED',message:'도착창고를 선택하세요.'})
|
||||
if(header.fromWarehouseId&&header.fromWarehouseId===header.toWarehouseId)next.push({field:'toWarehouseId',code:'WAREHOUSE_MUST_DIFFER',message:'도착창고는 출발창고와 달라야 합니다.'})
|
||||
if(!lines.value.length)next.push({code:'MOVE_LINE_REQUIRED',message:'이동 품목을 1건 이상 입력하세요.'})
|
||||
for(const line of lines.value){
|
||||
if(!line.itemId)next.push({rowKey:line.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(Number(line.moveQty)<=0)next.push({rowKey:line.clientId,field:'moveQty',code:'MOVE_QTY_POSITIVE',message:'이동수량은 0보다 커야 합니다.'})
|
||||
if(Number(line.availableQty)>0&&Number(line.moveQty)>Number(line.availableQty))next.push({rowKey:line.clientId,field:'moveQty',code:'INSUFFICIENT_STOCK',message:`가용재고는 ${Number(line.availableQty).toLocaleString('ko-KR')}개입니다.`})
|
||||
}
|
||||
errors.value=next
|
||||
return next.length===0
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:InventoryMoveLine;field:keyof InventoryMoveLine & string;newValue:unknown}){
|
||||
clearError(event.row.clientId,event.field)
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code)Object.assign(event.row,{itemId:null,itemName:'',availableQty:0})
|
||||
else{
|
||||
const item=await lookupRegistry.item.resolveByCode(code)
|
||||
if(item)Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName,availableQty:Number(item.metadata?.availableQty??0)})
|
||||
else{
|
||||
Object.assign(event.row,{itemId:null,itemName:'',availableQty:0})
|
||||
errors.value.push({rowKey:event.row.clientId,field:'itemCode',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})
|
||||
}
|
||||
}
|
||||
}
|
||||
touch()
|
||||
}
|
||||
function openGridLookup(event:{row:InventoryMoveLine;field:keyof InventoryMoveLine & string;entity:string}){if(readonly.value||event.entity!=='item')return;activeLine.value=event.row;activeLookupField.value=event.field;itemLookupOpen.value=true}
|
||||
async function selectGridItem(item:KbxLookupItem<string>){if(!activeLine.value)return;Object.assign(activeLine.value,{itemId:item.id,itemCode:item.code,itemName:item.displayName,availableQty:Number(item.metadata?.availableQty??0)});clearError(activeLine.value.clientId,'itemCode');touch();itemLookupOpen.value=false;await nextTick();detailGrid.value?.focusNextEditableCell?.(activeLine.value.clientId,activeLookupField.value)}
|
||||
|
||||
function resetDraft(){status.value='DRAFT';Object.assign(header,{moveDate:today(),fromWarehouseId:null,toWarehouseId:null});lines.value=[newLine()];errors.value=[];pageNotice.value='';pendingReset.value=false;resetDirty()}
|
||||
function requestNew(){if(dirty.value){pendingReset.value=true;return}resetDraft()}
|
||||
function saveDraft(){pageNotice.value='';if(!validateDraft()){pageNotice.value='저장할 수 없습니다. 입력 오류를 확인하세요.';nextTick(()=>detailGrid.value?.focusError?.(1));return false}pageNotice.value='저장 Command가 아직 서버에 연결되지 않아 입력값을 반영하지 않았습니다. 화면 초안은 유지됩니다.';return false}
|
||||
function executeWorkflow(id:string){if(dirty.value){pageNotice.value='상태 변경 전에 먼저 저장해야 합니다.';return}pageNotice.value=`${inventoryMoveWorkflow.transitions.find(item=>item.id===id)?.label??'상태 변경'} Command가 서버에 연결되지 않아 상태를 변경하지 않았습니다.`}
|
||||
function command(id:string){if(id==='new')return requestNew();if(id==='save')return saveDraft();if(['confirm','ship','receive'].includes(id))return executeWorkflow(id);if(id==='excel')pageNotice.value='엑셀 기능은 공통 Import/Export 계약이 연결된 뒤 활성화합니다.'}
|
||||
function focusNextError(){detailGrid.value?.focusError?.(1)}
|
||||
useKbxPageShortcuts([{key:'F8',execute:saveDraft}])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxTransactionPage
|
||||
:screen="inventoryMoveScreen"
|
||||
:status="status"
|
||||
:dirty="dirty"
|
||||
:errors="errors"
|
||||
:workflow="inventoryMoveWorkflow"
|
||||
:summary-items="summaryItems"
|
||||
:context="pageContext"
|
||||
header-title="이동 정보"
|
||||
header-description="출발/도착 창고와 이동일을 먼저 확인합니다. 동일 창고 이동은 입력 단계에서 차단합니다."
|
||||
detail-title="이동 품목"
|
||||
:detail-count="lines.length"
|
||||
detail-count-unit="종"
|
||||
detail-description="가용재고와 이동수량을 비교하며 입력하고, F2로 품목을 조회합니다."
|
||||
breadcrumb="ERP > 재고"
|
||||
@command="command"
|
||||
@transition="executeWorkflow"
|
||||
>
|
||||
<template #notice><div v-if="pageNotice" class="page-notice" role="status">{{pageNotice}}</div></template>
|
||||
<template #header>
|
||||
<KbxWorkflowBar :workflow="inventoryMoveWorkflow" :current="status" @transition="executeWorkflow" />
|
||||
<KbxFormSection title="이동정보">
|
||||
<KbxFormGrid>
|
||||
<KbxDateField v-model="header.moveDate" label="이동일" required :readonly="readonly" @update:model-value="touch" />
|
||||
<KbxLookup v-model="header.fromWarehouseId" entity="warehouse" label="출발창고" required :readonly="readonly" :error="fieldError('fromWarehouseId')" @selected="()=>{clearError(undefined,'fromWarehouseId');touch()}" />
|
||||
<KbxLookup v-model="header.toWarehouseId" entity="warehouse" label="도착창고" required :readonly="readonly" :error="fieldError('toWarehouseId')" @selected="()=>{clearError(undefined,'toWarehouseId');touch()}" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
|
||||
<template #detail-actions>
|
||||
<KbxButton v-if="errors.length" label="첫/다음 오류" variant="ghost" @click="focusNextError" />
|
||||
<KbxButton v-if="!readonly" label="행 추가" variant="secondary" @click="addLineAndFocus" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid
|
||||
ref="detailGrid"
|
||||
:rows="lines"
|
||||
:columns="inventoryMoveColumns"
|
||||
row-key="clientId"
|
||||
aria-label="재고이동 품목"
|
||||
selection="multiple"
|
||||
:errors="errors"
|
||||
:editable="!readonly"
|
||||
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
|
||||
@row-add-requested="addLine"
|
||||
@row-duplicate-requested="duplicateLines"
|
||||
@cell-changed="onCellChanged"
|
||||
@lookup-requested="openGridLookup"
|
||||
/>
|
||||
</template>
|
||||
</KbxTransactionPage>
|
||||
|
||||
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem" />
|
||||
<KbxUnsavedChangesDialog :open="pendingReset" :can-save="false" @stay="pendingReset=false" @discard="resetDraft" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-notice{padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
export interface InventoryMoveLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number; moveQty:number; lotNo:string; remark:string }
|
||||
export const inventoryMoveScreen=defineKbxScreen({ id:'ERP-INV-MOVE-001',version:'1.0.0',module:'ERP',type:'transaction', templateCode:'T03',title:'재고이동',helpKey:'ERP-INV-MOVE-001',permissions:['erp.inventory.move.read'],telemetry:{enabled:true}, description:'창고 간 재고이동을 등록하고 출고·입고 상태를 추적합니다.', commands:[
|
||||
{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.inventory.move.write'},{id:'confirm',label:'이동확정',group:'workflow',variant:'primary',permission:'erp.inventory.move.confirm'},{id:'excel',label:'엑셀',group:'output'}] })
|
||||
export const inventoryMoveColumns:KbxGridColumn<InventoryMoveLine>[]=[
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'},{field:'itemName',header:'품목명',width:200},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'moveQty',header:'이동수량',type:'quantity',width:100,editable:true},{field:'lotNo',header:'LOT',type:'code',width:120,editable:true},{field:'remark',header:'비고',width:220,editable:true}]
|
||||
export const inventoryMoveWorkflow:KbxWorkflowDefinition={id:'erp.inventory-move',version:'1.0.0',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'CONFIRMED',label:'확정',semantic:'pending'},{value:'IN_TRANSIT',label:'이동중',semantic:'processing'},{value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true},{value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}],transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'이동확정',permission:'erp.inventory.move.confirm',confirm:true},{id:'ship',from:['CONFIRMED'],to:'IN_TRANSIT',label:'출고',permission:'erp.inventory.move.ship'},{id:'receive',from:['IN_TRANSIT'],to:'RECEIVED',label:'입고완료',permission:'erp.inventory.move.receive',confirm:true}]}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
|
||||
]
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { inventoryApi } from './inventoryApi'
|
||||
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
|
||||
|
||||
const appliedKeyword=ref('')
|
||||
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
|
||||
const selectedItemId=ref<string|null>(null)
|
||||
const selectedItem=ref<InventoryItemRow|null>(null)
|
||||
const breakdownOpen=ref(false)
|
||||
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
|
||||
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
|
||||
|
||||
async function search(){
|
||||
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
|
||||
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
|
||||
}
|
||||
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
|
||||
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
|
||||
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
|
||||
function command(id:string){if(id==='search')return search()}
|
||||
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
|
||||
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
|
||||
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
|
||||
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
|
||||
])
|
||||
const drawerSummary=computed(()=>selectedItem.value?[
|
||||
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
|
||||
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
|
||||
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
|
||||
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
|
||||
]:[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" master-description="품목을 선택하면 우측 위치와 하단 이력이 같은 Context로 갱신됩니다." :master-count="itemsQuery.data.value?.totalCount ?? 0" master-count-unit="개" detail-description="선택 품목의 창고·로케이션별 현재 재고입니다." :detail-count="locationsQuery.data.value?.items?.length ?? 0" detail-count-unit="개" bottom-description="선택 품목의 입출고·조정 근거입니다." :bottom-count="historyQuery.data.value?.items?.length ?? 0" bottom-count-unit="건" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
|
||||
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
|
||||
<template #master>
|
||||
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
|
||||
</template>
|
||||
<template #bottom>
|
||||
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
|
||||
</template>
|
||||
</KbxMasterDetailPage>
|
||||
|
||||
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
|
||||
<div v-if="selectedItem" class="inventory-breakdown">
|
||||
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
|
||||
<KbxSummaryBar :items="drawerSummary" />
|
||||
<dl><dt>현재고</dt><dd>물리적으로 기록된 총 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 수 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 수 있는 수량</dd></dl>
|
||||
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
|
||||
</div>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
|
||||
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
|
||||
</style>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
|
||||
|
||||
export interface InventoryItemRow {
|
||||
itemId: string; itemCode: string; itemName: string; specification: string
|
||||
totalQty: number; availableQty: number; allocatedQty: number; holdQty: number
|
||||
}
|
||||
export interface InventoryLocationRow {
|
||||
key: string; warehouseName: string; locationCode: string
|
||||
onHandQty: number; allocatedQty: number; availableQty: number; holdQty: number
|
||||
}
|
||||
export interface InventoryHistoryRow {
|
||||
entryId: string; occurredAt: string; businessType: string; referenceNo: string
|
||||
warehouseName: string; locationCode: string; inboundQty: number; outboundQty: number; balanceQty: number; actor: string
|
||||
}
|
||||
|
||||
export const inventoryScreen = defineKbxScreen({
|
||||
id: 'ERP-INV-001', version: '1.1.0', module: 'ERP', type: 'master-detail', templateCode:'T05', title: '재고현황',
|
||||
description: '품목 현재고에서 창고·로케이션 근거와 재고이력까지 조회 Context를 유지해 탐색합니다.',
|
||||
helpKey: 'ERP-INV-001', permissions: ['erp.inventory.read'], telemetry: { enabled: true },
|
||||
commands: [{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' }, { id: 'excel', label: '엑셀', group: 'output' }],
|
||||
})
|
||||
|
||||
export const inventoryItemColumns: KbxGridColumn<InventoryItemRow>[] = [
|
||||
{ field: 'itemCode', header: '품목코드', type: 'code', width: 120, pinned: 'left' },
|
||||
{ field: 'itemName', header: '품목명', width: 190 },
|
||||
{ field: 'specification', header: '규격', width: 140 },
|
||||
{ field: 'totalQty', header: '현재고', type: 'quantity', width: 100, drilldown:true },
|
||||
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100, drilldown:true },
|
||||
]
|
||||
export const inventoryLocationColumns: KbxGridColumn<InventoryLocationRow>[] = [
|
||||
{ field: 'warehouseName', header: '창고', width: 150 },
|
||||
{ field: 'locationCode', header: '로케이션', type: 'code', width: 120 },
|
||||
{ field: 'onHandQty', header: '현재고', type: 'quantity', width: 100 },
|
||||
{ field: 'allocatedQty', header: '할당', type: 'quantity', width: 90 },
|
||||
{ field: 'holdQty', header: '보류', type: 'quantity', width: 90 },
|
||||
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100 },
|
||||
]
|
||||
export const inventoryHistoryColumns: KbxGridColumn<InventoryHistoryRow>[] = [
|
||||
{ field:'occurredAt', header:'일시', type:'datetime', width:165 },
|
||||
{ field:'businessType', header:'업무', width:110 },
|
||||
{ field:'referenceNo', header:'참조번호', type:'code', width:150 },
|
||||
{ field:'warehouseName', header:'창고', width:130 },
|
||||
{ field:'locationCode', header:'로케이션', type:'code', width:110 },
|
||||
{ field:'inboundQty', header:'입고', type:'quantity', width:90 },
|
||||
{ field:'outboundQty', header:'출고', type:'quantity', width:90 },
|
||||
{ field:'balanceQty', header:'잔량', type:'quantity', width:100 },
|
||||
{ field:'actor', header:'처리자', width:110 },
|
||||
]
|
||||
export const inventorySearchFields: KbxSearchField[] = [{ key: 'keyword', label: '품목', type: 'text', width: 'lg', placeholder: '품목코드/품목명' }]
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { InventoryHistoryRow, InventoryItemRow, InventoryLocationRow } from './inventory.definition'
|
||||
export interface InventorySearchResponse { items: InventoryItemRow[]; totalCount: number }
|
||||
export const inventoryApi = {
|
||||
search(keyword = '') { return kbxApi.request<InventorySearchResponse>('erp.inventory.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
locations(itemId: string) { return kbxApi.request<{ items: InventoryLocationRow[] }>('erp.inventory.locations', { path: { itemId } }) },
|
||||
history(itemId: string) { return kbxApi.request<{ items: InventoryHistoryRow[] }>('erp.inventory.history', { path: { itemId }, query:{ pageSize:100 } }) },
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KbxLookupItem } from '@kbx/contracts'
|
||||
import { KbxDataGrid, KbxFastEntryPage, KbxLookupDialog, useKbxPageShortcuts } from '@kbx/ui'
|
||||
import { itemPriceColumns, itemPriceScreen, type ItemPriceRow } from './item-price.definition'
|
||||
import { useItemPriceFastEntry } from './useItemPriceFastEntry'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
|
||||
const vm=useItemPriceFastEntry()
|
||||
useKbxWorkspaceBinding(vm.dirty, async()=>await vm.save())
|
||||
const lookupOpen=ref(false); const activeRow=ref<ItemPriceRow|null>(null)
|
||||
function openLookup(event:{row:ItemPriceRow;entity:string}){ if(event.entity!=='item')return;activeRow.value=event.row;lookupOpen.value=true }
|
||||
function selectItem(item:KbxLookupItem<string>){ if(activeRow.value)vm.applyItemLookup(activeRow.value,item) }
|
||||
const pageContext=computed(()=>({label:'품목 단가 입력',hint:'저장 전 오류를 모두 해소한 뒤 F8로 일괄 저장합니다.',metrics:[{key:'rows',label:'입력',value:vm.enteredCount.value},{key:'errors',label:'오류',value:vm.errors.value.length,tone:vm.errors.value.length?'danger' as const:'default' as const,emphasis:vm.errors.value.length>0}]}))
|
||||
const summary=computed(()=>[
|
||||
{key:'rows',label:'입력',value:`${vm.enteredCount.value.toLocaleString('ko-KR')}건`},
|
||||
{key:'errors',label:'오류',value:`${vm.errors.value.length.toLocaleString('ko-KR')}건`},
|
||||
...(vm.lastResult.value?[{key:'saved',label:'최근 저장',value:`${vm.lastResult.value.saved.toLocaleString('ko-KR')}건`,emphasis:true}]:[]),
|
||||
])
|
||||
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
|
||||
async function command(id:string){ if(id==='save')await vm.save(); if(id==='new')vm.clear() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxFastEntryPage :screen="itemPriceScreen" :selection-count="0" :dirty="vm.dirty.value" :context="pageContext" :errors="vm.errors.value" :summary-items="summary" :problem="vm.problem.value" grid-title="품목 단가 입력" grid-description="품목·단가를 연속 입력하고 오류 셀을 해소한 뒤 F8로 저장합니다." :row-count="vm.enteredCount.value" row-count-unit="건" breadcrumb="ERP > 기준정보" @command="command" @retry-problem="vm.retryProblem" @dismiss-problem="vm.dismissProblem">
|
||||
<template #guide>
|
||||
<span><kbd>Enter</kbd> 다음 셀</span><span><kbd>F2</kbd> 품목 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span>행 선택 후 아래 채우기/복제</span>
|
||||
</template>
|
||||
<template #notice>
|
||||
<div v-if="vm.lastResult.value" class="price-save-result" role="status">저장 {{vm.lastResult.value.saved.toLocaleString('ko-KR')}}건 · 신규 {{vm.lastResult.value.created.toLocaleString('ko-KR')}} · 수정 {{vm.lastResult.value.updated.toLocaleString('ko-KR')}}</div>
|
||||
</template>
|
||||
<template #grid-actions><span class="grid-action-status">오류 {{vm.errors.value.length.toLocaleString('ko-KR')}}건</span></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>
|
||||
.grid-action-status{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);white-space:nowrap}.price-save-result{min-height:var(--kbx-control-sm);display:flex;align-items:center;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);color:var(--kbx-color-success-text);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { defineKbxScreen, type KbxGridColumn } from '@kbx/ui'
|
||||
|
||||
export interface ItemPriceRow {
|
||||
clientId: string
|
||||
itemId: string | null
|
||||
itemCode: string
|
||||
itemName: string
|
||||
effectiveDate: string
|
||||
unitPrice: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export const itemPriceScreen = defineKbxScreen({
|
||||
id: 'ERP-PRICE-001',
|
||||
version: '1.0.0',
|
||||
module: 'ERP',
|
||||
type: 'fast-entry', templateCode:'T04',
|
||||
title: '품목 단가 일괄등록',
|
||||
description: '품목별 적용일과 표준단가를 Excel처럼 연속 입력하고 한 번에 저장합니다.',
|
||||
helpKey: 'ERP-PRICE-001',
|
||||
permissions: ['erp.item.price.read', 'erp.item.read'],
|
||||
commands: [
|
||||
{ id: 'new', label: '신규', group: 'edit' },
|
||||
{ id: 'save', label: '저장', group: 'edit', variant: 'primary', shortcut: 'F8', permission: 'erp.item.price.write' },
|
||||
{ id: 'excel', label: '엑셀', group: 'output' },
|
||||
],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
|
||||
export const itemPriceColumns: KbxGridColumn<ItemPriceRow>[] = [
|
||||
{ field:'itemCode', header:'품목코드', type:'lookup', width:140, pinned:'left', editable:true, lookup:{entity:'item'} },
|
||||
{ field:'itemName', header:'품목명', width:220 },
|
||||
{ field:'effectiveDate', header:'적용일', type:'date', width:120, editable:true },
|
||||
{ field:'unitPrice', header:'단가', type:'money', width:130, editable:true },
|
||||
{ field:'remark', header:'비고', width:260, editable:true },
|
||||
]
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface SaveItemPriceRow {
|
||||
clientId: string
|
||||
itemId: string
|
||||
effectiveDate: string
|
||||
unitPrice: number
|
||||
remark?: string
|
||||
}
|
||||
export interface SaveItemPricesRequest { rows: SaveItemPriceRow[] }
|
||||
export interface SaveItemPricesResponse { requested: number; saved: number; created: number; updated: number }
|
||||
export interface ItemLookupResult { id: string; code: string; displayName: string; status?: string }
|
||||
|
||||
export function toKbxProblem(error: unknown): KbxProblem | null { return isKbxProblem(error) ? error : null }
|
||||
|
||||
export const itemPriceApi = {
|
||||
async resolveItemByCode(code: string) {
|
||||
try { return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode', { path:{ code } }) }
|
||||
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
|
||||
},
|
||||
save(request: SaveItemPricesRequest, idempotencyKey: string) {
|
||||
return kbxApi.request<SaveItemPricesResponse, SaveItemPricesRequest>('erp.itemPrices.bulkSave', { body:request, idempotencyKey })
|
||||
},
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxLookupItem, KbxProblem, KbxValidationError } from '@kbx/contracts'
|
||||
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
|
||||
import { itemPriceApi, toKbxProblem } from './itemPriceApi'
|
||||
import type { ItemPriceRow } from './item-price.definition'
|
||||
|
||||
function localDate() {
|
||||
const d=new Date(); const m=String(d.getMonth()+1).padStart(2,'0'); const day=String(d.getDate()).padStart(2,'0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
function newRow():ItemPriceRow { return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', effectiveDate:localDate(), unitPrice:0, remark:'' } }
|
||||
|
||||
export function useItemPriceFastEntry() {
|
||||
const rows=ref<ItemPriceRow[]>(Array.from({length:8},()=>newRow()))
|
||||
const validation=useKbxValidation()
|
||||
const { dirty, touch, markSaved, reset:resetDirty }=useKbxDirtyState()
|
||||
const mutation=useMutation({ mutationFn:({request,key}:{request:{rows:Array<{clientId:string;itemId:string;effectiveDate:string;unitPrice:number;remark?:string}>};key:string})=>itemPriceApi.save(request,key) })
|
||||
const lastResult=ref<{requested:number;saved:number;created:number;updated:number}|null>(null)
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
let failedAction:'save'|null=null
|
||||
let idempotencyKey=crypto.randomUUID()
|
||||
|
||||
const enteredRows=computed(()=>rows.value.filter(x=>x.itemId||x.itemCode.trim()||Number(x.unitPrice)!==0||x.remark.trim()))
|
||||
function invalidateCommandKey(){ idempotencyKey=crypto.randomUUID() }
|
||||
function addRow(){ rows.value.push(newRow()); validation.clear(); touch(); invalidateCommandKey() }
|
||||
function duplicateRows(source:ItemPriceRow[]){ rows.value.push(...source.map(x=>({...x,clientId:crypto.randomUUID()}))); touch(); invalidateCommandKey() }
|
||||
function clear(){ rows.value=Array.from({length:8},()=>newRow()); validation.clear(); lastResult.value=null; problem.value=null; failedAction=null; resetDirty(); invalidateCommandKey() }
|
||||
|
||||
function localErrors():KbxValidationError[] {
|
||||
const errors:KbxValidationError[]=[]
|
||||
const active=enteredRows.value
|
||||
if(!active.length) return [{code:'PRICE_ROWS_REQUIRED',message:'저장할 단가를 한 건 이상 입력하세요.'}]
|
||||
const duplicate=new Map<string,string>()
|
||||
for(const row of active){
|
||||
if(!row.itemId) errors.push({rowKey:row.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(row.effectiveDate)) errors.push({rowKey:row.clientId,field:'effectiveDate',code:'EFFECTIVE_DATE_REQUIRED',message:'적용일을 YYYY-MM-DD 형식으로 입력하세요.'})
|
||||
if(!Number.isFinite(Number(row.unitPrice))||Number(row.unitPrice)<0) errors.push({rowKey:row.clientId,field:'unitPrice',code:'PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
|
||||
if(row.itemId){ const key=`${row.itemId}|${row.effectiveDate}`; if(duplicate.has(key)){ errors.push({rowKey:row.clientId,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}); const first=duplicate.get(key)!; if(!errors.some(x=>x.rowKey===first&&x.code==='DUPLICATE_ITEM_DATE')) errors.push({rowKey:first,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}) } else duplicate.set(key,row.clientId) }
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:ItemPriceRow;field:keyof ItemPriceRow;newValue:unknown}){
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code) Object.assign(event.row,{itemId:null,itemName:''})
|
||||
else {
|
||||
const item=await itemPriceApi.resolveItemByCode(code)
|
||||
if(!item){ Object.assign(event.row,{itemId:null,itemName:''}); validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'}) }
|
||||
else { Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(event.row.clientId,'itemCode') }
|
||||
}
|
||||
}
|
||||
lastResult.value=null; touch(); invalidateCommandKey()
|
||||
}
|
||||
function applyItemLookup(row:ItemPriceRow,item:KbxLookupItem<string>){ Object.assign(row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(row.clientId,'itemCode'); touch(); invalidateCommandKey() }
|
||||
|
||||
async function save(){
|
||||
validation.clear(); problem.value=null; failedAction=null; const errors=localErrors(); if(errors.length){validation.setErrors(errors);return false}
|
||||
const active=enteredRows.value
|
||||
try{
|
||||
const result=await mutation.mutateAsync({key:idempotencyKey,request:{rows:active.map(x=>({clientId:x.clientId,itemId:x.itemId!,effectiveDate:x.effectiveDate,unitPrice:Number(x.unitPrice),remark:x.remark||undefined}))}})
|
||||
lastResult.value=result; markSaved(); idempotencyKey=crypto.randomUUID(); return true
|
||||
}catch(error){ const issue=toKbxProblem(error); if(issue?.type==='validation'){validation.applyProblem(issue);return false} if(issue){problem.value=issue;failedAction='save';return false} throw error }
|
||||
}
|
||||
async function retryProblem(){const action=failedAction;problem.value=null;failedAction=null;if(action==='save')return save();return false}
|
||||
function dismissProblem(){problem.value=null;failedAction=null}
|
||||
return {rows,errors:validation.errors,dirty,saving:computed(()=>mutation.isPending.value),lastResult,problem,enteredCount:computed(()=>enteredRows.value.length),addRow,duplicateRows,onCellChanged,applyItemLookup,save,clear,touch,retryProblem,dismissProblem}
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import {
|
||||
isKbxProblem,
|
||||
kbxFieldReadonly,
|
||||
resolveKbxRecordStatePolicy,
|
||||
type KbxAuditEntry,
|
||||
type KbxConflictSnapshot,
|
||||
type KbxProblem,
|
||||
type KbxSearchField,
|
||||
} from '@kbx/contracts'
|
||||
import {
|
||||
KbxBarcodeField,
|
||||
KbxCheckbox,
|
||||
KbxDataGrid,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxInput,
|
||||
KbxLookup,
|
||||
KbxMasterPage,
|
||||
KbxRecordNavigator,
|
||||
KbxSearchPanel,
|
||||
KbxUnsavedChangesDialog,
|
||||
useKbxDirtyState,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { itemApi } from './itemApi'
|
||||
import {
|
||||
itemMasterColumns,
|
||||
itemMasterScreen,
|
||||
itemStatePolicies,
|
||||
itemWorkflow,
|
||||
type ItemMasterRow,
|
||||
} from './item.definition'
|
||||
|
||||
const searchModel = ref({ keyword:'' })
|
||||
const searchFields:KbxSearchField[] = [
|
||||
{ key:'keyword', label:'검색', type:'text', width:'lg', placeholder:'품목코드/품목명' },
|
||||
]
|
||||
|
||||
const selected = ref<ItemMasterRow[]>([])
|
||||
const form = reactive<ItemMasterRow>(emptyItem())
|
||||
const auditEntries = ref<KbxAuditEntry[]>([])
|
||||
const conflict = ref<KbxConflictSnapshot | null>(null)
|
||||
const problem = ref<KbxProblem | null>(null)
|
||||
const failedCommand = ref<'load'|'save'|'deactivate'|null>(null)
|
||||
const errors = ref<any[]>([])
|
||||
const masterGrid = ref<any>(null)
|
||||
const pendingTransition = ref<{ kind:'row'; row:ItemMasterRow } | { kind:'new' } | null>(null)
|
||||
const savingTransition = ref(false)
|
||||
const { dirty, touch, markSaved, reset:resetDirty } = useKbxDirtyState()
|
||||
|
||||
const status = computed(() => !form.id ? '신규' : form.active ? '사용' : '사용중지')
|
||||
const policy = computed(() => resolveKbxRecordStatePolicy(itemStatePolicies, status.value))
|
||||
const query = useQuery({
|
||||
queryKey:['erp','items'],
|
||||
queryFn:() => itemApi.search(searchModel.value.keyword),
|
||||
enabled:false,
|
||||
})
|
||||
const rows = computed(() => query.data.value?.items ?? [])
|
||||
const currentRowIndex = computed(() => form.id ? rows.value.findIndex(row => row.id === form.id) : -1)
|
||||
const currentRowPosition = computed(() => currentRowIndex.value >= 0 ? currentRowIndex.value + 1 : 0)
|
||||
function navigateRecord(offset:number) {
|
||||
const index=currentRowIndex.value
|
||||
if(index<0)return
|
||||
const target=rows.value[index+offset]
|
||||
if(target) requestChoose([target])
|
||||
}
|
||||
|
||||
function emptyItem():ItemMasterRow {
|
||||
return {
|
||||
id:'', code:'', name:'', categoryName:'', specification:'', unit:'EA', barcode:'',
|
||||
defaultWarehouseId:null, defaultWarehouseName:'', lotManaged:false, expiryManaged:false,
|
||||
active:true, version:0,
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
selected.value = []
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
await query.refetch()
|
||||
}
|
||||
|
||||
async function loadRow(row:ItemMasterRow) {
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
try {
|
||||
const latest = await itemApi.get(row.id)
|
||||
const audit = await itemApi.audit(latest.id)
|
||||
selected.value = [latest]
|
||||
Object.assign(form, latest)
|
||||
auditEntries.value = audit
|
||||
errors.value = []
|
||||
conflict.value = null
|
||||
resetDirty()
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(latest.id)
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error)) {
|
||||
problem.value = error
|
||||
failedCommand.value = 'load'
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id || null)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function requestChoose(rows:ItemMasterRow[]) {
|
||||
const row = rows[0]
|
||||
if (!row || row.id === form.id) return
|
||||
if (dirty.value) {
|
||||
pendingTransition.value = { kind:'row', row }
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id || null)
|
||||
return
|
||||
}
|
||||
await loadRow(row)
|
||||
}
|
||||
|
||||
function state(field:string) {
|
||||
return kbxFieldReadonly(policy.value, field)
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
Object.assign(form, emptyItem())
|
||||
selected.value = []
|
||||
auditEntries.value = []
|
||||
conflict.value = null
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
errors.value = []
|
||||
resetDirty()
|
||||
masterGrid.value?.clearSelection?.()
|
||||
}
|
||||
|
||||
function requestNew() {
|
||||
if (dirty.value) {
|
||||
pendingTransition.value = { kind:'new' }
|
||||
return
|
||||
}
|
||||
createNew()
|
||||
}
|
||||
|
||||
function copy() {
|
||||
const source = { ...form }
|
||||
Object.assign(form, { ...source, id:'', code:'', barcode:'', version:0, active:true })
|
||||
selected.value = []
|
||||
auditEntries.value = []
|
||||
conflict.value = null
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
errors.value = []
|
||||
resetDirty()
|
||||
masterGrid.value?.clearSelection?.()
|
||||
touch()
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
id:form.id || undefined,
|
||||
version:form.version || undefined,
|
||||
code:form.code.trim(),
|
||||
name:form.name.trim(),
|
||||
categoryName:form.categoryName,
|
||||
specification:form.specification,
|
||||
unit:form.unit,
|
||||
barcode:form.barcode,
|
||||
defaultWarehouseId:form.defaultWarehouseId,
|
||||
lotManaged:form.lotManaged,
|
||||
expiryManaged:form.expiryManaged,
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
errors.value = []
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
if (!form.code.trim() || !form.name.trim()) {
|
||||
errors.value = [
|
||||
...(!form.code.trim() ? [{ field:'code', code:'REQUIRED', message:'품목코드를 입력하세요.' }] : []),
|
||||
...(!form.name.trim() ? [{ field:'name', code:'REQUIRED', message:'품목명을 입력하세요.' }] : []),
|
||||
]
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = form.id ? await itemApi.update(form.id, payload()) : await itemApi.create(payload())
|
||||
form.id = result.id
|
||||
form.version = result.version
|
||||
form.active = true
|
||||
markSaved()
|
||||
auditEntries.value = await itemApi.audit(form.id)
|
||||
await query.refetch()
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && error.type === 'validation') {
|
||||
errors.value = error.errors
|
||||
return false
|
||||
}
|
||||
if (isKbxProblem(error) && error.type === 'conflict' && form.id) {
|
||||
const latest = await itemApi.get(form.id)
|
||||
conflict.value = {
|
||||
code:error.code,
|
||||
title:error.title,
|
||||
detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',
|
||||
entityId:form.id,
|
||||
requestedVersion:form.version,
|
||||
currentVersion:error.currentVersion ?? latest.version,
|
||||
changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label]) => ({
|
||||
field,
|
||||
label,
|
||||
mine:(form as any)[field],
|
||||
latest:(latest as any)[field],
|
||||
})),
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (isKbxProblem(error)) {
|
||||
problem.value = error
|
||||
failedCommand.value = 'save'
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPendingTransition() {
|
||||
const pending = pendingTransition.value
|
||||
pendingTransition.value = null
|
||||
if (!pending) return
|
||||
if (pending.kind === 'new') createNew()
|
||||
else await loadRow(pending.row)
|
||||
}
|
||||
|
||||
async function saveThenTransition() {
|
||||
if (!pendingTransition.value) return
|
||||
savingTransition.value = true
|
||||
try {
|
||||
if (await save()) await applyPendingTransition()
|
||||
} finally {
|
||||
savingTransition.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!form.id) return
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
try {
|
||||
const result = await itemApi.deactivate(form.id, form.version)
|
||||
form.version = result.version
|
||||
form.active = false
|
||||
markSaved()
|
||||
auditEntries.value = await itemApi.audit(form.id)
|
||||
await query.refetch()
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && error.type === 'conflict') {
|
||||
const latest = await itemApi.get(form.id)
|
||||
conflict.value = {
|
||||
code:error.code,
|
||||
title:error.title,
|
||||
currentVersion:error.currentVersion ?? latest.version,
|
||||
requestedVersion:form.version,
|
||||
entityId:form.id,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isKbxProblem(error)) {
|
||||
problem.value = error
|
||||
failedCommand.value = 'deactivate'
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConflict() {
|
||||
if (!form.id) return
|
||||
const latest = await itemApi.get(form.id)
|
||||
Object.assign(form, latest)
|
||||
auditEntries.value = await itemApi.audit(form.id)
|
||||
conflict.value = null
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
resetDirty()
|
||||
await nextTick()
|
||||
masterGrid.value?.selectRowByKey?.(form.id)
|
||||
}
|
||||
|
||||
async function retryProblem() {
|
||||
const action = failedCommand.value
|
||||
const row = selected.value[0]
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
if (action === 'load' && row) return loadRow(row)
|
||||
if (action === 'save') return save()
|
||||
if (action === 'deactivate') return deactivate()
|
||||
}
|
||||
|
||||
function dismissProblem() {
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
}
|
||||
|
||||
async function command(id:string) {
|
||||
if (id === 'search') return search()
|
||||
if (id === 'new') return requestNew()
|
||||
if (id === 'copy') return copy()
|
||||
if (id === 'save') return save()
|
||||
if (id === 'deactivate') return deactivate()
|
||||
}
|
||||
|
||||
useKbxPageShortcuts([
|
||||
{ key:'F3', execute:search },
|
||||
{ key:'F8', execute:save },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxMasterPage
|
||||
:screen="itemMasterScreen"
|
||||
:status="status"
|
||||
:dirty="dirty"
|
||||
:version="form.version || undefined"
|
||||
:errors="errors"
|
||||
:workflow="itemWorkflow"
|
||||
:conflict="conflict"
|
||||
:problem="problem"
|
||||
:audit-entries="auditEntries"
|
||||
list-title="품목 목록"
|
||||
:list-count="query.data.value?.totalCount ?? rows.length"
|
||||
list-count-unit="개"
|
||||
detail-title="품목 상세"
|
||||
:detail-description="form.id ? `${form.code} · ${form.name}` : '신규 품목을 등록합니다.'"
|
||||
:content-state="query.error.value ? 'error' : query.isFetching.value && !query.data.value ? 'loading' : !query.data.value ?'idle' : query.data.value.items.length === 0 ? 'empty' : 'ready'"
|
||||
:refreshing="query.isFetching.value && Boolean(query.data.value)"
|
||||
breadcrumb="ERP > 기준정보"
|
||||
@command="command"
|
||||
@transition="command"
|
||||
@reload-conflict="reloadConflict"
|
||||
@dismiss-conflict="conflict=null"
|
||||
@retry-problem="retryProblem"
|
||||
@dismiss-problem="dismissProblem"
|
||||
>
|
||||
<template #detail-actions>
|
||||
<KbxRecordNavigator
|
||||
:current="currentRowPosition"
|
||||
:total="rows.length"
|
||||
item-label="품목"
|
||||
:previous-disabled="dirty || currentRowPosition<=1"
|
||||
:next-disabled="dirty || currentRowPosition>=rows.length"
|
||||
@previous="navigateRecord(-1)"
|
||||
@next="navigateRecord(1)"
|
||||
/>
|
||||
</template>
|
||||
<template #list>
|
||||
<KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search" />
|
||||
<KbxDataGrid
|
||||
ref="masterGrid"
|
||||
:rows="rows"
|
||||
:columns="itemMasterColumns"
|
||||
row-key="id"
|
||||
aria-label="품목 목록"
|
||||
selection="single"
|
||||
:active-row-key="form.id || undefined"
|
||||
:loading="query.isFetching.value"
|
||||
@selection-changed="requestChoose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<KbxFormSection title="기본정보" description="품목 식별과 분류에 필요한 핵심 기준정보입니다.">
|
||||
<KbxFormGrid>
|
||||
<KbxInput v-model="form.code" label="품목코드" required :readonly="Boolean(form.id) || state('code')" :error="errors.find(x => x.field === 'code')?.message" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.name" label="품목명" required :readonly="state('name')" :error="errors.find(x => x.field === 'name')?.message" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.categoryName" label="품목그룹" :readonly="state('categoryName')" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.specification" label="규격" :readonly="state('specification')" @update:model-value="touch" />
|
||||
<KbxInput v-model="form.unit" label="단위" :readonly="state('unit')" @update:model-value="touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
|
||||
<KbxFormSection title="물류정보" description="창고·바코드·LOT 등 현장 처리 기준을 정의합니다.">
|
||||
<KbxFormGrid>
|
||||
<KbxLookup v-model="form.defaultWarehouseId" entity="warehouse" label="기본창고" :readonly="state('defaultWarehouseId')" @selected="touch" />
|
||||
<KbxBarcodeField v-model="form.barcode" label="바코드" :readonly="state('barcode')" @update:model-value="touch" />
|
||||
<KbxCheckbox v-model="form.lotManaged" label="LOT 관리" :disabled="state('lotManaged')" @update:model-value="touch" />
|
||||
<KbxCheckbox v-model="form.expiryManaged" label="유통기한 관리" :disabled="state('expiryManaged')" @update:model-value="touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
</KbxMasterPage>
|
||||
|
||||
<KbxUnsavedChangesDialog
|
||||
:open="Boolean(pendingTransition)"
|
||||
:saving="savingTransition"
|
||||
@stay="pendingTransition=null"
|
||||
@discard="applyPendingTransition"
|
||||
@save="saveThenTransition"
|
||||
/>
|
||||
</template>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
|
||||
export interface ItemMasterRow { id:string; code:string; name:string; categoryName:string; specification:string; unit:string; barcode:string; defaultWarehouseId:string|null; defaultWarehouseName:string; lotManaged:boolean; expiryManaged:boolean; active:boolean; version:number }
|
||||
|
||||
export const itemWorkflow:KbxWorkflowDefinition={id:'erp.item.lifecycle',version:'1.0.0',states:[{value:'신규',label:'신규',semantic:'draft'},{value:'사용',label:'사용',semantic:'completed'},{value:'사용중지',label:'사용중지',semantic:'disabled',terminal:true}],transitions:[{id:'deactivate',from:['사용'],to:'사용중지',label:'사용중지',permission:'erp.item.write',confirm:true}]}
|
||||
export const itemStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'사용',editability:'editable'},{status:'사용중지',editability:'readonly',message:'사용중지된 품목은 조회·복사·이력확인만 가능합니다.'}]
|
||||
|
||||
export const itemMasterScreen = defineKbxScreen({
|
||||
id: 'ERP-MST-ITEM-001', version: '1.1.0', module: 'ERP', type: 'master', templateCode:'T02', title: '품목관리',
|
||||
description: '품목 기준정보와 물류 속성을 동일한 문법으로 관리합니다.', helpKey:'ERP-MST-ITEM-001', permissions:['erp.item.read'], telemetry:{enabled:true},
|
||||
commands:[
|
||||
{id:'search',label:'조회',group:'query',shortcut:'F3'},
|
||||
{id:'new',label:'신규',group:'edit',permission:'erp.item.create'},
|
||||
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'erp.item.create','사용':'erp.item.write'},allowedStatuses:['신규','사용'],requiresDirty:true},
|
||||
{id:'copy',label:'복사',group:'edit',permission:'erp.item.create'},
|
||||
{id:'deactivate',label:'사용중지',group:'workflow',permission:'erp.item.write',allowedStatuses:['사용'],confirm:{title:'품목을 사용중지하시겠습니까?',detail:'사용중지 후 신규 업무에서 이 품목을 선택할 수 없습니다. 기존 이력은 유지됩니다.',level:'high',confirmLabel:'사용중지'}},
|
||||
{id:'excel',label:'엑셀',group:'output'},
|
||||
],
|
||||
})
|
||||
export const itemMasterColumns:KbxGridColumn<ItemMasterRow>[]=[{field:'code',header:'품목코드',type:'code',width:120,pinned:'left'},{field:'name',header:'품목명',width:200},{field:'specification',header:'규격',width:160},{field:'unit',header:'단위',width:70},{field:'active',header:'사용',type:'boolean',width:70}]
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
import type { KbxAuditEntry } from '@kbx/contracts'
|
||||
import type { ItemMasterRow } from './item.definition'
|
||||
export interface ItemSearchResponse { items: ItemMasterRow[]; totalCount: number }
|
||||
export interface ItemSaveRequest { id?:string; version?:number; code:string; name:string; categoryName?:string; specification?:string; unit:string; barcode?:string; defaultWarehouseId?:string|null; lotManaged:boolean; expiryManaged:boolean }
|
||||
export interface ItemSaveResponse { id:string; version:number; status:string }
|
||||
export const itemApi = {
|
||||
search(keyword = '') { return kbxApi.request<ItemSearchResponse>('erp.items.search', { query: { keyword, page: 1, pageSize: 200 } }) },
|
||||
get(id: string) { return kbxApi.request<ItemMasterRow>('erp.items.get', { path: { id } }) },
|
||||
create(body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.create', { body }) },
|
||||
update(id:string,body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.update', { path:{id}, body }) },
|
||||
deactivate(id:string,version:number) { return kbxApi.request<ItemSaveResponse>('erp.items.deactivate', { path:{id}, body:{version} }) },
|
||||
audit(id:string) { return kbxApi.request<KbxAuditEntry[]>('erp.items.audit', { path:{id} }) },
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import type { KbxLookupItem, KbxValidationError } from '@kbx/contracts'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDateField,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxInput,
|
||||
KbxLookup,
|
||||
KbxLookupDialog,
|
||||
KbxTransactionPage,
|
||||
KbxUnsavedChangesDialog,
|
||||
KbxWorkflowBar,
|
||||
useKbxDirtyState,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
|
||||
import { lookupRegistry } from '../../../lookups/lookupRegistry'
|
||||
import { purchaseColumns, purchaseScreen, purchaseWorkflow, type PurchaseLine } from './purchase.definition'
|
||||
|
||||
function today() {
|
||||
const value = new Date()
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function newLine():PurchaseLine {
|
||||
return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', quantity:1, unitPrice:0, amount:0, dueDate:'' }
|
||||
}
|
||||
|
||||
const status=ref('DRAFT')
|
||||
const header=reactive({ purchaseDate:today(), supplierId:null as string|null, warehouseId:null as string|null, buyer:'', remark:'' })
|
||||
const lines=ref<PurchaseLine[]>([newLine()])
|
||||
const errors=ref<KbxValidationError[]>([])
|
||||
const pageNotice=ref('')
|
||||
const detailGrid=ref<any>(null)
|
||||
const itemLookupOpen=ref(false)
|
||||
const activeLine=ref<PurchaseLine|null>(null)
|
||||
const activeLookupField=ref<keyof PurchaseLine & string>('itemCode')
|
||||
const pendingReset=ref(false)
|
||||
const { dirty, touch, reset:resetDirty }=useKbxDirtyState()
|
||||
useKbxWorkspaceBinding(dirty, async()=>false)
|
||||
|
||||
const readonly=computed(()=>status.value!=='DRAFT')
|
||||
const totalQty=computed(()=>lines.value.reduce((sum,line)=>sum+Number(line.quantity||0),0))
|
||||
const total=computed(()=>lines.value.reduce((sum,line)=>sum+Number(line.amount||0),0))
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'items',label:'품목',value:`${lines.value.length.toLocaleString('ko-KR')}종`},
|
||||
{key:'qty',label:'총수량',value:totalQty.value},
|
||||
{key:'amount',label:'총 구매금액',value:`₩${total.value.toLocaleString('ko-KR')}`,emphasis:true},
|
||||
])
|
||||
const pageContext=computed(()=>({
|
||||
label:'신규 구매',
|
||||
hint:dirty.value?'저장하지 않은 입력이 있습니다.':'작성 상태 · 서버 반영 전',
|
||||
metrics:[
|
||||
{key:'status',label:'상태',value:'작성'},
|
||||
{key:'lines',label:'품목',value:lines.value.length},
|
||||
{key:'errors',label:'오류',value:errors.value.length,tone:'danger' as const,emphasis:errors.value.length>0},
|
||||
],
|
||||
}))
|
||||
|
||||
function fieldError(field:string){return errors.value.find(error=>!error.rowKey&&error.field===field)?.message}
|
||||
function clearError(rowKey:string|undefined,field:string){errors.value=errors.value.filter(error=>!(error.rowKey===rowKey&&error.field===field))}
|
||||
function recalc(line:PurchaseLine){line.amount=Number(line.quantity||0)*Number(line.unitPrice||0)}
|
||||
function addLine(){if(readonly.value)return;lines.value.push(newLine());touch()}
|
||||
async function addLineAndFocus(){addLine();const line=lines.value.at(-1);if(!line)return;await nextTick();detailGrid.value?.focusCell?.(line.clientId,'itemCode')}
|
||||
function duplicateLines(selected:PurchaseLine[]){if(readonly.value||!selected.length)return;lines.value.push(...selected.map(line=>({...line,clientId:crypto.randomUUID()})));touch()}
|
||||
|
||||
function validateDraft(){
|
||||
const next:KbxValidationError[]=[]
|
||||
if(!header.supplierId)next.push({field:'supplierId',code:'SUPPLIER_REQUIRED',message:'거래처를 선택하세요.'})
|
||||
if(!header.warehouseId)next.push({field:'warehouseId',code:'WAREHOUSE_REQUIRED',message:'입고창고를 선택하세요.'})
|
||||
if(!lines.value.length)next.push({code:'PURCHASE_LINE_REQUIRED',message:'구매 품목을 1건 이상 입력하세요.'})
|
||||
for(const line of lines.value){
|
||||
if(!line.itemId)next.push({rowKey:line.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
|
||||
if(Number(line.quantity)<=0)next.push({rowKey:line.clientId,field:'quantity',code:'QUANTITY_POSITIVE',message:'수량은 0보다 커야 합니다.'})
|
||||
if(Number(line.unitPrice)<0)next.push({rowKey:line.clientId,field:'unitPrice',code:'UNIT_PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
|
||||
}
|
||||
errors.value=next
|
||||
return next.length===0
|
||||
}
|
||||
|
||||
async function onCellChanged(event:{row:PurchaseLine;field:keyof PurchaseLine & string;newValue:unknown}){
|
||||
clearError(event.row.clientId,event.field)
|
||||
if(event.field==='itemCode'){
|
||||
const code=String(event.newValue??'').trim()
|
||||
if(!code)Object.assign(event.row,{itemId:null,itemName:''})
|
||||
else{
|
||||
const item=await lookupRegistry.item.resolveByCode(code)
|
||||
if(item)Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName})
|
||||
else{
|
||||
Object.assign(event.row,{itemId:null,itemName:''})
|
||||
errors.value.push({rowKey:event.row.clientId,field:'itemCode',code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})
|
||||
}
|
||||
}
|
||||
}
|
||||
if(event.field==='quantity'||event.field==='unitPrice')recalc(event.row)
|
||||
touch()
|
||||
}
|
||||
|
||||
function openGridLookup(event:{row:PurchaseLine;field:keyof PurchaseLine & string;entity:string}){
|
||||
if(readonly.value||event.entity!=='item')return
|
||||
activeLine.value=event.row
|
||||
activeLookupField.value=event.field
|
||||
itemLookupOpen.value=true
|
||||
}
|
||||
async function selectGridItem(item:KbxLookupItem<string>){
|
||||
if(!activeLine.value)return
|
||||
Object.assign(activeLine.value,{itemId:item.id,itemCode:item.code,itemName:item.displayName})
|
||||
clearError(activeLine.value.clientId,'itemCode')
|
||||
touch();itemLookupOpen.value=false
|
||||
await nextTick();detailGrid.value?.focusNextEditableCell?.(activeLine.value.clientId,activeLookupField.value)
|
||||
}
|
||||
|
||||
function resetDraft(){
|
||||
status.value='DRAFT'
|
||||
Object.assign(header,{purchaseDate:today(),supplierId:null,warehouseId:null,buyer:'',remark:''})
|
||||
lines.value=[newLine()]
|
||||
errors.value=[];pageNotice.value='';pendingReset.value=false;resetDirty()
|
||||
}
|
||||
function requestNew(){if(dirty.value){pendingReset.value=true;return}resetDraft()}
|
||||
function saveDraft(){
|
||||
pageNotice.value=''
|
||||
if(!validateDraft()){pageNotice.value='저장할 수 없습니다. 입력 오류를 확인하세요.';nextTick(()=>detailGrid.value?.focusError?.(1));return false}
|
||||
pageNotice.value='저장 Command가 아직 서버에 연결되지 않아 입력값을 반영하지 않았습니다. 화면 초안은 유지됩니다.'
|
||||
return false
|
||||
}
|
||||
function executeWorkflow(id:string){
|
||||
if(id==='confirm'){
|
||||
if(dirty.value){pageNotice.value='구매확정 전에 먼저 저장해야 합니다.';return}
|
||||
pageNotice.value='구매확정 Command가 서버에 연결되지 않아 상태를 변경하지 않았습니다.'
|
||||
return
|
||||
}
|
||||
pageNotice.value='현재 상태 변경은 서버 Domain Command가 연결된 뒤 실행할 수 있습니다.'
|
||||
}
|
||||
function command(id:string){
|
||||
if(id==='new')return requestNew()
|
||||
if(id==='save')return saveDraft()
|
||||
if(id==='confirm'||id==='cancel')return executeWorkflow(id)
|
||||
if(id==='excel')pageNotice.value='엑셀 메뉴는 공통 KbxExcelMenu 연결 후 활성화합니다. 임의 브라우저 Import는 수행하지 않습니다.'
|
||||
}
|
||||
function focusNextError(){detailGrid.value?.focusError?.(1)}
|
||||
|
||||
useKbxPageShortcuts([{key:'F8',execute:saveDraft}])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxTransactionPage
|
||||
:screen="purchaseScreen"
|
||||
:status="status"
|
||||
:dirty="dirty"
|
||||
:errors="errors"
|
||||
:workflow="purchaseWorkflow"
|
||||
:summary-items="summaryItems"
|
||||
:context="pageContext"
|
||||
header-title="구매 정보"
|
||||
header-description="거래처·입고창고·담당자 기준을 먼저 확정합니다."
|
||||
detail-title="구매 품목"
|
||||
:detail-count="lines.length"
|
||||
detail-count-unit="종"
|
||||
detail-description="수량·단가·납기일을 한 Grid에서 연속 입력합니다. F2로 품목을 조회할 수 있습니다."
|
||||
breadcrumb="ERP > 구매"
|
||||
@command="command"
|
||||
@transition="executeWorkflow"
|
||||
>
|
||||
<template #notice><div v-if="pageNotice" class="page-notice" role="status">{{pageNotice}}</div></template>
|
||||
<template #header>
|
||||
<KbxWorkflowBar :workflow="purchaseWorkflow" :current="status" @transition="executeWorkflow" />
|
||||
<KbxFormSection title="구매정보">
|
||||
<KbxFormGrid>
|
||||
<KbxDateField v-model="header.purchaseDate" label="구매일" required :readonly="readonly" @update:model-value="touch" />
|
||||
<KbxLookup v-model="header.supplierId" entity="customer" label="거래처" required :readonly="readonly" :error="fieldError('supplierId')" @selected="()=>{clearError(undefined,'supplierId');touch()}" />
|
||||
<KbxLookup v-model="header.warehouseId" entity="warehouse" label="입고창고" required :readonly="readonly" :error="fieldError('warehouseId')" @selected="()=>{clearError(undefined,'warehouseId');touch()}" />
|
||||
<KbxInput v-model="header.buyer" label="담당자" :readonly="readonly" @update:model-value="touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
|
||||
<template #detail-actions>
|
||||
<KbxButton v-if="errors.length" label="첫/다음 오류" variant="ghost" @click="focusNextError" />
|
||||
<KbxButton v-if="!readonly" label="행 추가" variant="secondary" @click="addLineAndFocus" />
|
||||
</template>
|
||||
<template #detail>
|
||||
<KbxDataGrid
|
||||
ref="detailGrid"
|
||||
:rows="lines"
|
||||
:columns="purchaseColumns"
|
||||
row-key="clientId"
|
||||
aria-label="구매 품목"
|
||||
selection="multiple"
|
||||
:errors="errors"
|
||||
:editable="!readonly"
|
||||
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
|
||||
@row-add-requested="addLine"
|
||||
@row-duplicate-requested="duplicateLines"
|
||||
@cell-changed="onCellChanged"
|
||||
@lookup-requested="openGridLookup"
|
||||
/>
|
||||
</template>
|
||||
</KbxTransactionPage>
|
||||
|
||||
<KbxLookupDialog v-model:visible="itemLookupOpen" entity="item" title="품목" @select="selectGridItem" />
|
||||
<KbxUnsavedChangesDialog :open="pendingReset" :can-save="false" @stay="pendingReset=false" @discard="resetDraft" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-notice{padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
export interface PurchaseLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; quantity:number; unitPrice:number; amount:number; dueDate:string }
|
||||
export const purchaseScreen = defineKbxScreen({
|
||||
id:'ERP-PUR-001', version:'1.0.0', module:'ERP', type:'transaction', templateCode:'T03', title:'구매등록', helpKey:'ERP-PUR-001', permissions:['erp.purchase.read'], telemetry:{enabled:true},
|
||||
description:'거래처 구매를 Header/Detail 방식으로 입력하고 확정합니다.',
|
||||
commands:[{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.purchase.write'},{id:'confirm',label:'구매확정',group:'workflow',variant:'primary',permission:'erp.purchase.confirm'},{id:'excel',label:'엑셀',group:'output'}]
|
||||
})
|
||||
export const purchaseColumns: KbxGridColumn<PurchaseLine>[] = [
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'}, {field:'itemName',header:'품목명',width:210},
|
||||
{field:'quantity',header:'수량',type:'quantity',width:95,editable:true}, {field:'unitPrice',header:'단가',type:'money',width:120,editable:true}, {field:'amount',header:'금액',type:'money',width:130}, {field:'dueDate',header:'납기일',type:'date',width:110,editable:true}
|
||||
]
|
||||
export const purchaseWorkflow: KbxWorkflowDefinition = { id:'erp.purchase', version:'1.0.0', states:[
|
||||
{value:'DRAFT',label:'작성',semantic:'draft'}, {value:'CONFIRMED',label:'확정',semantic:'processing'}, {value:'PARTIALLY_RECEIVED',label:'부분입고',semantic:'processing'}, {value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true}, {value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}
|
||||
], transitions:[
|
||||
{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'구매확정',permission:'erp.purchase.confirm',confirm:true}, {id:'cancel',from:['DRAFT','CONFIRMED'],to:'CANCELLED',label:'구매취소',permission:'erp.purchase.cancel',reasonRequired:true}
|
||||
] }
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const erpPurchaseRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
|
||||
]
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { isKbxProblem } from '@kbx/contracts'
|
||||
import { KbxDataGrid, KbxListPage, KbxSearchPanel, KbxWorkflowBar } from '@kbx/ui'
|
||||
import { claimColumns, claimScreen, claimSearchFields, claimWorkflow, type ClaimRow } from './claims.definition'
|
||||
import { claimsApi } from './claimsApi'
|
||||
|
||||
type ClaimActionReceipt={actionId:string;requested:number;succeeded:number;failed:number;failures:{claimNo:string;message:string}[]}
|
||||
const actionLabel:Record<string,string>={approve:'승인',hold:'보류',start:'처리 시작',complete:'처리 완료'}
|
||||
const allowedTransitions=new Set(Object.keys(actionLabel))
|
||||
const search=ref<Record<string,unknown>>({status:'REQUESTED'})
|
||||
const selected=ref<ClaimRow[]>([])
|
||||
const actionReceipt=ref<ClaimActionReceipt|null>(null)
|
||||
const receiptEl=ref<HTMLElement|null>(null)
|
||||
const q=useQuery({queryKey:['oms','claims',search],queryFn:()=>claimsApi.search(search.value),enabled:false})
|
||||
|
||||
async function run(){selected.value=[];actionReceipt.value=null;await q.refetch()}
|
||||
function failureMessage(cause:unknown){return isKbxProblem(cause)?cause.detail||cause.title:cause instanceof Error?cause.message:'업무 전이를 완료하지 못했습니다.'}
|
||||
async function executeTransition(actionId:string){
|
||||
if(!allowedTransitions.has(actionId))return
|
||||
const targets=[...selected.value]
|
||||
if(!targets.length)return
|
||||
if((actionId==='start'||actionId==='complete')&&targets.length!==1)return
|
||||
let succeeded=0
|
||||
const failures:ClaimActionReceipt['failures']=[]
|
||||
for(const row of targets){
|
||||
try{await claimsApi.transition(row.id,actionId);succeeded++}
|
||||
catch(cause){failures.push({claimNo:row.claimNo,message:failureMessage(cause)})}
|
||||
}
|
||||
actionReceipt.value={actionId,requested:targets.length,succeeded,failed:failures.length,failures}
|
||||
await q.refetch()
|
||||
selected.value=[]
|
||||
await nextTick();receiptEl.value?.focus()
|
||||
}
|
||||
async function command(id:string){if(id==='search')return run();return executeTransition(id)}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxListPage
|
||||
:screen="claimScreen"
|
||||
:selection-count="selected.length"
|
||||
: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 #notice>
|
||||
<section
|
||||
v-if="actionReceipt"
|
||||
ref="receiptEl"
|
||||
class="claim-action-receipt"
|
||||
data-kbx-surface="claim-action-receipt"
|
||||
tabindex="-1"
|
||||
role="status"
|
||||
:data-tone="actionReceipt.failed?'warning':'success'"
|
||||
>
|
||||
<strong>{{actionLabel[actionReceipt.actionId]}} 결과</strong>
|
||||
<span>요청 {{actionReceipt.requested}}건 · 성공 {{actionReceipt.succeeded}}건 · 실패 {{actionReceipt.failed}}건</span>
|
||||
<small v-if="actionReceipt.failures.length">{{actionReceipt.failures.map(item=>`${item.claimNo}: ${item.message}`).join(' · ')}}</small>
|
||||
</section>
|
||||
</template>
|
||||
<template #content>
|
||||
<KbxWorkflowBar v-if="selected.length===1" :workflow="claimWorkflow" :current="selected[0].status" @transition="command" />
|
||||
<KbxDataGrid
|
||||
:rows="q.data.value?.items ?? []"
|
||||
:columns="claimColumns"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:loading="q.isFetching.value"
|
||||
personalization
|
||||
exportable
|
||||
@selection-changed="selected=$event"
|
||||
/>
|
||||
</template>
|
||||
<template #summary>클레임 {{q.data.value?.totalCount ?? 0}}건 · 선택 {{selected.length}}건</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.claim-action-receipt{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-success-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success);background:var(--kbx-color-success-surface);font-size:var(--kbx-font-sm)}
|
||||
.claim-action-receipt[data-tone="warning"]{border-color:var(--kbx-color-warning-border);border-left-color:var(--kbx-color-warning);background:var(--kbx-color-warning-surface)}
|
||||
.claim-action-receipt small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--kbx-color-text-muted)}
|
||||
.claim-action-receipt:focus-visible{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:var(--kbx-space-1)}
|
||||
</style>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
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.2.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:110, statusMap:{definitions:kbxStatusCatalog.claim} }, { field:'ownerName', header:'담당', width:100 }, { field:'requestedAt', header:'요청일시', type:'datetime', width:160 },
|
||||
]
|
||||
export const claimWorkflow: KbxWorkflowDefinition = {
|
||||
id:'oms.claim', version:'1.1.0',
|
||||
states:[
|
||||
{value:'REQUESTED',label:'접수',semantic:'pending'}, {value:'APPROVED',label:'승인',semantic:'processing'}, {value:'IN_PROGRESS',label:'처리중',semantic:'processing'}, {value:'HOLD',label:'보류',semantic:'hold',terminal:true}, {value:'COMPLETED',label:'완료',semantic:'completed',terminal:true}
|
||||
],
|
||||
transitions:[
|
||||
{id:'approve',from:['REQUESTED'],to:'APPROVED',label:'승인',permission:'oms.claim.approve'},
|
||||
{id:'hold',from:['REQUESTED'],to:'HOLD',label:'보류',permission:'oms.claim.hold'},
|
||||
{id:'start',from:['APPROVED'],to:'IN_PROGRESS',label:'처리 시작',permission:'oms.claim.process'},
|
||||
{id:'complete',from:['IN_PROGRESS'],to:'COMPLETED',label:'처리 완료',permission:'oms.claim.process',confirm:true},
|
||||
]
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { ClaimRow } from './claims.definition'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export interface ClaimSearch { from?: string; to?: string; type?: string; status?: string; keyword?: string }
|
||||
|
||||
const transitionOperations = {
|
||||
approve: 'oms.claims.approve',
|
||||
hold: 'oms.claims.hold',
|
||||
start: 'oms.claims.start',
|
||||
complete: 'oms.claims.complete',
|
||||
} as const
|
||||
|
||||
export const claimsApi = {
|
||||
search(filter: ClaimSearch) {
|
||||
return kbxApi.request<{ items: ClaimRow[]; totalCount: number }>('oms.claims.search', { query: filter })
|
||||
},
|
||||
transition(id: string, transition: string) {
|
||||
const operation = transitionOperations[transition as keyof typeof transitionOperations]
|
||||
if (!operation) throw new Error(`Unsupported claim transition: ${transition}`)
|
||||
return kbxApi.request<{ id: string; status: string; version: number }>(operation, { path: { id } })
|
||||
},
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const omsClaimRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/oms/claims', name:'oms-claims', component:()=>import('./ClaimsPage.vue'), meta:{ screenId:'OMS-CLM-001' } },
|
||||
]
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { KbxExcelImport, KbxImportPage, useKbxImportProgress } from '@kbx/ui'
|
||||
import { orderImportDefinition, orderImportScreen } from './order-import.definition'
|
||||
import { useOrderExcelImport } from './useOrderExcelImport'
|
||||
import { createImportProgressConnection } from '../../../../imports/createImportProgressConnection'
|
||||
|
||||
const vm = useOrderExcelImport()
|
||||
const realtime = useKbxImportProgress(createImportProgressConnection())
|
||||
watch(vm.sessionId, id => { if (id) void realtime.watch(id) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxImportPage :screen="orderImportScreen" :refreshing="vm.busy.value" :problem="vm.problem.value" @retry-problem="vm.retryProblem" @dismiss-problem="vm.dismissProblem">
|
||||
<KbxExcelImport
|
||||
:definition="orderImportDefinition"
|
||||
:session="vm.session.value"
|
||||
:busy="vm.busy.value"
|
||||
:progress="realtime.lastEvent.value"
|
||||
@upload="vm.upload"
|
||||
@save-mapping="vm.saveMapping"
|
||||
@save-named-mapping="vm.saveNamedMapping"
|
||||
@validate="vm.validate"
|
||||
@commit="vm.commit"
|
||||
@download-template="vm.downloadTemplate"
|
||||
@download-errors="vm.downloadErrors"
|
||||
@cancel="vm.reset"
|
||||
/>
|
||||
</KbxImportPage>
|
||||
</template>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
import { kbxImportField, type KbxImportDefinition } from '@kbx/contracts'
|
||||
|
||||
export const orderImportScreen = defineKbxScreen({
|
||||
id: 'OMS-ORD-003',
|
||||
version: '1.2.0',
|
||||
module: 'OMS',
|
||||
type: 'import', templateCode:'T08',
|
||||
title: '주문 Excel 업로드',
|
||||
helpKey: 'OMS-ORD-003',
|
||||
permissions: ['oms.order.import'],
|
||||
commands: [],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
|
||||
export const orderImportDefinition: KbxImportDefinition = {
|
||||
id: 'oms.orders.v1',
|
||||
screenId: 'OMS-ORD-003',
|
||||
entity: 'order',
|
||||
title: '주문 Excel 업로드',
|
||||
allowCreate: true,
|
||||
allowUpdate: true,
|
||||
maxFileSizeBytes: 20 * 1024 * 1024,
|
||||
maxRows: 100_000,
|
||||
fields: [
|
||||
kbxImportField('orderNo'),
|
||||
kbxImportField('orderDate'),
|
||||
kbxImportField('customerCode'),
|
||||
kbxImportField('warehouseCode'),
|
||||
kbxImportField('receiverName'),
|
||||
kbxImportField('phone'),
|
||||
kbxImportField('postalCode'),
|
||||
kbxImportField('address1'),
|
||||
kbxImportField('address2'),
|
||||
kbxImportField('itemCode'),
|
||||
kbxImportField('orderQty'),
|
||||
kbxImportField('unitPrice'),
|
||||
kbxImportField('remark'),
|
||||
],
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { KbxImportMapping, KbxImportSession } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../../http/generated/kbxApiClient'
|
||||
|
||||
export const orderImportApi = {
|
||||
createSession(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('importType', 'oms.orders.v1')
|
||||
return kbxApi.request<KbxImportSession, FormData>('common.imports.createSession', { body: form })
|
||||
},
|
||||
|
||||
getSession(id: string) {
|
||||
return kbxApi.request<KbxImportSession>('common.imports.getSession', { path: { sessionId: id } })
|
||||
},
|
||||
|
||||
saveMapping(id: string, mappings: KbxImportMapping[]) {
|
||||
return kbxApi.request<KbxImportSession, { mappings: KbxImportMapping[] }>('common.imports.saveMapping', {
|
||||
path: { sessionId: id }, body: { mappings },
|
||||
})
|
||||
},
|
||||
|
||||
saveNamedMapping(id: string, name: string, mappings: KbxImportMapping[]) {
|
||||
return kbxApi.request<void, { name: string; mappings: KbxImportMapping[] }>('common.imports.saveNamedMapping', {
|
||||
path: { sessionId: id }, body: { name, mappings },
|
||||
})
|
||||
},
|
||||
|
||||
validate(id: string) {
|
||||
return kbxApi.request<KbxImportSession>('common.imports.validate', { path: { sessionId: id } })
|
||||
},
|
||||
|
||||
commit(id: string) {
|
||||
return kbxApi.request<KbxImportSession>('common.imports.commit', { path: { sessionId: id } })
|
||||
},
|
||||
|
||||
async downloadTemplate() {
|
||||
const value = await kbxApi.request<Blob>('common.imports.template', {
|
||||
path: { importType: 'oms.orders.v1' }, responseType: 'blob',
|
||||
})
|
||||
downloadBlob(value, 'OMS_주문_업로드_양식.xlsx')
|
||||
},
|
||||
|
||||
async downloadErrors(id: string) {
|
||||
const value = await kbxApi.request<Blob>('common.imports.errorWorkbook', {
|
||||
path: { sessionId: id }, responseType: 'blob',
|
||||
})
|
||||
downloadBlob(value, 'OMS_주문_업로드_오류.xlsx')
|
||||
},
|
||||
}
|
||||
|
||||
function downloadBlob(value: BlobPart, fileName: string) {
|
||||
const url = URL.createObjectURL(new Blob([value]))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import { isKbxProblem, type KbxImportMapping, type KbxImportSession, type KbxProblem } from '@kbx/contracts'
|
||||
import { orderImportApi } from './orderImportApi'
|
||||
import { kbxTelemetry } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { orderImportScreen } from './order-import.definition'
|
||||
|
||||
export function useOrderExcelImport() {
|
||||
const sessionId = ref<string | null>(null)
|
||||
const localSession = ref<KbxImportSession | null>(null)
|
||||
let importStartedAt:number|undefined
|
||||
let terminalRecorded=false
|
||||
const problem=ref<KbxProblem|null>(null)
|
||||
let retryAction:(()=>Promise<unknown>)|null=null
|
||||
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: computed(() => ['excel-import', sessionId.value]),
|
||||
queryFn: () => orderImportApi.getSession(sessionId.value!),
|
||||
enabled: computed(() => !!sessionId.value),
|
||||
refetchInterval: query => {
|
||||
const status = query.state.data?.status
|
||||
return status === 'validating' || status === 'committing' ? 1500 : false
|
||||
},
|
||||
})
|
||||
|
||||
const session = computed(() => sessionQuery.data.value ?? localSession.value)
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: orderImportApi.createSession,
|
||||
onMutate() { importStartedAt=performance.now(); terminalRecorded=false; kbxTelemetry.track('excel.import.start',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,attributes:{importType:'oms-order',rowCountBucket:'unknown'}}) },
|
||||
onSuccess(value) { sessionId.value = value.id; localSession.value = value },
|
||||
onError() { kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs:Math.round(performance.now()-(importStartedAt??performance.now())),attributes:{importType:'oms-order',reasonCode:'UPLOAD_FAILED',rowCountBucket:'unknown'}}); terminalRecorded=true },
|
||||
})
|
||||
|
||||
const mappingMutation = useMutation({
|
||||
mutationFn: ({ id, mappings }: { id: string; mappings: KbxImportMapping[] }) => orderImportApi.saveMapping(id, mappings),
|
||||
onSuccess(value) { localSession.value = value },
|
||||
})
|
||||
|
||||
const savedMappingMutation = useMutation({
|
||||
mutationFn: ({ id, name, mappings }: { id: string; name: string; mappings: KbxImportMapping[] }) => orderImportApi.saveNamedMapping(id, name, mappings),
|
||||
})
|
||||
|
||||
const validationMutation = useMutation({
|
||||
mutationFn: orderImportApi.validate,
|
||||
onSuccess(value) { localSession.value = value; void sessionQuery.refetch() },
|
||||
})
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: orderImportApi.commit,
|
||||
onSuccess(value) { localSession.value = value; void sessionQuery.refetch() },
|
||||
onError() { kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs:Math.round(performance.now()-(importStartedAt??performance.now())),attributes:{importType:'oms-order',reasonCode:'COMMIT_REQUEST_FAILED',rowCountBucket:'unknown'}}); terminalRecorded=true },
|
||||
})
|
||||
|
||||
|
||||
async function runProblemAware<T>(action:()=>Promise<T>):Promise<T|null>{
|
||||
problem.value=null; retryAction=null
|
||||
try{return await action()}catch(error){if(isKbxProblem(error)){problem.value=error;retryAction=()=>runProblemAware(action);return null}throw error}
|
||||
}
|
||||
async function retryProblem(){const retry=retryAction;problem.value=null;retryAction=null;if(retry)await retry()}
|
||||
function dismissProblem(){problem.value=null;retryAction=null}
|
||||
|
||||
watch(session, value => {
|
||||
if(!value || terminalRecorded || importStartedAt==null) return
|
||||
const bucket=value.totalRows<1000?'0-999':value.totalRows<10000?'1k-9k':value.totalRows<100000?'10k-99k':'100k+'
|
||||
const durationMs=Math.round(performance.now()-importStartedAt)
|
||||
if(value.status==='completed'||value.status==='partially-completed'){kbxTelemetry.track('excel.import.completed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs,attributes:{importType:'oms-order',result:value.status,rowCountBucket:bucket}});terminalRecorded=true}
|
||||
if(value.status==='failed'){kbxTelemetry.track('excel.import.failed',{screenId:orderImportScreen.id,screenVersion:orderImportScreen.version,durationMs,attributes:{importType:'oms-order',reasonCode:'JOB_FAILED',rowCountBucket:bucket}});terminalRecorded=true}
|
||||
})
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionId,
|
||||
problem,
|
||||
busy: computed(() => uploadMutation.isPending.value || mappingMutation.isPending.value || validationMutation.isPending.value || commitMutation.isPending.value),
|
||||
upload: (file: File) => runProblemAware(()=>uploadMutation.mutateAsync(file)),
|
||||
saveMapping: (mappings: KbxImportMapping[]) => sessionId.value ? runProblemAware(()=>mappingMutation.mutateAsync({ id: sessionId.value!, mappings })) : Promise.resolve(null),
|
||||
saveNamedMapping: (name: string, mappings: KbxImportMapping[]) => sessionId.value ? runProblemAware(()=>savedMappingMutation.mutateAsync({ id: sessionId.value!, name, mappings })) : Promise.resolve(null),
|
||||
validate: () => sessionId.value ? runProblemAware(()=>validationMutation.mutateAsync(sessionId.value!)) : Promise.resolve(null),
|
||||
commit: () => sessionId.value ? runProblemAware(()=>commitMutation.mutateAsync(sessionId.value!)) : Promise.resolve(null),
|
||||
downloadTemplate: () => runProblemAware(()=>orderImportApi.downloadTemplate()),
|
||||
downloadErrors: () => sessionId.value ? runProblemAware(()=>orderImportApi.downloadErrors(sessionId.value!)) : Promise.resolve(null),
|
||||
reset: () => { sessionId.value = null; localSession.value = null; problem.value=null; retryAction=null },
|
||||
retryProblem,
|
||||
dismissProblem,
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { KbxLookupItem } from '@kbx/contracts'
|
||||
import { kbxFieldReadonly, resolveKbxRecordStatePolicy } from '@kbx/contracts'
|
||||
import {
|
||||
KbxButton,
|
||||
KbxDataGrid,
|
||||
KbxDateField,
|
||||
KbxFormGrid,
|
||||
KbxFormSection,
|
||||
KbxFormSpan,
|
||||
KbxInput,
|
||||
KbxLookup,
|
||||
KbxLookupDialog,
|
||||
KbxTransactionPage,
|
||||
KbxUnsavedChangesDialog,
|
||||
useKbxPageShortcuts,
|
||||
resolveKbxGridStatus,
|
||||
} from '@kbx/ui'
|
||||
import {
|
||||
orderLineColumns,
|
||||
orderRegisterScreen,
|
||||
orderStatePolicies,
|
||||
orderWorkflow,
|
||||
type OrderLineForm,
|
||||
} from './order-register.definition'
|
||||
import { useKbxWorkspaceBinding } from '../../../../shell/useKbxWorkspaceBinding'
|
||||
import { useOrderRegistration } from './useOrderRegistration'
|
||||
import { kbxStatusCatalog } from '../../../../registry/statusCatalog'
|
||||
|
||||
const route = useRoute()
|
||||
const vm = useOrderRegistration()
|
||||
useKbxWorkspaceBinding(vm.dirty, async () => Boolean(await vm.save()))
|
||||
|
||||
const detailGrid = ref<any>(null)
|
||||
const itemLookupOpen = ref(false)
|
||||
const activeLine = ref<OrderLineForm | null>(null)
|
||||
const activeLookupField = ref<keyof OrderLineForm & string>('itemCode')
|
||||
const pendingReset = ref<'new' | 'copy' | null>(null)
|
||||
const savingReset = ref(false)
|
||||
|
||||
const statePolicy = computed(() => resolveKbxRecordStatePolicy(orderStatePolicies, vm.status.value))
|
||||
const readonly = computed(() => statePolicy.value.editability === 'readonly')
|
||||
const displayStatus = computed(() => resolveKbxGridStatus({definitions:kbxStatusCatalog.orderLifecycle}, vm.status.value))
|
||||
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 },
|
||||
])
|
||||
const pageContext = computed(() => ({
|
||||
label: vm.orderNo.value ? `주문 ${vm.orderNo.value}` : '신규 주문',
|
||||
hint: vm.dirty.value ? '저장하지 않은 변경사항이 있습니다.' : `${displayStatus.value.label} 상태`,
|
||||
metrics: [
|
||||
{ key:'status', label:'상태', value:displayStatus.value.label },
|
||||
{ key:'lines', label:'상품', value:vm.lines.value.length },
|
||||
{ key:'errors', label:'오류', value:vm.errors.value.length, tone:'danger' as const, emphasis:vm.errors.value.length > 0 },
|
||||
],
|
||||
}))
|
||||
|
||||
function fieldReadonly(field:string) {
|
||||
return kbxFieldReadonly(statePolicy.value, field)
|
||||
}
|
||||
|
||||
function openGridLookup(event:{ row:OrderLineForm; field:keyof OrderLineForm & string; entity:string }) {
|
||||
if (event.entity !== 'item' || readonly.value) return
|
||||
activeLine.value = event.row
|
||||
activeLookupField.value = event.field
|
||||
itemLookupOpen.value = true
|
||||
}
|
||||
|
||||
|
||||
async function addLineAndFocus() {
|
||||
if (readonly.value) return
|
||||
vm.addLine()
|
||||
const line = vm.lines.value.at(-1)
|
||||
if (!line) return
|
||||
await nextTick()
|
||||
detailGrid.value?.focusCell?.(line.clientId, 'itemCode')
|
||||
}
|
||||
|
||||
function focusNextError() {
|
||||
detailGrid.value?.focusError?.(1)
|
||||
}
|
||||
|
||||
async function selectGridItem(item:KbxLookupItem<string>) {
|
||||
const line = activeLine.value
|
||||
if (!line) return
|
||||
vm.applyItemLookup(line, item)
|
||||
itemLookupOpen.value = false
|
||||
await nextTick()
|
||||
detailGrid.value?.focusNextEditableCell?.(line.clientId, activeLookupField.value)
|
||||
}
|
||||
|
||||
function applyReset(kind:'new' | 'copy') {
|
||||
if (kind === 'new') vm.createNew()
|
||||
else vm.copyCurrent()
|
||||
pendingReset.value = null
|
||||
}
|
||||
|
||||
function requestReset(kind:'new' | 'copy') {
|
||||
if (readonly.value && kind === 'copy') {
|
||||
applyReset(kind)
|
||||
return
|
||||
}
|
||||
if (vm.dirty.value) {
|
||||
pendingReset.value = kind
|
||||
return
|
||||
}
|
||||
applyReset(kind)
|
||||
}
|
||||
|
||||
async function saveThenReset() {
|
||||
const kind = pendingReset.value
|
||||
if (!kind) return
|
||||
savingReset.value = true
|
||||
try {
|
||||
if (await vm.save()) applyReset(kind)
|
||||
} finally {
|
||||
savingReset.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCommand(id:string) {
|
||||
if (id === 'save') await vm.save()
|
||||
else if (id === 'new') requestReset('new')
|
||||
else if (id === 'copy') requestReset('copy')
|
||||
else if (id === 'confirm') await vm.confirm()
|
||||
}
|
||||
|
||||
useKbxPageShortcuts([{ key:'F8', execute:() => vm.save() }])
|
||||
|
||||
onMounted(async () => {
|
||||
const id = String(route.params.orderId ?? '')
|
||||
if (id) await vm.load(id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxTransactionPage
|
||||
:screen="orderRegisterScreen"
|
||||
:status="vm.status.value"
|
||||
:dirty="vm.dirty.value"
|
||||
:version="vm.version.value"
|
||||
:errors="vm.errors.value"
|
||||
:workflow="orderWorkflow"
|
||||
:conflict="vm.conflict.value"
|
||||
:problem="vm.problem.value"
|
||||
:audit-entries="vm.auditEntries.value"
|
||||
:summary-items="summaryItems"
|
||||
:context="pageContext"
|
||||
header-title="주문 정보"
|
||||
header-description="주문 조건과 배송 기준을 먼저 확인한 뒤 상품 상세를 입력합니다."
|
||||
detail-title="주문 상품"
|
||||
:detail-count="vm.lines.value.length"
|
||||
detail-count-unit="종"
|
||||
detail-description="Enter 연속입력, F2 품목 조회, Excel 붙여넣기 문법을 동일하게 사용합니다."
|
||||
breadcrumb="OMS > 주문"
|
||||
@command="executeCommand"
|
||||
@transition="executeCommand"
|
||||
@reload-conflict="vm.reloadConflict"
|
||||
@dismiss-conflict="vm.dismissConflict"
|
||||
@retry-problem="vm.retryProblem"
|
||||
@dismiss-problem="vm.dismissProblem"
|
||||
>
|
||||
<template #header>
|
||||
<KbxFormSection title="기본정보">
|
||||
<KbxFormGrid>
|
||||
<KbxDateField v-model="vm.header.orderDate" label="주문일" required :readonly="fieldReadonly('orderDate')" :error="vm.fieldError('orderDate')" @update:model-value="vm.touch" />
|
||||
<KbxLookup v-model="vm.header.customerId" entity="customer" label="거래처" required :readonly="fieldReadonly('customerId')" :error="vm.fieldError('customerId')" @selected="vm.touch" />
|
||||
<KbxLookup v-model="vm.header.warehouseId" entity="warehouse" label="출고창고" required :readonly="fieldReadonly('warehouseId')" :error="vm.fieldError('warehouseId')" @selected="vm.touch" />
|
||||
<KbxInput v-model="vm.header.receiverName" label="수취인" required :readonly="fieldReadonly('receiverName')" :error="vm.fieldError('receiverName')" @update:model-value="vm.touch" />
|
||||
<KbxInput v-model="vm.header.phone" label="연락처" required :readonly="fieldReadonly('phone')" :error="vm.fieldError('phone')" @update:model-value="vm.touch" />
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
|
||||
<KbxFormSection title="배송정보">
|
||||
<KbxFormGrid>
|
||||
<KbxInput v-model="vm.header.postalCode" label="우편번호" :readonly="fieldReadonly('postalCode')" @update:model-value="vm.touch" />
|
||||
<KbxFormSpan span="full"><KbxInput v-model="vm.header.address1" label="주소" required :readonly="fieldReadonly('address1')" :error="vm.fieldError('address1')" @update:model-value="vm.touch" /></KbxFormSpan>
|
||||
<KbxFormSpan span="full"><KbxInput v-model="vm.header.address2" label="상세주소" :readonly="fieldReadonly('address2')" @update:model-value="vm.touch" /></KbxFormSpan>
|
||||
</KbxFormGrid>
|
||||
</KbxFormSection>
|
||||
</template>
|
||||
|
||||
<template #detail-actions>
|
||||
<KbxButton v-if="vm.errors.value.length" label="첫/다음 오류" variant="ghost" @click="focusNextError" />
|
||||
<KbxButton v-if="!readonly" label="행 추가" variant="secondary" @click="addLineAndFocus" />
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<section class="order-lines">
|
||||
<KbxDataGrid
|
||||
ref="detailGrid"
|
||||
:rows="vm.lines.value"
|
||||
:columns="orderLineColumns"
|
||||
row-key="clientId"
|
||||
aria-label="주문 상품"
|
||||
selection="multiple"
|
||||
:errors="vm.errors.value"
|
||||
:editable="!readonly"
|
||||
:editing-policy="{ allowRowAdd:true, allowRowDuplicate:true, fillDown:true, paste:true, errorNavigation:true }"
|
||||
@row-add-requested="vm.addLine"
|
||||
@row-duplicate-requested="vm.duplicateLines"
|
||||
@cell-changed="vm.onCellChanged"
|
||||
@lookup-requested="openGridLookup"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</KbxTransactionPage>
|
||||
|
||||
<KbxLookupDialog
|
||||
v-model:visible="itemLookupOpen"
|
||||
entity="item"
|
||||
title="품목"
|
||||
@select="selectGridItem"
|
||||
/>
|
||||
|
||||
<KbxUnsavedChangesDialog
|
||||
:open="Boolean(pendingReset)"
|
||||
:saving="savingReset"
|
||||
@stay="pendingReset=null"
|
||||
@discard="pendingReset && applyReset(pendingReset)"
|
||||
@save="saveThenReset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-lines {
|
||||
min-height:var(--kbx-grid-min-height);
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:var(--kbx-space-2);
|
||||
}
|
||||
</style>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../../registry/statusCatalog'
|
||||
|
||||
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:'2.0.0',states:
|
||||
kbxStatusCatalog.orderLifecycle
|
||||
.filter(state => ['NEW','DRAFT','CONFIRMED','ALLOCATED','PICKING','CHECKED','SHIPPED'].includes(state.value))
|
||||
.map(state => ({...state,terminal:state.value==='SHIPPED'})),
|
||||
transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'주문확정',permission:'oms.order.confirm',confirm:true}]}
|
||||
export const orderStatePolicies:KbxRecordStatePolicy[]=[
|
||||
{status:'NEW',editability:'editable'},
|
||||
{status:'DRAFT',editability:'editable'},
|
||||
{status:'CONFIRMED',editability:'readonly',message:'확정된 주문은 직접 수정할 수 없습니다.'},
|
||||
{status:'ALLOCATED',editability:'readonly'},
|
||||
{status:'PICKING',editability:'readonly'},
|
||||
{status:'CHECKED',editability:'readonly'},
|
||||
{status:'SHIPPED',editability:'readonly'},
|
||||
]
|
||||
|
||||
export const orderRegisterScreen = defineKbxScreen({
|
||||
id:'OMS-ORD-002',version:'1.4.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:{NEW:'oms.order.create',DRAFT:'oms.order.write'},allowedStatuses:['NEW','DRAFT'],requiresDirty:true},
|
||||
{id:'confirm',label:'주문확정',group:'workflow',permission:'oms.order.confirm',allowedStatuses:['DRAFT'],requiresClean:true,disabledReason:'변경사항을 저장한 뒤 주문을 확정하세요.',confirm:{title:'주문을 확정하시겠습니까?',detail:'확정 후 주문의 일반 입력 필드는 직접 수정할 수 없습니다.',level:'high',confirmLabel:'주문확정'}},
|
||||
{id:'copy',label:'주문복사',group:'edit'},
|
||||
{id:'excel',label:'엑셀',group:'output'},
|
||||
],telemetry:{enabled:true},
|
||||
})
|
||||
export const orderLineColumns:KbxGridColumn<OrderLineForm>[]=[
|
||||
{field:'itemCode',header:'품목코드',type:'lookup',width:130,editable:true,pinned:'left',lookup:{entity:'item'}},{field:'itemName',header:'품목명',width:220},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'orderQty',header:'수량',type:'quantity',width:95,editable:true},{field:'unitPrice',header:'단가',type:'money',width:120,editable:true},{field:'amount',header:'금액',type:'money',width:130},{field:'remark',header:'비고',width:220,editable:true},
|
||||
]
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
kbxDecimalSchema,
|
||||
kbxNullableEntityIdSchema,
|
||||
kbxRequiredTextSchema,
|
||||
kbxTextSchema,
|
||||
} from '../../../../validation/kbxFieldSchema'
|
||||
|
||||
export const orderHeaderSchema = z.object({
|
||||
orderDate: kbxRequiredTextSchema('orderDate', '주문일을 입력하세요.'),
|
||||
customerId: kbxNullableEntityIdSchema('customerId', '거래처를 선택하세요.'),
|
||||
warehouseId: kbxNullableEntityIdSchema('warehouseId', '출고창고를 선택하세요.'),
|
||||
receiverName: kbxRequiredTextSchema('receiverName'),
|
||||
phone: kbxRequiredTextSchema('phone'),
|
||||
postalCode: kbxTextSchema('postalCode').optional(),
|
||||
address1: kbxRequiredTextSchema('address1'),
|
||||
address2: kbxTextSchema('address2').optional(),
|
||||
})
|
||||
|
||||
export const orderLineSchema = z.object({
|
||||
clientId: z.string(),
|
||||
itemId: kbxNullableEntityIdSchema('itemId', '품목을 선택하세요.'),
|
||||
// Positive quantity is an order rule, so the field dictionary only supplies numeric structure.
|
||||
orderQty: kbxDecimalSchema('orderQty').positive('수량은 0보다 커야 합니다.'),
|
||||
unitPrice: kbxDecimalSchema('unitPrice').nonnegative('단가는 0 이상이어야 합니다.'),
|
||||
})
|
||||
|
||||
export const orderRegisterSchema = z.object({
|
||||
header: orderHeaderSchema,
|
||||
lines: z.array(orderLineSchema).min(1, '주문 품목을 한 건 이상 입력하세요.'),
|
||||
})
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { isKbxProblem, type KbxAuditEntry, type KbxProblem } from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../../http/generated/kbxApiClient'
|
||||
import type { KbxOrderLifecycleValue } from '../../../../registry/statusCatalog'
|
||||
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:KbxOrderLifecycleValue }
|
||||
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}})},
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import type { KbxAuditEntry, KbxConflictSnapshot, KbxProblem, KbxValidationError } from '@kbx/contracts'
|
||||
import { isKbxProblem } from '@kbx/contracts'
|
||||
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
|
||||
import { orderRegisterSchema } from './order-register.schema'
|
||||
import { orderRegisterApi, toKbxProblem } from './orderRegisterApi'
|
||||
import type { OrderLineForm } from './order-register.definition'
|
||||
import { orderRegisterScreen } from './order-register.definition'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import type { KbxOrderLifecycleValue } from '../../../../registry/statusCatalog'
|
||||
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<KbxOrderLifecycleValue>('NEW');const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null);const problem=ref<KbxProblem|null>(null);const failedAction=ref<'save'|'confirm'|null>(null)
|
||||
const header=reactive<OrderHeaderForm>({orderDate:localDate(),customerId:null,warehouseId:null,receiverName:'',phone:'',postalCode:'',address1:'',address2:''});const lines=ref<OrderLineForm[]>([newLine()])
|
||||
const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState();const validation=useKbxValidation();const mutation=useMutation({mutationFn:orderRegisterApi.save})
|
||||
const totalQty=computed(()=>lines.value.reduce((s,l)=>s+Number(l.orderQty||0),0));const totalAmount=computed(()=>lines.value.reduce((s,l)=>s+Number(l.amount||0),0))
|
||||
function addLine(){lines.value.push(newLine());touch()}function duplicateLines(source:OrderLineForm[]){if(!source.length)return;lines.value.push(...source.map(l=>({...l,clientId:crypto.randomUUID()})));touch()}function recalc(l:OrderLineForm){l.amount=Number(l.orderQty||0)*Number(l.unitPrice||0)}
|
||||
async function onCellChanged(event:{row:OrderLineForm;field:keyof OrderLineForm;newValue:unknown}){if(event.field==='itemCode'){const code=String(event.newValue??'').trim();if(!code)Object.assign(event.row,{itemId:null,itemName:'',availableQty:null});else{const item=await orderRegisterApi.resolveItemByCode(code);if(!item){Object.assign(event.row,{itemId:null,itemName:'',availableQty:null});validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'})}else{Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName});validation.setRowFieldError(event.row.clientId,'itemCode')}}}if(event.field==='orderQty'||event.field==='unitPrice')recalc(event.row);touch()}
|
||||
function applyItemLookup(line:OrderLineForm,item:{id:string;code:string;displayName:string}){Object.assign(line,{itemId:item.id,itemCode:item.code,itemName:item.displayName});validation.setRowFieldError(line.clientId,'itemCode');touch()}
|
||||
function toValidationErrors():KbxValidationError[]{const parsed=orderRegisterSchema.safeParse({header,lines:lines.value});if(parsed.success)return[];return parsed.error.issues.map(issue=>{const p=issue.path;if(p[0]==='header')return{field:String(p[1]??''),code:'CLIENT_VALIDATION',message:issue.message};if(p[0]==='lines'&&typeof p[1]==='number'){const raw=String(p[2]??'');return{rowKey:lines.value[p[1]]?.clientId,field:raw==='itemId'?'itemCode':raw,code:'CLIENT_VALIDATION',message:issue.message}}return{code:'CLIENT_VALIDATION',message:issue.message}})}
|
||||
async function refreshAudit(){auditEntries.value=orderId.value?await orderRegisterApi.audit(orderId.value):[]}
|
||||
async function load(id:string){problem.value=null;failedAction.value=null;const r=await orderRegisterApi.get(id);orderId.value=r.orderId;orderNo.value=r.orderNo;version.value=r.version;status.value=r.status;Object.assign(header,{orderDate:r.orderDate,customerId:r.customerId,warehouseId:r.warehouseId,receiverName:r.receiverName,phone:r.phone,postalCode:r.postalCode??'',address1:r.address1,address2:r.address2??''});lines.value=r.lines.map(l=>({...l,availableQty:null}));validation.clear();conflict.value=null;resetDirty();await refreshAudit()}
|
||||
async function save(){problem.value=null;failedAction.value=null;const task=startKbxTask(orderRegisterScreen.id,orderRegisterScreen.version,'save-order');validation.clear();const local=toValidationErrors();if(local.length){validation.setErrors(local);task.abandon('client-validation');return false}try{const r=await mutation.mutateAsync({orderId:orderId.value,version:version.value,orderDate:header.orderDate,customerId:header.customerId!,warehouseId:header.warehouseId!,receiverName:header.receiverName,phone:header.phone,postalCode:header.postalCode||undefined,address1:header.address1,address2:header.address2||undefined,lines:lines.value.map(l=>({clientId:l.clientId,itemId:l.itemId!,orderQty:l.orderQty,unitPrice:l.unitPrice,remark:l.remark||undefined}))});orderId.value=r.orderId;orderNo.value=r.orderNo;version.value=r.version;status.value=r.status;markSaved();conflict.value=null;await refreshAudit();task.complete('success');return true}catch(error){const issue=toKbxProblem(error);if(issue?.type==='validation')validation.applyProblem(issue);if(issue?.type==='conflict'&&orderId.value){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:issue.code,title:issue.title,detail:'입력 중인 주문은 유지됩니다. 최신 값과 비교한 뒤 다시 읽으세요.',entityId:orderId.value,requestedVersion:version.value,currentVersion:issue.currentVersion??latest.version,changes:[['orderDate','주문일'],['customerId','거래처'],['warehouseId','출고창고'],['receiverName','수취인'],['address1','주소']].map(([field,label])=>({field,label,mine:(header as any)[field],latest:(latest as any)[field]}))}}if(issue && !['validation','conflict'].includes(issue.type)){ problem.value=issue; failedAction.value='save' }task.abandon(issue?.type??'system');if(!issue)throw error;return false}}
|
||||
async function confirm(){if(!orderId.value||version.value==null||dirty.value)return;problem.value=null;failedAction.value=null;try{const r=await orderRegisterApi.confirm(orderId.value,version.value);version.value=r.version;status.value=r.status;await refreshAudit()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await orderRegisterApi.get(orderId.value);conflict.value={code:e.code,title:e.title,requestedVersion:version.value,currentVersion:e.currentVersion??latest.version,entityId:orderId.value};return}if(isKbxProblem(e)){problem.value=e;failedAction.value='confirm';return}throw e}}
|
||||
async function reloadConflict(){if(orderId.value)await load(orderId.value)}
|
||||
function dismissConflict(){conflict.value=null}
|
||||
async function retryProblem(){const action=failedAction.value;problem.value=null;failedAction.value=null;if(action==='confirm')return confirm();if(action==='save')return save()}
|
||||
function dismissProblem(){problem.value=null;failedAction.value=null}
|
||||
function createNew(){orderId.value=undefined;orderNo.value=undefined;version.value=undefined;status.value='NEW';Object.assign(header,{orderDate:localDate(),customerId:null,warehouseId:null,receiverName:'',phone:'',postalCode:'',address1:'',address2:''});lines.value=[newLine()];auditEntries.value=[];conflict.value=null;problem.value=null;failedAction.value=null;validation.clear();resetDirty()}
|
||||
function copyCurrent(){
|
||||
orderId.value=undefined;orderNo.value=undefined;version.value=undefined;status.value='NEW'
|
||||
lines.value=lines.value.map(line=>({...line,clientId:crypto.randomUUID()}))
|
||||
auditEntries.value=[];conflict.value=null;problem.value=null;failedAction.value=null;validation.clear();resetDirty();touch()
|
||||
}
|
||||
return{orderId,orderNo,version,status,header,lines,dirty,errors:validation.errors,fieldError:validation.fieldError,totalQty,totalAmount,saving:computed(()=>mutation.isPending.value),auditEntries,conflict,problem,addLine,duplicateLines,onCellChanged,applyItemLookup,save,confirm,load,reloadConflict,dismissConflict,retryProblem,dismissProblem,createNew,copyCurrent,touch}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { KbxButton, KbxDataState, KbxDrawer, KbxStatus, resolveKbxGridStatus } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../../registry/statusCatalog'
|
||||
import type { OrderDetailResponse } from './orderApi'
|
||||
import type { OrderSearchRow } from './order-list.definition'
|
||||
|
||||
const props = defineProps<{
|
||||
open:boolean
|
||||
summary:OrderSearchRow | null
|
||||
detail:OrderDetailResponse | null
|
||||
loading?:boolean
|
||||
error?:string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open':[boolean]
|
||||
retry:[]
|
||||
fullDetail:[string]
|
||||
}>()
|
||||
|
||||
const title = computed(() => props.summary?.orderNo ? `주문 ${props.summary.orderNo}` : '주문 상세')
|
||||
const resolvedStatus = computed(() => resolveKbxGridStatus({definitions:kbxStatusCatalog.orderLifecycle}, props.detail?.status))
|
||||
|
||||
|
||||
const maskedPhone = computed(() => {
|
||||
const value = props.detail?.phone ?? ''
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length < 7) return value
|
||||
return `${digits.slice(0,3)}-****-${digits.slice(-4)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxDrawer :open="open" :title="title" width="580px" @update:open="emit('update:open',$event)">
|
||||
<KbxDataState v-if="loading" state="loading" title="주문 상세를 불러오는 중입니다." />
|
||||
<KbxDataState v-else-if="error" state="error" title="주문 상세를 불러오지 못했습니다." :detail="error" action-label="다시 조회" @action="emit('retry')" />
|
||||
<div v-else-if="detail" class="order-detail" data-kbx-order-detail>
|
||||
<section class="order-detail__headline">
|
||||
<div><span>주문상태</span><KbxStatus :label="resolvedStatus.label" :semantic="resolvedStatus.semantic" :unknown="resolvedStatus.unknown" :raw-value="resolvedStatus.rawValue" /></div>
|
||||
<div><span>판매채널</span><strong>{{summary?.channelName || '-'}}</strong></div>
|
||||
<div><span>주문일</span><strong>{{detail.orderDate}}</strong></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>수취인</h3>
|
||||
<dl><dt>이름</dt><dd>{{detail.receiverName}}</dd><dt>연락처</dt><dd>{{maskedPhone}}</dd></dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>배송</h3>
|
||||
<p>{{detail.postalCode ? `[${detail.postalCode}] ` : ''}}{{detail.address1}} {{detail.address2}}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>상품 <small>{{detail.lines.length}}종</small></h3>
|
||||
<div class="order-detail__lines">
|
||||
<article v-for="line in detail.lines" :key="line.id || line.clientId">
|
||||
<div><b>{{line.itemCode}}</b><span>{{line.itemName}}</span></div>
|
||||
<strong>{{Number(line.orderQty).toLocaleString('ko-KR')}} EA</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="order-detail__footer">
|
||||
<KbxButton label="닫기" variant="secondary" @click="emit('update:open',false)" />
|
||||
<KbxButton v-if="detail" label="전체 상세" variant="primary" @click="emit('fullDetail',detail.orderId)" />
|
||||
</div>
|
||||
</template>
|
||||
</KbxDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-detail{display:flex;flex-direction:column;gap:var(--kbx-space-4);font-size:var(--kbx-font-md)}
|
||||
.order-detail section{border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);padding-bottom:var(--kbx-space-3)}
|
||||
.order-detail section:last-child{border-bottom:0}.order-detail h3{margin:0 0 var(--kbx-space-2);font-size:var(--kbx-font-lg)}
|
||||
.order-detail h3 small{margin-left:var(--kbx-space-1);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);font-weight:400}
|
||||
.order-detail__headline{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--kbx-space-2)}
|
||||
.order-detail__headline>div{display:grid;gap:var(--kbx-space-1)}.order-detail__headline span,dt{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
|
||||
dl{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);margin:0}dd{margin:0}.order-detail p{margin:0;line-height:1.6}
|
||||
.order-detail__lines{border:var(--kbx-border-width) solid var(--kbx-color-border)}.order-detail__lines article{min-height:var(--kbx-control-lg);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);padding:var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.order-detail__lines article:last-child{border-bottom:0}.order-detail__lines article>div{display:grid;min-width:0}.order-detail__lines span{color:var(--kbx-color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.order-detail__footer{display:flex;justify-content:flex-end;gap:var(--kbx-space-2)}
|
||||
@media(max-width:42rem){.order-detail__headline{grid-template-columns:1fr}}
|
||||
</style>
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
KbxDataGrid,
|
||||
KbxListPage,
|
||||
KbxQuickFilterBar,
|
||||
KbxSearchPanel,
|
||||
KbxSummaryBar,
|
||||
useKbxPageShortcuts,
|
||||
} from '@kbx/ui'
|
||||
import { orderColumns, orderListScreen, orderSearchFields, type OrderSearchRow } from './order-list.definition'
|
||||
import { orderApi, type OrderDetailResponse } from './orderApi'
|
||||
import { orderImportApi } from '../import/orderImportApi'
|
||||
import OrderDetailDrawer from './OrderDetailDrawer.vue'
|
||||
import { useOrderSearch } from './useOrderSearch'
|
||||
import { useKbxExperiment } from '../../../../experiments/kbxExperimentClient'
|
||||
|
||||
const vm = useOrderSearch()
|
||||
const router = useRouter()
|
||||
const grid = ref<any>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailSummary = ref<OrderSearchRow | null>(null)
|
||||
const detail = ref<OrderDetailResponse | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const pageNotice = ref('')
|
||||
let detailSequence = 0
|
||||
|
||||
const exceptionSummaryExperiment = useKbxExperiment(
|
||||
orderListScreen.id,
|
||||
orderListScreen.version,
|
||||
'exp.oms.order-list.exception-summary-v2',
|
||||
'information-emphasis',
|
||||
)
|
||||
|
||||
useKbxPageShortcuts([{ key:'F3', execute:vm.executeSearch }])
|
||||
|
||||
async function selectQuickFilter(key:string) {
|
||||
vm.search.exceptionOnly = false
|
||||
vm.search.status = null
|
||||
if (key === 'new') vm.search.status = 'NEW'
|
||||
if (key === 'ready') vm.search.status = 'READY'
|
||||
if (key === 'exceptions') vm.search.exceptionOnly = true
|
||||
await vm.executeSearch()
|
||||
}
|
||||
|
||||
async function openDetail(row:OrderSearchRow) {
|
||||
const sequence = ++detailSequence
|
||||
detailSummary.value = row
|
||||
detail.value = null
|
||||
detailError.value = ''
|
||||
detailLoading.value = true
|
||||
detailOpen.value = true
|
||||
try {
|
||||
const result = await orderApi.getDetail(row.id)
|
||||
if (sequence === detailSequence) detail.value = result
|
||||
} catch {
|
||||
if (sequence === detailSequence) detailError.value = '네트워크 상태를 확인한 후 다시 시도하세요.'
|
||||
} finally {
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailSequence += 1
|
||||
detailOpen.value = false
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
async function openFullDetail(orderId:string) {
|
||||
closeDetail()
|
||||
await router.push({ name:'oms-order-edit', params:{ orderId } })
|
||||
}
|
||||
|
||||
async function executeCommand(id:string) {
|
||||
pageNotice.value = ''
|
||||
if (id === 'excel.export') {
|
||||
const total = vm.result.value?.totalCount ?? 0
|
||||
if (total > vm.rows.value.length) {
|
||||
pageNotice.value = `전체 ${total.toLocaleString('ko-KR')}건 중 현재 로드된 ${vm.rows.value.length.toLocaleString('ko-KR')}건만 있습니다. 누락 방지를 위해 전체 조회결과 Export API가 연결될 때까지 다운로드를 차단합니다.`
|
||||
return
|
||||
}
|
||||
grid.value?.exportCsv?.()
|
||||
pageNotice.value = `${total.toLocaleString('ko-KR')}건을 CSV로 다운로드했습니다.`
|
||||
return
|
||||
}
|
||||
if (id === 'excel.template') {
|
||||
await orderImportApi.downloadTemplate()
|
||||
return
|
||||
}
|
||||
if (id === 'excel.import') return router.push({ name:'oms-order-import' })
|
||||
if (id === 'excel.paste') return router.push({ name:'oms-order-import', query:{ source:'clipboard' } })
|
||||
if (id === 'excel.history') return router.push({ name:'oms-order-import', query:{ view:'history' } })
|
||||
await vm.executeCommand(id)
|
||||
}
|
||||
|
||||
const quickFilters = computed(() => {
|
||||
const counters = vm.result.value?.counters
|
||||
if (!counters) return []
|
||||
return [
|
||||
{ key:'all', label:'전체', count:counters.all, active:!vm.search.status && !vm.search.exceptionOnly },
|
||||
{ key:'new', label:'신규', count:counters.new, active:vm.search.status === 'NEW' },
|
||||
{ key:'ready', label:'출고대기', count:counters.readyToShip, active:vm.search.status === 'READY' },
|
||||
{ key:'exceptions', label:'오류', count:counters.exceptions, active:vm.search.exceptionOnly, tone:'danger' as const },
|
||||
]
|
||||
})
|
||||
|
||||
const pageContext = computed(() => vm.result.value ? {
|
||||
label:`${vm.appliedSearch.value.from} ~ ${vm.appliedSearch.value.to}`,
|
||||
hint:vm.appliedSearch.value.exceptionOnly ? '예외 주문만 조회 중' : '현재 적용된 조회조건 기준',
|
||||
metrics:[
|
||||
{ key:'result', label:'조회', value:vm.result.value.totalCount },
|
||||
{ key:'selected', label:'선택', value:vm.selectionCount.value },
|
||||
{ key:'exceptions', label:'오류', value:vm.result.value.counters.exceptions, tone:'danger' as const, emphasis:vm.result.value.counters.exceptions > 0 },
|
||||
],
|
||||
} : null)
|
||||
|
||||
const summaryItems = computed(() => vm.result.value ? [
|
||||
{ key:'rows', label:'조회', value:`${vm.result.value.totalCount.toLocaleString('ko-KR')}건` },
|
||||
{ key:'selected', label:'선택', value:`${vm.selectionCount.value.toLocaleString('ko-KR')}건` },
|
||||
{ key:'qty', label:'수량', value:vm.result.value.totalQty },
|
||||
{ key:'amount', label:'금액', value:`₩${vm.result.value.totalAmount.toLocaleString('ko-KR')}`, emphasis:true },
|
||||
] : [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxListPage
|
||||
:screen="orderListScreen"
|
||||
:selection-count="vm.selectionCount.value"
|
||||
:context="pageContext"
|
||||
:content-state="vm.error.value ? 'error' : vm.loading.value && !vm.result.value ? 'loading' : !vm.result.value ?'idle' : vm.result.value.totalCount === 0 ? 'empty' : 'ready'"
|
||||
:refreshing="vm.loading.value && Boolean(vm.result.value)"
|
||||
:problem="vm.problem.value"
|
||||
empty-action-label="조회조건 초기화"
|
||||
breadcrumb="OMS > 주문"
|
||||
@command="executeCommand"
|
||||
@empty-action="vm.resetAndSearch"
|
||||
@retry-problem="vm.retryProblem"
|
||||
@dismiss-problem="vm.dismissProblem"
|
||||
>
|
||||
<template #notice><div v-if="pageNotice || vm.preferenceNotice.value" class="page-notice" role="status">{{pageNotice || vm.preferenceNotice.value}}</div></template>
|
||||
<template #search>
|
||||
<KbxSearchPanel
|
||||
:model-value="vm.search"
|
||||
:fields="orderSearchFields"
|
||||
remember
|
||||
saved-search
|
||||
:remember-checked="vm.rememberSearch.value"
|
||||
@update:model-value="value => Object.assign(vm.search, value)"
|
||||
@update:remember-checked="vm.setRememberSearch"
|
||||
@saved-search-requested="vm.saveCurrentSearchDefaults"
|
||||
@search="vm.executeSearch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #quick-filter>
|
||||
<div v-if="vm.result.value && exceptionSummaryExperiment.is('exception-summary')" class="experiment-summary">
|
||||
<strong>확인 필요</strong> · 오류 {{vm.result.value.counters.exceptions.toLocaleString()}}건 — 기존 조회·처리 문법은 그대로 유지됩니다.
|
||||
</div>
|
||||
<KbxQuickFilterBar v-if="quickFilters.length" :items="quickFilters" @select="selectQuickFilter" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<KbxDataGrid
|
||||
ref="grid"
|
||||
:rows="vm.rows.value"
|
||||
:columns="orderColumns"
|
||||
row-key="id"
|
||||
aria-label="주문 조회 결과"
|
||||
selection="multiple"
|
||||
:loading="vm.loading.value"
|
||||
:total-count="vm.result.value?.totalCount ?? 0"
|
||||
:selection-state="vm.selectionState.value"
|
||||
allow-all-filtered-selection
|
||||
export-file-name="orders-current-page.csv"
|
||||
@selection-state-changed="value => vm.selectionState.value = value"
|
||||
@drill-down-requested="openDetail"
|
||||
@row-double-clicked="openDetail"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #summary>
|
||||
<KbxSummaryBar v-if="summaryItems.length" :items="summaryItems" />
|
||||
</template>
|
||||
|
||||
<template #detail>
|
||||
<OrderDetailDrawer
|
||||
:open="detailOpen"
|
||||
:summary="detailSummary"
|
||||
:detail="detail"
|
||||
:loading="detailLoading"
|
||||
:error="detailError"
|
||||
@update:open="value => value ? detailOpen = true : closeDetail()"
|
||||
@retry="detailSummary && openDetail(detailSummary)"
|
||||
@full-detail="openFullDetail"
|
||||
/>
|
||||
</template>
|
||||
</KbxListPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-notice{padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}
|
||||
.experiment-summary {
|
||||
margin-bottom:var(--kbx-space-1);
|
||||
padding:var(--kbx-space-2);
|
||||
border:var(--kbx-border-width) solid var(--kbx-color-border);
|
||||
background:var(--kbx-color-surface-muted);
|
||||
}
|
||||
</style>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/contracts'
|
||||
import { kbxStatusCatalog, kbxStatusOptions } from '../../../../registry/statusCatalog'
|
||||
|
||||
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.5.0',
|
||||
module: 'OMS',
|
||||
type: 'list', templateCode:'T01',
|
||||
title: '주문관리',
|
||||
description: '주문을 조회하고 예외 및 출고대상을 일괄 처리합니다.',
|
||||
permissions: ['oms.order.read'],
|
||||
helpKey: 'OMS-ORD-001',
|
||||
telemetry: { enabled: true },
|
||||
commands: [
|
||||
{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' },
|
||||
{ id: 'new', label: '신규', group: 'edit', permission: 'oms.order.create' },
|
||||
{
|
||||
id: 'ship', label: '출고지시', group: 'workflow', variant: 'primary',
|
||||
requiresSelection: true, minSelection: 1, permission: 'oms.order.ship'
|
||||
},
|
||||
{ id: 'excel', label: '엑셀', group: 'output', menu: 'excel' },
|
||||
],
|
||||
})
|
||||
|
||||
export const orderColumns: KbxGridColumn<OrderSearchRow>[] = [
|
||||
{ field: 'orderNo', header: '주문번호', type: 'link', width: 150, pinned: 'left' },
|
||||
{ field: 'channelName', header: '판매채널', width: 110 },
|
||||
{ field: 'orderedAt', header: '주문일시', type: 'datetime', width: 160 },
|
||||
{ field: 'customerName', header: '주문자', width: 120 },
|
||||
{ field: 'itemSummary', header: '대표상품', width: 240 },
|
||||
{ field: 'totalQty', header: '수량', type: 'quantity', width: 90 },
|
||||
{ field: 'amount', header: '금액', type: 'money', width: 130 },
|
||||
{ field: 'allocationStatus', header: '재고', type: 'status', width: 110, statusMap:{definitions:kbxStatusCatalog.orderAllocation} },
|
||||
{ field: 'shipmentStatus', header: '출고상태', type: 'status', width: 120, statusMap:{definitions:kbxStatusCatalog.orderShipment} },
|
||||
{ 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: kbxStatusOptions(kbxStatusCatalog.orderShipment) },
|
||||
{ key: 'keyword', label: '통합검색', type: 'text', width: 'lg', placeholder: '주문번호/주문자/상품' },
|
||||
]
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { kbxApi } from '../../../../http/generated/kbxApiClient'
|
||||
import type { KbxBulkSelectionRequest } from '@kbx/contracts'
|
||||
import type { OrderSearchRow } from './order-list.definition'
|
||||
import type { KbxOrderLifecycleValue } from '../../../../registry/statusCatalog'
|
||||
|
||||
export interface OrderSearchFilter {
|
||||
from: string
|
||||
to: string
|
||||
channelId?: string | null
|
||||
status?: string | null
|
||||
exceptionOnly?: boolean
|
||||
keyword?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type OrderBulkFilter = Omit<OrderSearchFilter, 'page' | 'pageSize'>
|
||||
|
||||
export interface OrderSearchResponse {
|
||||
items: OrderSearchRow[]
|
||||
totalCount: number
|
||||
totalQty: number
|
||||
totalAmount: number
|
||||
counters: { all: number; new: number; readyToShip: number; exceptions: number }
|
||||
}
|
||||
|
||||
export interface ShipOrdersResult { requested: number; accepted: number; rejected: number }
|
||||
|
||||
export interface OrderDetailResponse {
|
||||
orderId:string
|
||||
orderNo:string
|
||||
version:number
|
||||
status:KbxOrderLifecycleValue
|
||||
orderDate:string
|
||||
customerId:string
|
||||
warehouseId:string
|
||||
receiverName:string
|
||||
phone:string
|
||||
postalCode?:string
|
||||
address1:string
|
||||
address2?:string
|
||||
lines:Array<{id:string;clientId:string;itemId:string;itemCode:string;itemName:string;orderQty:number;unitPrice:number;amount:number;remark:string}>
|
||||
}
|
||||
|
||||
export const orderApi = {
|
||||
search(filter: OrderSearchFilter) {
|
||||
return kbxApi.request<OrderSearchResponse>('oms.orders.search', { query: filter })
|
||||
},
|
||||
|
||||
getDetail(orderId:string) {
|
||||
return kbxApi.request<OrderDetailResponse>('oms.orders.get', { path:{ id:orderId } })
|
||||
},
|
||||
|
||||
ship(selection: KbxBulkSelectionRequest<OrderBulkFilter>) {
|
||||
return kbxApi.request<ShipOrdersResult, KbxBulkSelectionRequest<OrderBulkFilter>>('oms.orders.ship', {
|
||||
body: selection,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
})
|
||||
},
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { computed, inject, onMounted, reactive, ref } from 'vue'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { isKbxProblem, type KbxProblem, type KbxSelectionState } from '@kbx/contracts'
|
||||
import { toKbxBulkSelectionRequest } from '@kbx/ui'
|
||||
import { orderApi, type OrderBulkFilter, type OrderSearchFilter } from './orderApi'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { orderListScreen } from './order-list.definition'
|
||||
import { KbxScreenPreferenceScopeKey, loadScreenPreference, saveScreenPreference } from '../../../../preferences/screenPreferenceStore'
|
||||
import { kbxStatusCatalog, normalizeKbxStatusValue } from '../../../../registry/statusCatalog'
|
||||
|
||||
function todayIso() { return new Date().toISOString().slice(0, 10) }
|
||||
|
||||
export function useOrderSearch() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const queryClient = useQueryClient()
|
||||
const selectionState = ref<KbxSelectionState<string>>({ mode:'explicit', selectedIds:[] })
|
||||
const preferenceScope = inject(KbxScreenPreferenceScopeKey, ref(''))
|
||||
const rememberSearch = ref(false)
|
||||
const savedSearchDefaults = ref<Record<string, unknown> | undefined>()
|
||||
const preferenceNotice = ref('')
|
||||
const problem = ref<KbxProblem | null>(null)
|
||||
const failedCommand = ref<'ship' | null>(null)
|
||||
|
||||
const search = reactive<OrderSearchFilter>({
|
||||
from: todayIso(), to: todayIso(), channelId: null, status: null,
|
||||
exceptionOnly: false, keyword: '', page: 1, pageSize: 100,
|
||||
})
|
||||
const appliedSearch = ref<OrderSearchFilter>({ ...search })
|
||||
|
||||
function firstQueryValue(value: unknown) {
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
function hydrateSearchFromRoute() {
|
||||
let contextual = false
|
||||
const from = String(firstQueryValue(route.query.from) ?? '')
|
||||
const to = String(firstQueryValue(route.query.to) ?? '')
|
||||
const status = String(firstQueryValue(route.query.status) ?? '')
|
||||
const exceptionOnly = String(firstQueryValue(route.query.exceptionOnly) ?? '')
|
||||
const keyword = String(firstQueryValue(route.query.keyword) ?? '')
|
||||
const channelId = String(firstQueryValue(route.query.channelId) ?? '')
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(from)) { search.from = from; contextual = true }
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(to)) { search.to = to; contextual = true }
|
||||
const canonicalStatus = normalizeKbxStatusValue(kbxStatusCatalog.orderShipment, status)
|
||||
if (canonicalStatus) { search.status = canonicalStatus; contextual = true }
|
||||
if (exceptionOnly === 'true') { search.exceptionOnly = true; contextual = true }
|
||||
if (keyword) { search.keyword = keyword.slice(0, 100); contextual = true }
|
||||
if (channelId) { search.channelId = channelId.slice(0, 64); contextual = true }
|
||||
return contextual
|
||||
}
|
||||
|
||||
|
||||
function safePersistableSearch() {
|
||||
return {
|
||||
from: /^\d{4}-\d{2}-\d{2}$/.test(search.from) ? search.from : todayIso(),
|
||||
to: /^\d{4}-\d{2}-\d{2}$/.test(search.to) ? search.to : todayIso(),
|
||||
channelId: typeof search.channelId === 'string' ? search.channelId.slice(0, 64) : null,
|
||||
status: normalizeKbxStatusValue(kbxStatusCatalog.orderShipment, search.status),
|
||||
exceptionOnly: Boolean(search.exceptionOnly),
|
||||
}
|
||||
}
|
||||
|
||||
function applySafeSearchDefaults(value: Record<string, unknown> | undefined) {
|
||||
if (!value) return false
|
||||
let applied = false
|
||||
if (typeof value.from === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.from)) { search.from = value.from; applied = true }
|
||||
if (typeof value.to === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.to)) { search.to = value.to; applied = true }
|
||||
if (typeof value.channelId === 'string' && value.channelId.length <= 64) { search.channelId = value.channelId || null; applied = true }
|
||||
if (value.channelId === null) { search.channelId = null; applied = true }
|
||||
const canonicalStatus = normalizeKbxStatusValue(kbxStatusCatalog.orderShipment, value.status)
|
||||
if (canonicalStatus) { search.status = canonicalStatus; applied = true }
|
||||
if (value.status === null) { search.status = null; applied = true }
|
||||
if (typeof value.exceptionOnly === 'boolean') { search.exceptionOnly = value.exceptionOnly; applied = true }
|
||||
// Free-text keyword is intentionally never restored from local preference storage.
|
||||
search.keyword = ''
|
||||
return applied
|
||||
}
|
||||
|
||||
function persistPreference(message: string) {
|
||||
const scope = preferenceScope.value
|
||||
if (!scope) return false
|
||||
const saved = saveScreenPreference(scope, {
|
||||
screenId: orderListScreen.id,
|
||||
screenVersion: orderListScreen.version,
|
||||
rememberSearch: rememberSearch.value,
|
||||
searchDefaults: savedSearchDefaults.value,
|
||||
})
|
||||
preferenceNotice.value = saved ? message : '개인화 설정을 저장하지 못했습니다. 현재 업무는 계속 사용할 수 있습니다.'
|
||||
return saved
|
||||
}
|
||||
|
||||
function setRememberSearch(value: boolean) {
|
||||
rememberSearch.value = value
|
||||
if (value) savedSearchDefaults.value = safePersistableSearch()
|
||||
persistPreference(value ? '마지막 조회조건 기억을 켰습니다. 자유검색어는 저장하지 않습니다.' : '마지막 조회조건 기억을 껐습니다.')
|
||||
}
|
||||
|
||||
function saveCurrentSearchDefaults() {
|
||||
savedSearchDefaults.value = safePersistableSearch()
|
||||
persistPreference('현재 조회조건을 이 화면의 기본조건으로 저장했습니다. 자유검색어는 저장하지 않습니다.')
|
||||
}
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['oms', 'orders', appliedSearch.value]),
|
||||
queryFn: () => orderApi.search({ ...appliedSearch.value }),
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const shipMutation = useMutation({
|
||||
mutationFn: (selection: ReturnType<typeof currentBulkSelection>) => orderApi.ship(selection),
|
||||
onSuccess: async () => {
|
||||
selectionState.value = { mode:'explicit', selectedIds:[] }
|
||||
await queryClient.invalidateQueries({ queryKey: ['oms', 'orders'] })
|
||||
await query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const selectionCount = computed(() => selectionState.value.mode === 'all-filtered'
|
||||
? Math.max((query.data.value?.totalCount ?? 0) - (selectionState.value.excludedIds?.length ?? 0), 0)
|
||||
: selectionState.value.selectedIds.length)
|
||||
|
||||
function currentBulkFilter(): OrderBulkFilter {
|
||||
return {
|
||||
from: appliedSearch.value.from, to: appliedSearch.value.to, channelId: appliedSearch.value.channelId,
|
||||
status: appliedSearch.value.status, exceptionOnly: appliedSearch.value.exceptionOnly, keyword: appliedSearch.value.keyword,
|
||||
}
|
||||
}
|
||||
|
||||
function currentBulkSelection() {
|
||||
return toKbxBulkSelectionRequest(selectionState.value, currentBulkFilter())
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
const started=performance.now();selectionState.value={mode:'explicit',selectedIds:[]};appliedSearch.value={...search}
|
||||
kbxTelemetry.track('command.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{commandId:'search',operationKind:'query'}})
|
||||
kbxTelemetry.track('search.execute',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,attributes:{resultBucket:'requested'}})
|
||||
await query.refetch()
|
||||
if (rememberSearch.value) { savedSearchDefaults.value = safePersistableSearch(); persistPreference('마지막 조회조건을 기억했습니다. 자유검색어는 저장하지 않습니다.') }
|
||||
kbxTelemetry.track('command.succeeded',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,durationMs:Math.round(performance.now()-started),attributes:{commandId:'search',operationKind:'query'}})
|
||||
}
|
||||
|
||||
|
||||
async function resetAndSearch() {
|
||||
Object.assign(search, { from:todayIso(), to:todayIso(), channelId:null, status:null, exceptionOnly:false, keyword:'', page:1, pageSize:100 })
|
||||
await executeSearch()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const contextual = hydrateSearchFromRoute()
|
||||
const preference = loadScreenPreference(preferenceScope.value, orderListScreen.id, orderListScreen.version)
|
||||
rememberSearch.value = Boolean(preference?.rememberSearch)
|
||||
savedSearchDefaults.value = preference?.searchDefaults
|
||||
if (!contextual && savedSearchDefaults.value) applySafeSearchDefaults(savedSearchDefaults.value)
|
||||
if (contextual) void executeSearch()
|
||||
})
|
||||
|
||||
async function executeCommand(commandId: string) {
|
||||
const handlers: Record<string, () => unknown | Promise<unknown>> = {
|
||||
search: executeSearch,
|
||||
new: () => router.push({ name: 'oms-order-new' }),
|
||||
ship: async () => {
|
||||
const task=startKbxTask(orderListScreen.id,orderListScreen.version,'ship-orders');task.interaction('bulk-command','ship')
|
||||
kbxTelemetry.track('grid.bulk_action',{screenId:orderListScreen.id,screenVersion:orderListScreen.version,taskSessionId:task.id,attributes:{commandId:'ship',countBucket:selectionCount.value<10?'1-9':selectionCount.value<100?'10-99':'100+',selectionMode:selectionState.value.mode}})
|
||||
try{await shipMutation.mutateAsync(currentBulkSelection());problem.value=null;failedCommand.value=null;task.complete('success')}catch(error){task.abandon('failed');if(isKbxProblem(error)){problem.value=error;failedCommand.value='ship';return}throw error}
|
||||
},
|
||||
}
|
||||
return handlers[commandId]?.()
|
||||
}
|
||||
|
||||
|
||||
async function retryProblem() {
|
||||
const action = failedCommand.value
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
if (action === 'ship' && selectionCount.value > 0) return executeCommand('ship')
|
||||
}
|
||||
|
||||
function dismissProblem() {
|
||||
problem.value = null
|
||||
failedCommand.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
appliedSearch,
|
||||
rows: computed(() => query.data.value?.items ?? []),
|
||||
result: computed(() => query.data.value),
|
||||
loading: computed(() => query.isFetching.value),
|
||||
error: computed(() => query.error.value),
|
||||
selectionState,
|
||||
selectionCount,
|
||||
rememberSearch,
|
||||
preferenceNotice,
|
||||
problem,
|
||||
setRememberSearch,
|
||||
saveCurrentSearchDefaults,
|
||||
executeSearch,
|
||||
resetAndSearch,
|
||||
executeCommand,
|
||||
retryProblem,
|
||||
dismissProblem,
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { KbxBarcodeCapture, KbxProblemFeedback, KbxWmsActionButton, KbxWmsMobilePage, playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
|
||||
import { countingScreen } from './counting.definition'
|
||||
import { createWmsExecutionNotConnectedProblem, wmsFrontendScenarioMode } from '../shared/wmsFrontendCapability'
|
||||
const props=defineProps<{taskId:string}>()
|
||||
const {online}=useKbxNetworkState()
|
||||
const stage=ref<'location'|'item'|'counted'>('location')
|
||||
const location=ref('')
|
||||
const item=ref('')
|
||||
const counted=ref(0)
|
||||
const bookQty=3
|
||||
const expectedLocation='C-01-01'
|
||||
const expectedBarcode='8801234567003'
|
||||
const message=ref('실사 위치를 스캔하세요.')
|
||||
const problem=createWmsExecutionNotConnectedProblem('재고 실사')
|
||||
const difference=computed(()=>counted.value-bookQty)
|
||||
function scan(value:string){
|
||||
if(!wmsFrontendScenarioMode)return
|
||||
if(stage.value==='location'){
|
||||
if(value!==expectedLocation){message.value=`다른 위치입니다. 실사 위치 ${expectedLocation}로 이동하세요.`;playKbxWmsFeedback('error');return}
|
||||
location.value=value;stage.value='item';message.value='실사 상품을 스캔하세요.';playKbxWmsFeedback('success');return
|
||||
}
|
||||
if(stage.value==='item'){
|
||||
if(value!==expectedBarcode){message.value=`다른 상품입니다. 필요한 바코드는 ${expectedBarcode}입니다.`;playKbxWmsFeedback('error');return}
|
||||
counted.value+=1;item.value=value;message.value=`실사 ${counted.value}개를 확인했습니다. 계속 스캔하거나 수량을 확정하세요.`;playKbxWmsFeedback('success')
|
||||
}
|
||||
}
|
||||
function finish(){stage.value='counted';message.value=difference.value===0?'장부수량과 일치합니다.':'차이가 있습니다. 운영에서는 관리자 확인 대상으로 Server에 등록해야 합니다.';playKbxWmsFeedback(difference.value===0?'success':'warning')}
|
||||
function reset(){stage.value='location';location.value='';item.value='';counted.value=0;message.value='실사 위치를 스캔하세요.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :task-context="`Task ${props.taskId}`" :online="online" :actions-visible="wmsFrontendScenarioMode"><template #notice><KbxProblemFeedback v-if="!wmsFrontendScenarioMode" :problem="problem" :dismissible="false" /></template><template v-if="wmsFrontendScenarioMode"><p class="msg" role="status" aria-live="polite">{{message}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>기능성 양말</strong><span>{{item}}</span><p>실사 {{counted}} · 장부 {{bookQty}} · 차이 {{difference>0?'+':''}}{{difference}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'LOCATION SCAN':'ITEM SCAN'" @scan="event=>scan(event.normalizedValue)" /></template><template #actions><template v-if="wmsFrontendScenarioMode"><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" :disabled="!online" @click="finish"/><KbxWmsActionButton v-else-if="stage==='counted'" label="다음 위치" @click="reset"/></template></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.box{display:flex;flex-direction:column;gap:var(--kbx-space-1);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);margin-bottom:var(--kbx-space-3)}.box small,.box span{color:var(--kbx-color-text-muted)}.box strong{font-size:var(--kbx-font-2xl)}.box p{font-size:var(--kbx-font-lg);font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const countingScreen=defineKbxScreen({id:'WMS-COUNT-001',version:'1.1.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'재고실사',helpKey:'WMS-COUNT-001',permissions:['wms.inventory.count'],telemetry:{enabled:true}})
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsCountingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/counting/:taskId', name:'wms-counting', component:()=>import('./WmsCountingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-COUNT-001' } },
|
||||
]
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { KbxWmsExceptionType } from '@kbx/contracts'
|
||||
import { KbxWmsActionButton } from '@kbx/ui'
|
||||
|
||||
defineProps<{ busy?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [KbxWmsExceptionType, string]
|
||||
}>()
|
||||
|
||||
const type = ref<KbxWmsExceptionType>('no-stock')
|
||||
const memo = ref('')
|
||||
|
||||
const options: { value: KbxWmsExceptionType; label: string }[] = [
|
||||
{ value: 'no-stock', label: '실물 없음' },
|
||||
{ value: 'short-quantity', label: '수량 부족' },
|
||||
{ value: 'wrong-location', label: '위치 오류' },
|
||||
{ value: 'damaged-item', label: '상품 이상' },
|
||||
{ value: 'barcode-issue', label: '바코드 문제' },
|
||||
{ value: 'other', label: '기타' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="exception-title">
|
||||
<h2 id="exception-title">피킹 문제 신고</h2>
|
||||
<p>가장 가까운 사유 하나만 선택하세요.</p>
|
||||
<label v-for="option in options" :key="option.value" class="reason">
|
||||
<input v-model="type" type="radio" :value="option.value">
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
<label class="memo">메모 <small>선택 입력</small>
|
||||
<textarea v-model="memo" rows="3" maxlength="300" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
|
||||
<KbxWmsActionButton label="문제 등록" :busy="busy" @click="$emit('submit', type, memo)" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:30; }
|
||||
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; max-height:85dvh; overflow:auto; }
|
||||
h2 { margin:0 0 4px; font-size:20px; }
|
||||
p { margin:0 0 14px; color:var(--kbx-color-text-muted); }
|
||||
.reason { min-height:48px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--kbx-color-border); }
|
||||
.reason input { width:20px; height:20px; }
|
||||
.memo { display:block; margin-top:16px; font-weight:600; }
|
||||
.memo small { font-weight:400; color:var(--kbx-color-text-muted); }
|
||||
textarea { width:100%; margin-top:6px; border:1px solid var(--kbx-color-border); border-radius:6px; padding:10px; font:inherit; box-sizing:border-box; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
</style>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxWmsActionButton } from '@kbx/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
required: number
|
||||
busy?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; submit: [number] }>()
|
||||
const quantity = ref(props.current)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backdrop" @click.self="$emit('close')">
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="quantity-title">
|
||||
<h2 id="quantity-title">피킹 수량 입력</h2>
|
||||
<p>상품 바코드를 최소 1회 확인한 뒤 사용합니다.</p>
|
||||
<label>
|
||||
피킹 수량
|
||||
<input v-model.number="quantity" type="number" inputmode="decimal" :min="1" :max="required">
|
||||
</label>
|
||||
<div class="hint">필요수량 {{ required }}</div>
|
||||
<div class="actions">
|
||||
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
|
||||
<KbxWmsActionButton
|
||||
label="수량 적용"
|
||||
:busy="busy"
|
||||
:disabled="quantity <= 0 || quantity > required"
|
||||
@click="$emit('submit', quantity)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:31; }
|
||||
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; }
|
||||
h2 { margin:0 0 4px; font-size:20px; }
|
||||
p { margin:0 0 16px; color:var(--kbx-color-text-muted); }
|
||||
label { display:block; font-weight:600; }
|
||||
input { width:100%; min-height:52px; margin-top:6px; padding:0 12px; box-sizing:border-box; font:inherit; font-size:22px; text-align:right; border:1px solid var(--kbx-color-border-strong); border-radius:6px; }
|
||||
.hint { margin-top:6px; text-align:right; color:var(--kbx-color-text-muted); font-size:13px; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; }
|
||||
</style>
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<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 taskContext = computed(() => vm.task.value
|
||||
? `${vm.task.value.taskNo} · ${vm.task.value.completedLines}/${vm.task.value.totalLines} 라인 · 총 ${vm.task.value.totalQty}개`
|
||||
: undefined)
|
||||
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 '작업을 불러오는 중입니다.'
|
||||
}
|
||||
})
|
||||
const noticeText = computed(() => {
|
||||
if (!vm.online.value && vm.pendingCommands.value > 0) return `오프라인입니다. 전송 대기 ${vm.pendingCommands.value}건은 연결되면 같은 작업키로 안전하게 재전송합니다.`
|
||||
if (!vm.online.value) return '오프라인입니다. 서버 확인이 필요한 스캔은 입력할 수 없습니다.'
|
||||
if (vm.syncing.value) return `전송 대기 ${vm.pendingCommands.value}건을 순서대로 동기화하고 있습니다.`
|
||||
return vm.message.value
|
||||
})
|
||||
const noticeTone = computed(() => {
|
||||
if (vm.task.value?.stage === 'blocked') return 'danger'
|
||||
if (!vm.online.value || vm.pendingCommands.value > 0) return 'warning'
|
||||
if (vm.task.value?.stage === 'completed') return 'success'
|
||||
return 'info'
|
||||
})
|
||||
const noticeLabel = computed(() => ({danger:'확인 필요',warning:'연결 상태',success:'완료',info:'작업 안내'}[noticeTone.value]))
|
||||
const scanReadiness = computed(() => {
|
||||
if (!vm.online.value) return '입력 차단 · 온라인 확인 필요'
|
||||
if (vm.syncing.value || vm.pendingCommands.value > 0) return `재전송 확인 중 · ${vm.pendingCommands.value}건 대기`
|
||||
if (vm.scanning.value || vm.task.value?.stage === 'processing') return '서버 확인 중 · 중복 스캔 금지'
|
||||
if (vm.task.value && ['await-location','await-item'].includes(vm.task.value.stage)) return '스캔 가능'
|
||||
if (vm.task.value?.stage === 'completed') return '작업 완료'
|
||||
if (vm.task.value?.stage === 'blocked') return '입력 차단 · 관리자 확인'
|
||||
return '스캔 대기'
|
||||
})
|
||||
const scanReadinessTone = computed(() => !vm.online.value || vm.task.value?.stage === 'blocked'
|
||||
? 'danger'
|
||||
: vm.syncing.value || vm.pendingCommands.value > 0 || vm.scanning.value
|
||||
? 'warning'
|
||||
: vm.task.value?.stage === 'completed'
|
||||
? 'success'
|
||||
: 'ready')
|
||||
const lastConfirmedText = computed(() => {
|
||||
const last = vm.lastConfirmedScan.value
|
||||
if (!last) return ''
|
||||
const time = new Date(last.confirmedAt).toLocaleTimeString('ko-KR', { hour:'2-digit', minute:'2-digit', second:'2-digit' })
|
||||
return `${last.barcode} · ${time}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxWmsMobilePage
|
||||
:screen="pickingScreen"
|
||||
:progress="progress"
|
||||
:task-context="taskContext"
|
||||
:instruction="stageTitle"
|
||||
:online="vm.online.value"
|
||||
:pending-commands="vm.pendingCommands.value"
|
||||
:syncing="vm.syncing.value"
|
||||
:content-state="vm.contentState.value"
|
||||
retry-label="작업 다시 불러오기"
|
||||
@retry="vm.reload"
|
||||
>
|
||||
<template #notice>
|
||||
<div v-if="noticeText" class="work-notice" :data-tone="noticeTone" role="status" aria-live="polite">
|
||||
<strong>{{ noticeLabel }}</strong><span>{{ noticeText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-if="vm.task.value" class="instruction" :data-stage="vm.task.value.stage">
|
||||
<template v-if="current">
|
||||
<div class="location" aria-label="현재 피킹 위치">
|
||||
<span>LOCATION</span>
|
||||
<strong>{{ current.locationCode }}</strong>
|
||||
<small v-if="vm.task.value.stage === 'await-location'">이 위치와 일치하는 라벨을 먼저 스캔하세요.</small>
|
||||
</div>
|
||||
|
||||
<div class="item" aria-label="현재 피킹 상품">
|
||||
<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" aria-label="피킹 수량">
|
||||
<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>
|
||||
|
||||
<p class="next-action">
|
||||
<template v-if="vm.task.value.stage === 'await-location'">다음 행동 · 위치 {{ current.locationCode }} 스캔</template>
|
||||
<template v-else-if="vm.task.value.stage === 'await-item'">다음 행동 · 상품 {{ current.itemCode }} 스캔</template>
|
||||
<template v-else-if="vm.task.value.stage === 'processing'">중복 스캔하지 말고 서버 확인을 기다리세요.</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<KbxWmsActionButton
|
||||
v-if="current && vm.task.value.stage === 'await-item' && current.pickedQty > 0 && current.remainingQty > 0"
|
||||
class="quantity-action"
|
||||
label="수량 직접 입력"
|
||||
variant="secondary"
|
||||
:disabled="!vm.online.value || vm.pendingCommands.value > 0 || vm.scanning.value"
|
||||
@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>
|
||||
<small>작업 시작 후 위치 → 상품 순서로 스캔합니다.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="vm.task.value && vm.task.value.stage !== 'ready'" class="scan-truth" :data-tone="scanReadinessTone" aria-live="polite" data-kbx-surface="scan-truth">
|
||||
<div><span>스캔 상태</span><strong>{{ scanReadiness }}</strong></div>
|
||||
<div v-if="lastConfirmedText"><span>최근 서버 확인</span><strong>{{ lastConfirmedText }}</strong></div>
|
||||
<div v-else><span>최근 서버 확인</span><strong>아직 확인된 스캔 없음</strong></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>
|
||||
.work-notice{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-info-border);border-radius:var(--kbx-radius-md);background:var(--kbx-color-info-surface);font-size:var(--kbx-font-sm)}
|
||||
.work-notice strong{white-space:nowrap}.work-notice[data-tone="warning"]{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.work-notice[data-tone="danger"]{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.work-notice[data-tone="success"]{border-color:var(--kbx-color-success-border);background:var(--kbx-color-success-surface)}
|
||||
.instruction{text-align:center}.location{padding:var(--kbx-space-4);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg)}
|
||||
.location span,.item span{display:block;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);font-weight:600}.location strong{display:block;margin-top:var(--kbx-space-1);font-size:calc(var(--kbx-font-2xl) + var(--kbx-space-3));letter-spacing:.03em}.location small{display:block;margin-top:var(--kbx-space-2);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
|
||||
.item{padding:var(--kbx-space-5) var(--kbx-space-1) var(--kbx-space-2)}.item strong{display:block;margin-top:var(--kbx-space-1);font-size:calc(var(--kbx-font-xl) + var(--kbx-space-1))}.item small{display:block;margin-top:var(--kbx-space-1);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}
|
||||
.qty{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}.qty div{border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);padding:var(--kbx-space-3) var(--kbx-space-1)}.qty span{display:block;font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}.qty strong{display:block;margin-top:var(--kbx-space-1);font-size:calc(var(--kbx-font-2xl) + var(--kbx-space-2))}
|
||||
.next-action{margin:var(--kbx-space-3) 0 0;padding:var(--kbx-space-2);border-top:var(--kbx-border-width) solid var(--kbx-color-border);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm);font-weight:600}.quantity-action{margin-top:var(--kbx-space-3)}.ready{min-height:calc(var(--kbx-control-touch) * 5);display:flex;flex-direction:column;justify-content:center;gap:var(--kbx-space-2)}.ready strong{font-size:calc(var(--kbx-font-2xl) + var(--kbx-space-2))}.ready span,.ready small{color:var(--kbx-color-text-muted)}
|
||||
.scan-truth{display:grid;gap:var(--kbx-space-2);margin:0 0 var(--kbx-space-3);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-left:var(--kbx-accent-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-xs)}.scan-truth>div{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start}.scan-truth span{color:var(--kbx-color-text-muted)}.scan-truth strong{text-align:right;overflow-wrap:anywhere}.scan-truth[data-tone="warning"]{border-left-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.scan-truth[data-tone="danger"]{border-left-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}
|
||||
@media(forced-colors:active){.scan-truth{border-color:CanvasText}.scan-truth[data-tone="warning"],.scan-truth[data-tone="danger"]{border-left-color:Highlight}}
|
||||
</style>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
|
||||
export const pickingScreen = defineKbxScreen({
|
||||
id: 'WMS-PICK-001',
|
||||
version: '1.2.0',
|
||||
module: 'WMS',
|
||||
type: 'wms-mobile', templateCode:'T09',
|
||||
title: '출고 피킹',
|
||||
helpKey: 'WMS-PICK-001',
|
||||
permissions: ['wms.picking.execute'],
|
||||
telemetry: { enabled: true },
|
||||
})
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
KbxWmsExceptionCommand,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsScanResult,
|
||||
KbxWmsSetQuantityCommand,
|
||||
} from '@kbx/contracts'
|
||||
import { kbxApi } from '../../../http/generated/kbxApiClient'
|
||||
|
||||
export function getPickingTask(taskId: string) {
|
||||
return kbxApi.request<KbxWmsPickingTask>('wms.picking.getTask', { path: { taskId } })
|
||||
}
|
||||
|
||||
export function startPickingTask(taskId: string, expectedVersion: number, idempotencyKey = crypto.randomUUID()) {
|
||||
return kbxApi.request<KbxWmsPickingTask, { expectedVersion: number }>('wms.picking.start', {
|
||||
path: { taskId }, body: { expectedVersion }, idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function scanPickingBarcode(command: KbxWmsScanCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsScanCommand>('wms.picking.scan', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function reportPickingException(command: KbxWmsExceptionCommand) {
|
||||
return kbxApi.request<KbxWmsPickingTask, KbxWmsExceptionCommand>('wms.picking.reportException', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
export function setPickingQuantity(command: KbxWmsSetQuantityCommand) {
|
||||
return kbxApi.request<KbxWmsScanResult, KbxWmsSetQuantityCommand>('wms.picking.setQuantity', {
|
||||
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
|
||||
})
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const wmsPickingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/wms/picking/:taskId',
|
||||
name: 'wms-picking-task',
|
||||
component: () => import('./WmsPickingPage.vue'),
|
||||
props: route => ({ taskId: String(route.params.taskId) }),
|
||||
},
|
||||
]
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type {
|
||||
KbxBarcodeEvent,
|
||||
KbxWmsExceptionType,
|
||||
KbxWmsPickingTask,
|
||||
KbxWmsScanCommand,
|
||||
KbxWmsSetQuantityCommand,
|
||||
KbxAsyncState,
|
||||
} from '@kbx/contracts'
|
||||
import { isKbxProblem, isRetryableKbxProblem } from '@kbx/contracts'
|
||||
import { playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
|
||||
import {
|
||||
getPickingTask,
|
||||
reportPickingException,
|
||||
scanPickingBarcode,
|
||||
setPickingQuantity,
|
||||
startPickingTask,
|
||||
} from './pickingApi'
|
||||
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
|
||||
import { pickingScreen } from './picking.definition'
|
||||
import { queuePickingScan, readPickingRetryQueue, removePickingScan } from './wmsRetryQueue'
|
||||
import { KbxScreenPreferenceScopeKey } from '../../../preferences/screenPreferenceStore'
|
||||
|
||||
function newIdempotencyKey(taskId: string) {
|
||||
return `${taskId}:${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
type ScopedScanMutation = { command: KbxWmsScanCommand; scope: string }
|
||||
|
||||
export function useWmsPicking(taskId: string) {
|
||||
const { online } = useKbxNetworkState()
|
||||
const retryScope = inject(KbxScreenPreferenceScopeKey, ref(''))
|
||||
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)
|
||||
const lastConfirmedScan = ref<{ barcode:string; confirmedAt:string } | null>(null)
|
||||
let taskTelemetry:ReturnType<typeof startKbxTask>|undefined
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['wms-picking-task', taskId],
|
||||
queryFn: () => getPickingTask(taskId),
|
||||
})
|
||||
|
||||
const contentState = computed<KbxAsyncState>(() => query.isLoading.value
|
||||
? 'loading'
|
||||
: query.isError.value
|
||||
? 'error'
|
||||
: task.value
|
||||
? 'ready'
|
||||
: 'empty')
|
||||
|
||||
watch(() => query.data.value, value => {
|
||||
if (value) task.value = value
|
||||
}, { immediate: true })
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startPickingTask(taskId, task.value?.version ?? 0),
|
||||
onSuccess(result) {
|
||||
taskTelemetry=startKbxTask(pickingScreen.id,pickingScreen.version,'wms-picking')
|
||||
task.value = result
|
||||
message.value = result.message ?? '피킹을 시작합니다.'
|
||||
playKbxWmsFeedback('neutral')
|
||||
},
|
||||
onError(error) {
|
||||
message.value = isKbxProblem(error) ? error.title : '작업을 시작하지 못했습니다.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: ({ command }: ScopedScanMutation) => scanPickingBarcode(command),
|
||||
onSuccess(result, input) {
|
||||
removePickingScan(input.command.idempotencyKey, input.scope)
|
||||
if (input.scope !== retryScope.value) return
|
||||
pendingCommands.value = readPickingRetryQueue(input.scope).filter(x => x.command.taskId === taskId).length
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
lastConfirmedScan.value = { barcode: input.command.barcode, confirmedAt: new Date().toISOString() }
|
||||
playKbxWmsFeedback(result.feedback)
|
||||
if(result.task.stage==='completed'){taskTelemetry?.complete('success');taskTelemetry=undefined}
|
||||
},
|
||||
onError(error, input) {
|
||||
const sameScope = input.scope === retryScope.value
|
||||
// 4xx is an authoritative server rejection. Only network/5xx ambiguity is safe-retried.
|
||||
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
|
||||
if (sameScope) {
|
||||
message.value = error.title ?? '현재 작업 상태를 다시 확인하세요.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The request may have committed even if the response was lost. Preserve the scope that issued
|
||||
// the command so a tenant/user switch cannot move an ambiguous scan into another operator queue.
|
||||
queuePickingScan(input.command, input.scope)
|
||||
if (!sameScope) return
|
||||
pendingCommands.value = readPickingRetryQueue(input.scope).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')
|
||||
},
|
||||
onError(error) {
|
||||
message.value = isKbxProblem(error) ? error.title : '예외를 등록하지 못했습니다.'
|
||||
playKbxWmsFeedback('error')
|
||||
void query.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
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 '위치 스캔'
|
||||
case 'await-item': return '상품 스캔'
|
||||
case 'processing': return '서버 확인 중'
|
||||
case 'completed': return '피킹 완료'
|
||||
default: return '스캔 대기'
|
||||
}
|
||||
})
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
const scope = retryScope.value
|
||||
await scanMutation.mutateAsync({ command, scope }).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function flushRetryQueue() {
|
||||
if (!online.value || syncing.value) return
|
||||
const scope = retryScope.value
|
||||
const queued = readPickingRetryQueue(scope)
|
||||
.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)
|
||||
removePickingScan(item.command.idempotencyKey, scope)
|
||||
if (scope !== retryScope.value) break
|
||||
task.value = result.task
|
||||
message.value = result.message
|
||||
lastConfirmedScan.value = { barcode: item.command.barcode, confirmedAt: new Date().toISOString() }
|
||||
} catch (error) {
|
||||
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
|
||||
removePickingScan(item.command.idempotencyKey, scope)
|
||||
if (scope === retryScope.value) {
|
||||
message.value = error.title ?? '작업 상태가 변경되었습니다. 최신 상태를 확인하세요.'
|
||||
playKbxWmsFeedback('error')
|
||||
await query.refetch()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const scopeChanged = scope !== retryScope.value
|
||||
pendingCommands.value = readPickingRetryQueue(retryScope.value).filter(x => x.command.taskId === taskId).length
|
||||
syncing.value = false
|
||||
if (scopeChanged && online.value) void flushRetryQueue()
|
||||
}
|
||||
}
|
||||
|
||||
watch(online, value => {
|
||||
if (value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
watch(retryScope, (scope, previous) => {
|
||||
if (scope === previous) return
|
||||
// Shared-PDA context switch is a hard UX/security boundary. Never carry prior operator scan truth
|
||||
// or overlay state into the next tenant/user scope, and only count/replay that new scope's queue.
|
||||
taskTelemetry?.abandon('scope_changed')
|
||||
taskTelemetry = undefined
|
||||
lastConfirmedScan.value = null
|
||||
exceptionOpen.value = false
|
||||
quantityOpen.value = false
|
||||
pendingCommands.value = readPickingRetryQueue(scope).filter(x => x.command.taskId === taskId).length
|
||||
message.value = pendingCommands.value > 0 ? '현재 사용자 범위의 미확인 스캔을 서버와 다시 확인합니다.' : ''
|
||||
if (online.value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
pendingCommands.value = readPickingRetryQueue(retryScope.value).filter(x => x.command.taskId === taskId).length
|
||||
if (online.value) void flushRetryQueue()
|
||||
})
|
||||
|
||||
return {
|
||||
task,
|
||||
message,
|
||||
online,
|
||||
pendingCommands,
|
||||
syncing,
|
||||
lastConfirmedScan,
|
||||
scanEnabled,
|
||||
scanLabel,
|
||||
exceptionOpen,
|
||||
quantityOpen,
|
||||
loading: query.isLoading,
|
||||
contentState,
|
||||
reload: () => query.refetch(),
|
||||
starting: startMutation.isPending,
|
||||
scanning: scanMutation.isPending,
|
||||
reportingException: exceptionMutation.isPending,
|
||||
settingQuantity: quantityMutation.isPending,
|
||||
start: () => startMutation.mutateAsync(),
|
||||
handleScan,
|
||||
openException: () => { exceptionOpen.value = true },
|
||||
openQuantity: () => { quantityOpen.value = true },
|
||||
closeQuantity: () => { quantityOpen.value = false },
|
||||
setQuantity: (quantity: number) => quantityMutation.mutateAsync(quantity),
|
||||
closeException: () => { exceptionOpen.value = false },
|
||||
reportException: (type: KbxWmsExceptionType, memo?: string) => exceptionMutation.mutateAsync({ type, memo }),
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { KbxWmsScanCommand } from '@kbx/contracts'
|
||||
|
||||
const STORAGE_PREFIX = 'kbx:wms:safe-retry:v2:'
|
||||
const LEGACY_STORAGE_KEY = 'kbx:wms:safe-retry:v1'
|
||||
const ANONYMOUS_SCOPE = 'anonymous:secure-default'
|
||||
const MAX_QUEUE_ITEMS = 32
|
||||
|
||||
export interface QueuedPickingScan {
|
||||
command: KbxWmsScanCommand
|
||||
queuedAt: string
|
||||
}
|
||||
|
||||
function scopeHash(scope:string){
|
||||
// Storage keys must not expose tenant/user identifiers. This is partitioning, not cryptography.
|
||||
let hash=2166136261
|
||||
for(const ch of scope){hash^=ch.charCodeAt(0);hash=Math.imul(hash,16777619)}
|
||||
return (hash>>>0).toString(36)
|
||||
}
|
||||
function store(scope:string){
|
||||
// Authenticated tenant/user scope can safely survive reload/browser restart. Anonymous/default
|
||||
// contexts stay session-only so a shared PDA cannot replay another operator's queue later.
|
||||
return scope&&scope!==ANONYMOUS_SCOPE?localStorage:sessionStorage
|
||||
}
|
||||
function storageKey(scope:string){return `${STORAGE_PREFIX}${scopeHash(scope||ANONYMOUS_SCOPE)}`}
|
||||
function retireLegacyQueue(){
|
||||
// v1 was an unscoped localStorage queue. Never replay it after the isolation upgrade.
|
||||
try { localStorage.removeItem(LEGACY_STORAGE_KEY) } catch { /* storage can be unavailable */ }
|
||||
}
|
||||
function validItem(item:unknown): item is QueuedPickingScan {
|
||||
if(!item||typeof item!=='object'||!('queuedAt' in item)||typeof item.queuedAt!=='string'||!('command' in item)||!item.command||typeof item.command!=='object')return false
|
||||
const command=item.command
|
||||
return 'idempotencyKey' in command&&typeof command.idempotencyKey==='string'&&'taskId' in command&&typeof command.taskId==='string'&&'barcode' in command&&typeof command.barcode==='string'
|
||||
}
|
||||
|
||||
export function readPickingRetryQueue(scope=''): QueuedPickingScan[] {
|
||||
retireLegacyQueue()
|
||||
try {
|
||||
const parsed = JSON.parse(store(scope).getItem(storageKey(scope)) ?? '[]') as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(validItem).slice(-MAX_QUEUE_ITEMS)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function queuePickingScan(command: KbxWmsScanCommand, scope='') {
|
||||
retireLegacyQueue()
|
||||
// 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(scope)
|
||||
if (!queue.some(x => x.command.idempotencyKey === command.idempotencyKey)) {
|
||||
queue.push({ command, queuedAt: new Date().toISOString() })
|
||||
store(scope).setItem(storageKey(scope), JSON.stringify(queue.slice(-MAX_QUEUE_ITEMS)))
|
||||
}
|
||||
}
|
||||
|
||||
export function removePickingScan(idempotencyKey: string, scope='') {
|
||||
const next = readPickingRetryQueue(scope).filter(x => x.command.idempotencyKey !== idempotencyKey)
|
||||
store(scope).setItem(storageKey(scope), JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function clearPickingRetryQueue(scope='') {
|
||||
store(scope).removeItem(storageKey(scope))
|
||||
retireLegacyQueue()
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KbxBarcodeCapture, KbxProblemFeedback, KbxWmsActionButton, KbxWmsMobilePage, playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
|
||||
import { putawayScreen } from './putaway.definition'
|
||||
import { createWmsExecutionNotConnectedProblem, wmsFrontendScenarioMode } from '../shared/wmsFrontendCapability'
|
||||
const props=defineProps<{taskId:string}>()
|
||||
const {online}=useKbxNetworkState()
|
||||
const stage=ref<'item'|'location'|'done'>('item')
|
||||
const item=ref('')
|
||||
const suggested='A-01-03'
|
||||
const expectedBarcode='8801234567001'
|
||||
const message=ref('적치할 상품을 스캔하세요.')
|
||||
const problem=createWmsExecutionNotConnectedProblem('입고 적치')
|
||||
function scan(value:string){
|
||||
if(!wmsFrontendScenarioMode)return
|
||||
if(stage.value==='item'){
|
||||
if(value!==expectedBarcode){message.value=`다른 상품입니다. ${expectedBarcode}을 스캔하세요.`;playKbxWmsFeedback('error');return}
|
||||
item.value=value;stage.value='location';message.value=`추천 위치 ${suggested}를 스캔하세요.`;playKbxWmsFeedback('success');return
|
||||
}
|
||||
if(stage.value==='location'){
|
||||
if(value!==suggested){message.value=`잘못된 위치입니다. 현재 ${value}, 작업 위치 ${suggested}. ${suggested}로 이동하세요.`;playKbxWmsFeedback('error');return}
|
||||
stage.value='done';message.value='Demo 적치 흐름을 완료했습니다. 운영에서는 Server 결과 확인 후 완료됩니다.';playKbxWmsFeedback('success')
|
||||
}
|
||||
}
|
||||
function reset(){stage.value='item';item.value='';message.value='적치할 상품을 스캔하세요.'}
|
||||
</script>
|
||||
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'적치완료':'적치중'" :task-context="`Task ${props.taskId}`" :online="online" :actions-visible="wmsFrontendScenarioMode"><template #notice><KbxProblemFeedback v-if="!wmsFrontendScenarioMode" :problem="problem" :dismissible="false" /></template><template v-if="wmsFrontendScenarioMode"><p class="msg" role="status" aria-live="polite">{{message}}</p><div v-if="item" class="box"><small>상품</small><strong>운동화 BLACK 270</strong><span>{{item}}</span></div><div v-if="stage!=='item'" class="box location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'ITEM SCAN':'LOCATION SCAN'" @scan="event=>scan(event.normalizedValue)" /></template><template #actions><KbxWmsActionButton v-if="wmsFrontendScenarioMode&&stage==='done'" label="다음 상품" @click="reset" /></template></KbxWmsMobilePage></template>
|
||||
<style scoped>.msg{font-weight:650}.box{display:flex;flex-direction:column;gap:var(--kbx-space-1);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);margin-bottom:var(--kbx-space-3)}.box small,.box span{color:var(--kbx-color-text-muted)}.box strong{font-size:var(--kbx-font-xl)}.location strong{font-size:var(--kbx-font-2xl)}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const putawayScreen=defineKbxScreen({id:'WMS-PUT-001',version:'1.1.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 적치',helpKey:'WMS-PUT-001',permissions:['wms.putaway.execute'],telemetry:{enabled:true}})
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsPutawayRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/putaway/:taskId', name:'wms-putaway', component:()=>import('./WmsPutawayPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-PUT-001' } },
|
||||
]
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { KbxBarcodeCapture, KbxProblemFeedback, KbxWmsActionButton, KbxWmsMobilePage, playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
|
||||
import { receivingScreen } from './receiving.definition'
|
||||
import { createWmsExecutionNotConnectedProblem, wmsFrontendScenarioMode } from '../shared/wmsFrontendCapability'
|
||||
|
||||
const props=defineProps<{taskId:string}>()
|
||||
const {online}=useKbxNetworkState()
|
||||
const stage=ref<'asn'|'item'|'completed'>('asn')
|
||||
const asnNo=ref('')
|
||||
const received=ref(0)
|
||||
const expectedQty=3
|
||||
const expectedAsn='ASN-260811-001'
|
||||
const expectedBarcode='8801234567001'
|
||||
const message=ref('입고예정번호 또는 ASN을 스캔하세요.')
|
||||
const problem=createWmsExecutionNotConnectedProblem('입고 검수')
|
||||
const progress=computed(()=>stage.value==='completed'?'검수완료':stage.value==='asn'?'입고대기':`검수 ${received.value} / ${expectedQty}`)
|
||||
|
||||
function scan(value:string){
|
||||
if(!wmsFrontendScenarioMode)return
|
||||
if(stage.value==='asn'){
|
||||
if(value!==expectedAsn){message.value=`다른 입고예정입니다. ${expectedAsn}을 스캔하세요.`;playKbxWmsFeedback('error');return}
|
||||
asnNo.value=value;stage.value='item';message.value='상품 바코드를 스캔하세요.';playKbxWmsFeedback('success');return
|
||||
}
|
||||
if(stage.value==='item'){
|
||||
if(value!==expectedBarcode){message.value=`다른 상품입니다. 필요한 바코드는 ${expectedBarcode}입니다.`;playKbxWmsFeedback('error');return}
|
||||
if(received.value>=expectedQty){message.value='예정수량을 모두 확인했습니다. 검수를 완료하세요.';playKbxWmsFeedback('neutral');return}
|
||||
received.value+=1;message.value=received.value===expectedQty?'예정수량과 일치합니다. 검수를 완료하세요.':`${received.value} / ${expectedQty} 확인했습니다. 같은 상품을 계속 스캔하세요.`;playKbxWmsFeedback('success')
|
||||
}
|
||||
}
|
||||
function complete(){
|
||||
if(received.value!==expectedQty){message.value=`예정 ${expectedQty}개 중 ${received.value}개만 확인했습니다. 계속 스캔하세요.`;playKbxWmsFeedback('error');return}
|
||||
stage.value='completed';message.value='Demo 검수 흐름을 완료했습니다. 운영에서는 Server 결과 확인 후 완료됩니다.';playKbxWmsFeedback('success')
|
||||
}
|
||||
function reset(){stage.value='asn';asnNo.value='';received.value=0;message.value='입고예정번호 또는 ASN을 스캔하세요.'}
|
||||
</script>
|
||||
<template>
|
||||
<KbxWmsMobilePage :screen="receivingScreen" :progress="progress" :task-context="`Task ${props.taskId}`" :online="online" :actions-visible="wmsFrontendScenarioMode">
|
||||
<template #notice><KbxProblemFeedback v-if="!wmsFrontendScenarioMode" :problem="problem" :dismissible="false" /></template>
|
||||
<template v-if="wmsFrontendScenarioMode">
|
||||
<p class="msg" role="status" aria-live="polite">{{message}}</p>
|
||||
<section v-if="asnNo" class="card"><small>입고예정</small><strong>{{asnNo}}</strong></section>
|
||||
<section v-if="stage!=='asn'" class="card"><small>상품</small><strong>운동화 BLACK 270</strong><span>{{expectedBarcode}}</span><div class="qty">예정 {{expectedQty}} · 검수 {{received}} · 남음 {{Math.max(expectedQty-received,0)}}</div></section>
|
||||
<KbxBarcodeCapture v-if="stage!=='completed'" :enabled="online" :label="stage==='asn'?'ASN SCAN':'ITEM SCAN'" @scan="event=>scan(event.normalizedValue)" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<template v-if="wmsFrontendScenarioMode">
|
||||
<KbxWmsActionButton v-if="stage==='item'" label="검수 완료" :disabled="!online || received!==expectedQty" @click="complete" />
|
||||
<KbxWmsActionButton v-else-if="stage==='completed'" label="다음 입고" @click="reset" />
|
||||
</template>
|
||||
</template>
|
||||
</KbxWmsMobilePage>
|
||||
</template>
|
||||
<style scoped>.msg{margin:0 0 var(--kbx-space-3);font-weight:650}.card{display:flex;flex-direction:column;gap:var(--kbx-space-1);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-lg);padding:var(--kbx-space-4);margin-bottom:var(--kbx-space-3);background:var(--kbx-color-surface)}.card small,.card span{color:var(--kbx-color-text-muted)}.card strong{font-size:var(--kbx-font-xl)}.qty{font-size:var(--kbx-font-lg);font-weight:650}</style>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
import { defineKbxScreen } from '@kbx/ui'
|
||||
export const receivingScreen=defineKbxScreen({id:'WMS-REC-001',version:'1.1.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 검수',helpKey:'WMS-REC-001',permissions:['wms.receiving.execute'],telemetry:{enabled:true}})
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsReceivingRoutes: RouteRecordRaw[] = [
|
||||
{ path:'/wms/receiving/:taskId', name:'wms-receiving', component:()=>import('./WmsReceivingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-REC-001' } },
|
||||
]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { KbxIntegrationProblem } from '@kbx/contracts'
|
||||
|
||||
export const wmsFrontendScenarioMode = import.meta.env.VITE_KBX_DEMO_MODE === 'true'
|
||||
|
||||
export function createWmsExecutionNotConnectedProblem(workflowLabel:string):KbxIntegrationProblem{
|
||||
return {
|
||||
type:'integration',
|
||||
code:'WMS_EXECUTION_API_NOT_CONNECTED',
|
||||
title:`${workflowLabel} 실행 API가 연결되지 않았습니다.`,
|
||||
detail:'실제 현장 작업은 Server 검증·Idempotency·Audit 결과가 확인되기 전 성공으로 표시하지 않습니다. Demo Mode에서만 FE 시나리오를 실행할 수 있습니다.',
|
||||
retryable:false,
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isKbxProblem } from '@kbx/contracts'
|
||||
import { KbxButton, KbxDataGrid, KbxQueuePage, KbxSearchPanel } from '@kbx/ui'
|
||||
import { routeForWmsTask, searchWmsWork, type WmsWorkSearchSummary } from './workApi'
|
||||
import { wmsWorkColumns, wmsWorkScreen, wmsWorkSearchFields, type WmsWorkRow } from './work.definition'
|
||||
|
||||
const router=useRouter()
|
||||
const search=ref({taskType:'',status:'',owner:'mine',keyword:''})
|
||||
const rows=ref<WmsWorkRow[]>([])
|
||||
const selection=ref<WmsWorkRow[]>([])
|
||||
const summary=ref<WmsWorkSearchSummary>({totalCount:0,readyCount:0,inProgressCount:0,blockedCount:0})
|
||||
const resultCount=ref(0)
|
||||
const loading=ref(false)
|
||||
const searched=ref(false)
|
||||
const error=ref<unknown>(null)
|
||||
const activeExceptionKey=ref<string|null>(null)
|
||||
|
||||
const queryProblem=computed(()=>isKbxProblem(error.value)?error.value:null)
|
||||
const contentState=computed(()=>error.value?'error':loading.value&&!searched.value?'loading':!searched.value?'idle':rows.value.length===0?'empty':'ready')
|
||||
const selectedTask=computed(()=>selection.value.length===1?selection.value[0]:null)
|
||||
const canOpenTask=computed(()=>Boolean(selectedTask.value))
|
||||
const selectedTaskActionLabel=computed(()=>selectedTask.value?.status==='IN_PROGRESS'?'선택 작업 계속':'선택 작업 시작')
|
||||
const selectedBlocked=computed(()=>selectedTask.value?.status==='BLOCKED')
|
||||
|
||||
async function executeSearch(){
|
||||
loading.value=true;error.value=null;selection.value=[]
|
||||
try{
|
||||
const result=await searchWmsWork(search.value)
|
||||
rows.value=result.items;resultCount.value=result.totalCount;summary.value=result.summary;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'&&selectedTask.value&&!selectedBlocked.value)return router.push(routeForWmsTask(selectedTask.value))
|
||||
}
|
||||
const summaryItems=computed(()=>[
|
||||
{key:'total',label:'업무 대상',value:summary.value.totalCount},
|
||||
{key:'ready',label:'대기',value:summary.value.readyCount},
|
||||
{key:'progress',label:'진행',value:summary.value.inProgressCount},
|
||||
{key:'blocked',label:'예외',value:summary.value.blockedCount,emphasis:summary.value.blockedCount>0},
|
||||
])
|
||||
const exceptionCounters=computed(()=>summary.value.blockedCount>0?[{key:'blocked',label:'확인 필요한 작업',count:summary.value.blockedCount,severity:'warning' as const}]:[])
|
||||
async function filterException(key:string|null){activeExceptionKey.value=key;search.value={...search.value,status:key==='blocked'?'BLOCKED':''};await executeSearch()}
|
||||
async function resetSearch(){search.value={taskType:'',status:'',owner:'mine',keyword:''};activeExceptionKey.value=null;await executeSearch()}
|
||||
onMounted(()=>void executeSearch())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KbxQueuePage
|
||||
:screen="wmsWorkScreen"
|
||||
:selection-count="selection.length"
|
||||
:content-state="contentState"
|
||||
:refreshing="loading&&searched&&!error"
|
||||
:summary-items="summaryItems"
|
||||
:exception-counters="exceptionCounters"
|
||||
:active-exception-key="activeExceptionKey"
|
||||
queue-title="현재 작업 Queue"
|
||||
queue-description="작업유형·작업자 기준 전체 업무를 먼저 보여주고, 상태 필터는 사용자가 좁혀 봅니다."
|
||||
:queue-count="resultCount"
|
||||
breadcrumb="WMS > 작업관리"
|
||||
empty-title="조건에 맞는 물류 작업이 없습니다."
|
||||
empty-detail="작업유형·상태·작업자 조건을 변경하거나 기본조건으로 다시 조회하세요."
|
||||
empty-action-label="기본조건으로 조회"
|
||||
:error-title="queryProblem?.title || '물류 작업을 조회하지 못했습니다.'"
|
||||
:error-detail="queryProblem?.detail || '네트워크 상태를 확인한 후 다시 조회하세요.'"
|
||||
:error-retryable="queryProblem?.retryable !== false"
|
||||
retry-label="다시 조회"
|
||||
@command="executeCommand"
|
||||
@exception-filter="filterException"
|
||||
@empty-action="resetSearch"
|
||||
>
|
||||
<template #search><KbxSearchPanel v-model="search" :fields="wmsWorkSearchFields" @search="executeSearch"/></template>
|
||||
<template #queue-actions>
|
||||
<div class="wms-work-actions">
|
||||
<span v-if="selectedBlocked" role="status">예외 작업은 행을 열어 원인을 먼저 확인하세요.</span>
|
||||
<KbxButton :label="selectedTaskActionLabel" variant="primary" :disabled="!canOpenTask||selectedBlocked" @click="executeCommand('start')"/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
.wms-work-actions{display:flex;align-items:center;gap:var(--kbx-space-2)}
|
||||
.wms-work-actions span{font-size:var(--kbx-font-xs);color:var(--kbx-color-warning-text)}
|
||||
</style>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const wmsWorkRoutes: RouteRecordRaw[] = [{ path:'/wms/work', name:'wms-work', component:()=>import('./WmsWorkPage.vue'), meta:{ screenId:'WMS-WORK-001' } }]
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
|
||||
import { kbxStatusCatalog } from '../../../registry/statusCatalog'
|
||||
|
||||
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.4.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:110, statusMap:{definitions:kbxStatusCatalog.wmsWork} },
|
||||
{ field:'exceptionCount', header:'예외', type:'integer', width:80 },
|
||||
]
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import type { KbxIntegrationProblem } from '@kbx/contracts'
|
||||
import type { WmsWorkRow } from './work.definition'
|
||||
|
||||
export interface WmsWorkSearch { taskType?: string; status?: string; owner?: string; keyword?: string }
|
||||
export interface WmsWorkSearchSummary { totalCount:number; readyCount:number; inProgressCount:number; blockedCount:number }
|
||||
export interface WmsWorkSearchResult { items:WmsWorkRow[]; totalCount:number; summary:WmsWorkSearchSummary }
|
||||
|
||||
const demoRows:WmsWorkRow[]=[
|
||||
{taskId:'41000000-0000-4000-8000-000000000001',taskNo:'RCV-260811-001',taskType:'RECEIVING',waveNo:'ASN-260811-001',ownerName:'QA 사용자',progress:0,totalLines:3,completedLines:0,slaAt:'2026-08-11T23:40:00+09:00',status:'READY',exceptionCount:0},
|
||||
{taskId:'42000000-0000-4000-8000-000000000001',taskNo:'PUT-260811-004',taskType:'PUTAWAY',waveNo:'PUT-260811-A',ownerName:'QA 사용자',progress:33,totalLines:3,completedLines:1,slaAt:'2026-08-12T00:10:00+09:00',status:'IN_PROGRESS',exceptionCount:0},
|
||||
{taskId:'30000000-0000-4000-8000-000000000001',taskNo:'PICK-260811-001',taskType:'PICKING',waveNo:'W260811-01',ownerName:'QA 사용자',progress:17,totalLines:138,completedLines:23,slaAt:'2026-08-11T23:30:00+09:00',status:'READY',exceptionCount:0},
|
||||
{taskId:'43000000-0000-4000-8000-000000000001',taskNo:'CNT-260811-002',taskType:'COUNTING',waveNo:'COUNT-A',ownerName:'QA 사용자',progress:50,totalLines:8,completedLines:4,slaAt:'2026-08-12T00:30:00+09:00',status:'IN_PROGRESS',exceptionCount:1},
|
||||
{taskId:'30000000-0000-4000-8000-000000000009',taskNo:'PICK-260811-009',taskType:'PICKING',waveNo:'W260811-03',ownerName:'QA 사용자',progress:42,totalLines:62,completedLines:26,slaAt:'2026-08-11T23:20:00+09:00',status:'BLOCKED',exceptionCount:2},
|
||||
]
|
||||
|
||||
function matchesBase(row:WmsWorkRow, search:WmsWorkSearch){
|
||||
if(search.taskType&&row.taskType!==search.taskType)return false
|
||||
if(search.owner==='mine'&&row.ownerName!=='QA 사용자')return false
|
||||
if(search.owner==='unassigned'&&row.ownerName)return false
|
||||
const keyword=String(search.keyword??'').trim().toLocaleLowerCase()
|
||||
if(keyword&&!`${row.taskNo} ${row.waveNo??''} ${row.ownerName??''} ${row.taskType}`.toLocaleLowerCase().includes(keyword))return false
|
||||
return true
|
||||
}
|
||||
function matches(row:WmsWorkRow, search:WmsWorkSearch){
|
||||
return matchesBase(row,search)&&(!search.status||row.status===search.status)
|
||||
}
|
||||
|
||||
/**
|
||||
* The work queue must never pretend that an unconnected production query returned an empty set.
|
||||
* Demo mode provides browser-only fixtures so the FE workflow can be exercised without BE coupling.
|
||||
*/
|
||||
export async function searchWmsWork(search: WmsWorkSearch): Promise<WmsWorkSearchResult> {
|
||||
if(import.meta.env.VITE_KBX_DEMO_MODE==='true'){
|
||||
await new Promise(resolve=>setTimeout(resolve,90))
|
||||
const summaryRows=demoRows.filter(row=>matchesBase(row,search))
|
||||
const items=demoRows.filter(row=>matches(row,search)).map(row=>({...row}))
|
||||
return {
|
||||
items,
|
||||
totalCount:items.length,
|
||||
summary:{
|
||||
totalCount:summaryRows.length,
|
||||
readyCount:summaryRows.filter(row=>row.status==='READY').length,
|
||||
inProgressCount:summaryRows.filter(row=>row.status==='IN_PROGRESS').length,
|
||||
blockedCount:summaryRows.filter(row=>row.status==='BLOCKED').length,
|
||||
},
|
||||
}
|
||||
}
|
||||
const problem:KbxIntegrationProblem={
|
||||
type:'integration',
|
||||
code:'WMS_WORK_QUERY_NOT_CONNECTED',
|
||||
title:'물류 작업 조회를 연결할 수 없습니다.',
|
||||
detail:'운영 WMS 작업조회 API가 연결되기 전에는 빈 작업목록으로 가장하지 않습니다. 연결 상태를 확인한 후 다시 조회하세요.',
|
||||
retryable:false,
|
||||
}
|
||||
throw problem
|
||||
}
|
||||
|
||||
export function routeForWmsTask(row: WmsWorkRow): string {
|
||||
switch (row.taskType) {
|
||||
case 'RECEIVING': return `/wms/receiving/${row.taskId}`
|
||||
case 'PUTAWAY': return `/wms/putaway/${row.taskId}`
|
||||
case 'PICKING': return `/wms/picking/${row.taskId}`
|
||||
case 'COUNTING': return `/wms/counting/${row.taskId}`
|
||||
default: return `/wms/work?task=${encodeURIComponent(row.taskId)}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user