V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
import { countingScreen } from './counting.definition'
const online=ref(true);const stage=ref<'location'|'item'|'counted'>('location');const location=ref('');const item=ref('');const counted=ref(0);const bookQty=ref(12);const msg=ref('실사 위치를 스캔하세요.')
function scan(v:string){if(stage.value==='location'){location.value=v;stage.value='item';msg.value='상품을 스캔하세요.';return}if(stage.value==='item'){item.value=v;counted.value++;msg.value='계속 스캔하거나 수량 확정하세요.'}}
function finish(){stage.value='counted';msg.value=counted.value===bookQty.value?'장부수량과 일치합니다.':'차이가 있어 관리자 확인 대상으로 등록합니다.'}
</script>
<template><KbxWmsMobilePage :screen="countingScreen" :progress="stage==='counted'?'실사완료':'실사중'" :online="online"><p class="msg">{{msg}}</p><div v-if="location" class="box"><small>LOCATION</small><strong>{{location}}</strong></div><div v-if="item" class="box"><small>상품</small><strong>{{item}}</strong><p>실사 {{counted}} · 장부 {{bookQty}}</p></div><KbxBarcodeCapture v-if="stage!=='counted'" :enabled="online" :label="stage==='location'?'위치 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='item'" label="수량 확정" @click="finish"/><KbxWmsActionButton v-if="stage==='counted'" label="다음 위치" @click="stage='location';location='';item='';counted=0;msg='실사 위치를 스캔하세요.'"/></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.box{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.box small{display:block;color:var(--kbx-color-text-muted)}.box strong{font-size:28px}.box p{font-size:20px;font-weight:650}</style>
@@ -0,0 +1,3 @@
import { defineKbxScreen } from '@kbx/ui'
export const countingScreen=defineKbxScreen({id:'WMS-COUNT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'재고실사',helpKey:'WMS-COUNT-001',permissions:['wms.inventory.count'],telemetry:{enabled:true}})
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsCountingRoutes: RouteRecordRaw[] = [
{ path:'/wms/counting/:taskId', name:'wms-counting', component:()=>import('./WmsCountingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-COUNT-001' } },
]
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { KbxWmsExceptionType } from '@kbx/contracts'
import { KbxWmsActionButton } from '@kbx/ui'
defineProps<{ busy?: boolean }>()
const emit = defineEmits<{
close: []
submit: [KbxWmsExceptionType, string]
}>()
const type = ref<KbxWmsExceptionType>('no-stock')
const memo = ref('')
const options: { value: KbxWmsExceptionType; label: string }[] = [
{ value: 'no-stock', label: '실물 없음' },
{ value: 'short-quantity', label: '수량 부족' },
{ value: 'wrong-location', label: '위치 오류' },
{ value: 'damaged-item', label: '상품 이상' },
{ value: 'barcode-issue', label: '바코드 문제' },
{ value: 'other', label: '기타' },
]
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="exception-title">
<h2 id="exception-title">피킹 문제 신고</h2>
<p>가장 가까운 사유 하나만 선택하세요.</p>
<label v-for="option in options" :key="option.value" class="reason">
<input v-model="type" type="radio" :value="option.value">
<span>{{ option.label }}</span>
</label>
<label class="memo">메모 <small>선택 입력</small>
<textarea v-model="memo" rows="3" maxlength="300" />
</label>
<div class="actions">
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
<KbxWmsActionButton label="문제 등록" :busy="busy" @click="$emit('submit', type, memo)" />
</div>
</section>
</div>
</template>
<style scoped>
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:30; }
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; max-height:85dvh; overflow:auto; }
h2 { margin:0 0 4px; font-size:20px; }
p { margin:0 0 14px; color:var(--kbx-color-text-muted); }
.reason { min-height:48px; display:flex; align-items:center; gap:10px; border-bottom:1px solid var(--kbx-color-border); }
.reason input { width:20px; height:20px; }
.memo { display:block; margin-top:16px; font-weight:600; }
.memo small { font-weight:400; color:var(--kbx-color-text-muted); }
textarea { width:100%; margin-top:6px; border:1px solid var(--kbx-color-border); border-radius:6px; padding:10px; font:inherit; box-sizing:border-box; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxWmsActionButton } from '@kbx/ui'
const props = defineProps<{
current: number
required: number
busy?: boolean
}>()
const emit = defineEmits<{ close: []; submit: [number] }>()
const quantity = ref(props.current)
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="quantity-title">
<h2 id="quantity-title">피킹 수량 입력</h2>
<p>상품 바코드를 최소 1 확인한 사용합니다.</p>
<label>
피킹 수량
<input v-model.number="quantity" type="number" inputmode="decimal" :min="1" :max="required">
</label>
<div class="hint">필요수량 {{ required }}</div>
<div class="actions">
<KbxWmsActionButton label="취소" variant="secondary" @click="$emit('close')" />
<KbxWmsActionButton
label="수량 적용"
:busy="busy"
:disabled="quantity <= 0 || quantity > required"
@click="$emit('submit', quantity)"
/>
</div>
</section>
</div>
</template>
<style scoped>
.backdrop { position:fixed; inset:0; background:var(--kbx-color-overlay); display:flex; align-items:flex-end; z-index:31; }
.sheet { width:min(520px,100%); margin:0 auto; background:var(--kbx-color-surface); padding:20px 16px max(16px, env(safe-area-inset-bottom)); border-radius:12px 12px 0 0; }
h2 { margin:0 0 4px; font-size:20px; }
p { margin:0 0 16px; color:var(--kbx-color-text-muted); }
label { display:block; font-weight:600; }
input { width:100%; min-height:52px; margin-top:6px; padding:0 12px; box-sizing:border-box; font:inherit; font-size:22px; text-align:right; border:1px solid var(--kbx-color-border-strong); border-radius:6px; }
.hint { margin-top:6px; text-align:right; color:var(--kbx-color-text-muted); font-size:13px; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:18px; }
</style>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
KbxBarcodeCapture,
KbxWmsActionButton,
KbxWmsMobilePage,
} from '@kbx/ui'
import PickingExceptionSheet from './PickingExceptionSheet.vue'
import PickingQuantitySheet from './PickingQuantitySheet.vue'
import { useWmsPicking } from './useWmsPicking'
import { pickingScreen } from './picking.definition'
const props = defineProps<{ taskId: string }>()
const vm = useWmsPicking(props.taskId)
const progress = computed(() => vm.task.value
? `${vm.task.value.completedLines} / ${vm.task.value.totalLines}`
: undefined)
const current = computed(() => vm.task.value?.currentLine ?? null)
const stageTitle = computed(() => {
switch (vm.task.value?.stage) {
case 'ready': return '작업을 시작하세요.'
case 'await-location': return '위치를 스캔하세요.'
case 'await-item': return '상품을 스캔하세요.'
case 'processing': return '처리 중입니다.'
case 'completed': return '피킹을 완료했습니다.'
case 'blocked': return '관리자 확인이 필요합니다.'
default: return '작업을 불러오는 중입니다.'
}
})
</script>
<template>
<KbxWmsMobilePage
:screen="pickingScreen"
:progress="progress"
:online="vm.online.value"
:pending-commands="vm.pendingCommands.value"
:syncing="vm.syncing.value"
>
<p v-if="vm.message.value" class="message" role="status">{{ vm.message.value }}</p>
<section v-if="vm.task.value" class="instruction">
<p class="eyebrow">{{ stageTitle }}</p>
<template v-if="current">
<div class="location">
<span>LOCATION</span>
<strong>{{ current.locationCode }}</strong>
</div>
<div class="item">
<span>상품</span>
<strong>{{ current.itemName }}</strong>
<small>{{ current.itemCode }}<template v-if="current.itemOption"> · {{ current.itemOption }}</template></small>
<small>바코드 {{ current.barcode }}</small>
</div>
<div class="qty">
<div><span>필요</span><strong>{{ current.requiredQty }}</strong></div>
<div><span>피킹</span><strong>{{ current.pickedQty }}</strong></div>
<div><span>남음</span><strong>{{ current.remainingQty }}</strong></div>
</div>
</template>
<KbxWmsActionButton
v-if="current && vm.task.value.stage === 'await-item' && current.pickedQty > 0 && current.remainingQty > 0"
class="quantity-action"
label="수량 직접 입력"
variant="secondary"
@click="vm.openQuantity"
/>
<div v-else-if="vm.task.value.stage === 'ready'" class="ready">
<strong>{{ vm.task.value.taskNo }}</strong>
<span> {{ vm.task.value.totalLines }} 라인 · {{ vm.task.value.totalQty }}</span>
</div>
</section>
<KbxBarcodeCapture
v-if="vm.task.value && !['ready','completed','blocked'].includes(vm.task.value.stage)"
:enabled="vm.scanEnabled.value"
:label="vm.scanLabel.value"
@scan="vm.handleScan"
/>
<template #actions>
<KbxWmsActionButton
v-if="vm.task.value?.stage === 'ready'"
label="작업 시작"
:busy="vm.starting.value"
:disabled="!vm.online.value"
@click="vm.start"
/>
<KbxWmsActionButton
v-else-if="vm.task.value && ['await-location','await-item'].includes(vm.task.value.stage)"
label="문제 신고"
variant="secondary"
:disabled="!vm.online.value || vm.pendingCommands.value > 0"
@click="vm.openException"
/>
<KbxWmsActionButton
v-else-if="vm.task.value?.stage === 'completed'"
label="작업 목록으로"
@click="$router.push('/wms/picking')"
/>
</template>
</KbxWmsMobilePage>
<PickingQuantitySheet
v-if="vm.quantityOpen.value && current"
:current="current.pickedQty"
:required="current.requiredQty"
:busy="vm.settingQuantity.value"
@close="vm.closeQuantity"
@submit="vm.setQuantity"
/>
<PickingExceptionSheet
v-if="vm.exceptionOpen.value"
:busy="vm.reportingException.value"
@close="vm.closeException"
@submit="(type, memo) => vm.reportException(type, memo)"
/>
</template>
<style scoped>
.message { margin:0 0 12px; padding:10px 12px; background:var(--kbx-color-surface-subtle); border-radius:6px; font-size:14px; }
.instruction { text-align:center; }
.eyebrow { margin:0 0 12px; font-size:16px; font-weight:650; }
.location { padding:16px; background:var(--kbx-color-surface-muted); border:1px solid var(--kbx-color-border); border-radius:8px; }
.location span, .item span { display:block; color:var(--kbx-color-text-muted); font-size:12px; font-weight:600; }
.location strong { display:block; margin-top:3px; font-size:32px; letter-spacing:.03em; }
.item { padding:20px 4px 8px; }
.item strong { display:block; margin-top:4px; font-size:22px; }
.item small { display:block; margin-top:4px; color:var(--kbx-color-text-muted); font-size:13px; }
.qty { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:12px; }
.qty div { border:1px solid var(--kbx-color-border); border-radius:8px; padding:12px 4px; }
.qty span { display:block; font-size:13px; color:var(--kbx-color-text-muted); }
.qty strong { display:block; margin-top:2px; font-size:28px; }
.quantity-action { margin-top:var(--kbx-space-3); }
.ready { min-height:260px; display:flex; flex-direction:column; justify-content:center; gap:8px; }
.ready strong { font-size:26px; }
.ready span { color:var(--kbx-color-text-muted); }
</style>
@@ -0,0 +1,12 @@
import { defineKbxScreen } from '@kbx/ui'
export const pickingScreen = defineKbxScreen({
id: 'WMS-PICK-001',
version: '1.0.0',
module: 'WMS',
type: 'wms-mobile', templateCode:'T09',
title: '출고 피킹',
helpKey: 'WMS-PICK-001',
permissions: ['wms.picking.execute'],
telemetry: { enabled: true },
})
@@ -0,0 +1,36 @@
import type {
KbxWmsExceptionCommand,
KbxWmsPickingTask,
KbxWmsScanCommand,
KbxWmsScanResult,
KbxWmsSetQuantityCommand,
} from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export function getPickingTask(taskId: string) {
return kbxApi.request<KbxWmsPickingTask>('wms.picking.getTask', { path: { taskId } })
}
export function startPickingTask(taskId: string, expectedVersion: number, idempotencyKey = crypto.randomUUID()) {
return kbxApi.request<KbxWmsPickingTask, { expectedVersion: number }>('wms.picking.start', {
path: { taskId }, body: { expectedVersion }, idempotencyKey,
})
}
export function scanPickingBarcode(command: KbxWmsScanCommand) {
return kbxApi.request<KbxWmsScanResult, KbxWmsScanCommand>('wms.picking.scan', {
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
})
}
export function reportPickingException(command: KbxWmsExceptionCommand) {
return kbxApi.request<KbxWmsPickingTask, KbxWmsExceptionCommand>('wms.picking.reportException', {
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
})
}
export function setPickingQuantity(command: KbxWmsSetQuantityCommand) {
return kbxApi.request<KbxWmsScanResult, KbxWmsSetQuantityCommand>('wms.picking.setQuantity', {
path: { taskId: command.taskId }, body: command, idempotencyKey: command.idempotencyKey,
})
}
@@ -0,0 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsPickingRoutes: RouteRecordRaw[] = [
{
path: '/wms/picking/:taskId',
name: 'wms-picking-task',
component: () => import('./WmsPickingPage.vue'),
props: route => ({ taskId: String(route.params.taskId) }),
},
]
@@ -0,0 +1,234 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useMutation, useQuery } from '@tanstack/vue-query'
import type {
KbxBarcodeEvent,
KbxWmsExceptionType,
KbxWmsPickingTask,
KbxWmsScanCommand,
KbxWmsSetQuantityCommand,
} from '@kbx/contracts'
import { isKbxProblem, isRetryableKbxProblem } from '@kbx/contracts'
import { playKbxWmsFeedback, useKbxNetworkState } from '@kbx/ui'
import {
getPickingTask,
reportPickingException,
scanPickingBarcode,
setPickingQuantity,
startPickingTask,
} from './pickingApi'
import { kbxTelemetry, startKbxTask } from '../../../telemetry/kbxTelemetryClient'
import { pickingScreen } from './picking.definition'
import { queuePickingScan, readPickingRetryQueue, removePickingScan } from './wmsRetryQueue'
function newIdempotencyKey(taskId: string) {
return `${taskId}:${crypto.randomUUID()}`
}
export function useWmsPicking(taskId: string) {
const { online } = useKbxNetworkState()
const task = ref<KbxWmsPickingTask | null>(null)
const message = ref('')
const pendingCommands = ref(0)
const syncing = ref(false)
const exceptionOpen = ref(false)
const quantityOpen = ref(false)
let taskTelemetry:ReturnType<typeof startKbxTask>|undefined
const query = useQuery({
queryKey: ['wms-picking-task', taskId],
queryFn: () => getPickingTask(taskId),
})
watch(() => query.data.value, value => {
if (value) task.value = value
}, { immediate: true })
const startMutation = useMutation({
mutationFn: () => startPickingTask(taskId, task.value?.version ?? 0),
onSuccess(result) {
taskTelemetry=startKbxTask(pickingScreen.id,pickingScreen.version,'wms-picking')
task.value = result
message.value = result.message ?? '피킹을 시작합니다.'
playKbxWmsFeedback('neutral')
},
})
const scanMutation = useMutation({
mutationFn: (command: KbxWmsScanCommand) => scanPickingBarcode(command),
onSuccess(result, command) {
removePickingScan(command.idempotencyKey)
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
task.value = result.task
message.value = result.message
playKbxWmsFeedback(result.feedback)
if(result.task.stage==='completed'){taskTelemetry?.complete('success');taskTelemetry=undefined}
},
onError(error, command) {
// 4xx is an authoritative server rejection. Only network/5xx ambiguity is safe-retried.
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
message.value = error.title ?? '현재 작업 상태를 다시 확인하세요.'
playKbxWmsFeedback('error')
void query.refetch()
return
}
// The request may have committed even if the response was lost. Reuse the exact idempotency key.
queuePickingScan(command)
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
message.value = '서버 확인이 필요합니다. 연결되면 같은 작업을 안전하게 재전송합니다.'
playKbxWmsFeedback('warning')
},
})
const quantityMutation = useMutation({
mutationFn: (pickedQty: number) => {
if (!task.value?.currentLine) throw new Error('Current line not loaded')
const command: KbxWmsSetQuantityCommand = {
taskId,
lineId: task.value.currentLine.lineId,
pickedQty,
idempotencyKey: newIdempotencyKey(taskId),
expectedVersion: task.value.version,
}
return setPickingQuantity(command)
},
onSuccess(result) {
task.value = result.task
message.value = result.message
quantityOpen.value = false
playKbxWmsFeedback(result.feedback)
},
onError(error) {
message.value = isKbxProblem(error)
? error.title
: '수량을 반영하지 못했습니다.'
playKbxWmsFeedback('error')
void query.refetch()
},
})
const exceptionMutation = useMutation({
mutationFn: ({ type, memo }: { type: KbxWmsExceptionType; memo?: string }) => {
if (!task.value) throw new Error('Task not loaded')
return reportPickingException({
taskId,
lineId: task.value.currentLine?.lineId,
type,
memo,
idempotencyKey: newIdempotencyKey(taskId),
expectedVersion: task.value.version,
})
},
onSuccess(result, input) {
task.value = result
kbxTelemetry.track('manual.intervention',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{workType:'wms-picking',reasonCode:input.type,exceptionType:input.type}})
exceptionOpen.value = false
message.value = result.message ?? '예외를 등록했습니다.'
playKbxWmsFeedback('warning')
},
})
const scanEnabled = computed(() =>
Boolean(
task.value &&
online.value &&
pendingCommands.value === 0 &&
!scanMutation.isPending.value &&
['await-location', 'await-item'].includes(task.value.stage),
),
)
const scanLabel = computed(() => {
switch (task.value?.stage) {
case 'await-location': return 'LOCATION SCAN'
case 'await-item': return 'ITEM SCAN'
case 'processing': return 'PROCESSING'
case 'completed': return 'COMPLETED'
default: return 'SCAN READY'
}
})
async function handleScan(event: KbxBarcodeEvent) {
if (!task.value || !scanEnabled.value) return
taskTelemetry?.interaction('scan')
kbxTelemetry.track('command.execute',{screenId:pickingScreen.id,screenVersion:pickingScreen.version,taskSessionId:taskTelemetry?.id,attributes:{commandId:'scan',operationKind:'command'}})
const command: KbxWmsScanCommand = {
taskId,
barcode: event.normalizedValue,
source: event.source,
idempotencyKey: newIdempotencyKey(taskId),
expectedVersion: task.value.version,
occurredAt: new Date(event.occurredAt).toISOString(),
}
await scanMutation.mutateAsync(command).catch(() => undefined)
}
async function flushRetryQueue() {
if (!online.value || syncing.value) return
const queued = readPickingRetryQueue()
.filter(x => x.command.taskId === taskId)
.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt))
if (!queued.length) return
syncing.value = true
try {
// Authoritative picking is sequence-sensitive. Replay one-by-one and stop at first failure.
for (const item of queued) {
try {
const result = await scanPickingBarcode(item.command)
task.value = result.task
message.value = result.message
removePickingScan(item.command.idempotencyKey)
} catch (error) {
if (isKbxProblem(error) && !isRetryableKbxProblem(error)) {
removePickingScan(item.command.idempotencyKey)
message.value = error.title ?? '작업 상태가 변경되었습니다. 최신 상태를 확인하세요.'
playKbxWmsFeedback('error')
await query.refetch()
}
break
}
}
} finally {
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
syncing.value = false
}
}
watch(online, value => {
if (value) void flushRetryQueue()
})
onMounted(() => {
pendingCommands.value = readPickingRetryQueue().filter(x => x.command.taskId === taskId).length
if (online.value) void flushRetryQueue()
})
return {
task,
message,
online,
pendingCommands,
syncing,
scanEnabled,
scanLabel,
exceptionOpen,
quantityOpen,
loading: query.isLoading,
starting: startMutation.isPending,
scanning: scanMutation.isPending,
reportingException: exceptionMutation.isPending,
settingQuantity: quantityMutation.isPending,
start: () => startMutation.mutateAsync(),
handleScan,
openException: () => { exceptionOpen.value = true },
openQuantity: () => { quantityOpen.value = true },
closeQuantity: () => { quantityOpen.value = false },
setQuantity: (quantity: number) => quantityMutation.mutateAsync(quantity),
closeException: () => { exceptionOpen.value = false },
reportException: (type: KbxWmsExceptionType, memo?: string) => exceptionMutation.mutateAsync({ type, memo }),
}
}
@@ -0,0 +1,31 @@
import type { KbxWmsScanCommand } from '@kbx/contracts'
const STORAGE_KEY = 'kbx:wms:safe-retry:v1'
export interface QueuedPickingScan {
command: KbxWmsScanCommand
queuedAt: string
}
export function readPickingRetryQueue(): QueuedPickingScan[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as QueuedPickingScan[]
} catch {
return []
}
}
export function queuePickingScan(command: KbxWmsScanCommand) {
// Safety-first queue: only unacknowledged commands are retained for replay.
// UI stops accepting the next authoritative scan until the server confirms this one.
const queue = readPickingRetryQueue()
if (!queue.some(x => x.command.idempotencyKey === command.idempotencyKey)) {
queue.push({ command, queuedAt: new Date().toISOString() })
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue))
}
}
export function removePickingScan(idempotencyKey: string) {
const next = readPickingRetryQueue().filter(x => x.command.idempotencyKey !== idempotencyKey)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
}
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { KbxBarcodeCapture,KbxWmsActionButton,KbxWmsMobilePage } from '@kbx/ui'
import { putawayScreen } from './putaway.definition'
const online=ref(true);const stage=ref<'item'|'location'|'done'>('item');const item=ref('');const suggested=ref('A-01-03');const location=ref('');const msg=ref('적치할 상품을 스캔하세요.')
function scan(v:string){if(stage.value==='item'){item.value=v;stage.value='location';msg.value=`추천 위치 ${suggested.value}를 스캔하세요.`;return}if(stage.value==='location'){if(v!==suggested.value){msg.value=`다른 위치입니다. ${suggested.value}로 이동하세요.`;return}location.value=v;stage.value='done';msg.value='적치가 완료되었습니다.'}}
</script>
<template><KbxWmsMobilePage :screen="putawayScreen" :progress="stage==='done'?'완료':'적치중'" :online="online"><p class="msg">{{msg}}</p><div v-if="item" class="item"><small>상품</small><strong>{{item}}</strong></div><div v-if="stage!=='item'" class="location"><small>추천 LOCATION</small><strong>{{suggested}}</strong></div><KbxBarcodeCapture v-if="stage!=='done'" :enabled="online" :label="stage==='item'?'상품 스캔':'위치 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='done'" label="다음 상품" @click="stage='item';item='';location='';msg='적치할 상품을 스캔하세요.'"/></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.item,.location{padding:16px;border:1px solid var(--kbx-color-border);border-radius:8px;margin-bottom:12px}.item small,.location small{display:block;color:var(--kbx-color-text-muted)}.item strong{font-size:22px}.location strong{font-size:32px}</style>
@@ -0,0 +1,3 @@
import { defineKbxScreen } from '@kbx/ui'
export const putawayScreen=defineKbxScreen({id:'WMS-PUT-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 적치',helpKey:'WMS-PUT-001',permissions:['wms.putaway.execute'],telemetry:{enabled:true}})
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsPutawayRoutes: RouteRecordRaw[] = [
{ path:'/wms/putaway/:taskId', name:'wms-putaway', component:()=>import('./WmsPutawayPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-PUT-001' } },
]
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { KbxBarcodeCapture, KbxWmsActionButton, KbxWmsMobilePage } from '@kbx/ui'
import { receivingScreen } from './receiving.definition'
const props=defineProps<{taskId:string}>(); const online=ref(true); const stage=ref<'po'|'item'|'qty'|'completed'>('po'); const poNo=ref(''); const item=ref({code:'',name:'',expected:0,received:0}); const message=ref('입고예정번호 또는 ASN을 스캔하세요.')
const progress=computed(()=>stage.value==='completed'?'완료':stage.value==='po'?'입고대기':'검수중')
function scan(value:string){ if(stage.value==='po'){poNo.value=value; stage.value='item'; message.value='상품을 스캔하세요.'; return} if(stage.value==='item'){item.value={code:value,name:'스캔 품목',expected:10,received:1}; stage.value='qty'; message.value='수량을 확인하세요.'}}
function complete(){stage.value='completed';message.value='입고 검수가 완료되었습니다.'}
</script>
<template><KbxWmsMobilePage :screen="receivingScreen" :progress="progress" :online="online"><p class="msg">{{message}}</p><section v-if="poNo" class="card"><small>입고예정</small><strong>{{poNo}}</strong></section><section v-if="item.code" class="card"><small>상품</small><strong>{{item.name}}</strong><span>{{item.code}}</span><div class="qty">예정 {{item.expected}} · 검수 {{item.received}}</div></section><KbxBarcodeCapture v-if="stage==='po'||stage==='item'" :enabled="online" :label="stage==='po'?'입고예정 스캔':'상품 스캔'" @scan="scan"/><template #actions><KbxWmsActionButton v-if="stage==='qty'" label="검수 완료" @click="complete"/><KbxWmsActionButton v-else-if="stage==='completed'" label="다음 입고" @click="stage='po';poNo='';item={code:'',name:'',expected:0,received:0}"/></template></KbxWmsMobilePage></template>
<style scoped>.msg{font-weight:650}.card{display:flex;flex-direction:column;gap:5px;border:1px solid var(--kbx-color-border);border-radius:8px;padding:16px;margin-bottom:12px}.card small,.card span{color:var(--kbx-color-text-muted)}.card strong{font-size:24px}.qty{font-size:20px;font-weight:650}</style>
@@ -0,0 +1,3 @@
import { defineKbxScreen } from '@kbx/ui'
export const receivingScreen=defineKbxScreen({id:'WMS-REC-001',version:'1.0.0',module:'WMS',type:'wms-mobile', templateCode:'T09',title:'입고 검수',helpKey:'WMS-REC-001',permissions:['wms.receiving.execute'],telemetry:{enabled:true}})
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsReceivingRoutes: RouteRecordRaw[] = [
{ path:'/wms/receiving/:taskId', name:'wms-receiving', component:()=>import('./WmsReceivingPage.vue'), props:r=>({taskId:String(r.params.taskId)}), meta:{ screenId:'WMS-REC-001' } },
]
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { KbxDataGrid, KbxQueuePage, KbxSearchPanel } from '@kbx/ui'
import { routeForWmsTask, searchWmsWork } from './workApi'
import { wmsWorkColumns, wmsWorkScreen, wmsWorkSearchFields, type WmsWorkRow } from './work.definition'
const router=useRouter();const search=reactive({taskType:'',status:'READY',owner:'mine',keyword:''});const rows=ref<WmsWorkRow[]>([]);const selection=ref<WmsWorkRow[]>([]);const loading=ref(false);const searched=ref(false);const error=ref<unknown>(null);const activeExceptionKey=ref<string|null>(null)
async function executeSearch(){loading.value=true;error.value=null;try{rows.value=await searchWmsWork(search);searched.value=true}catch(cause){error.value=cause;searched.value=true}finally{loading.value=false}}
function executeCommand(id:string){if(id==='search')return executeSearch();if(id==='start'&&selection.value[0])return router.push(routeForWmsTask(selection.value[0]))}
const summaryItems=computed(()=>[
{key:'total',label:'전체 작업',value:rows.value.length},
{key:'ready',label:'대기',value:rows.value.filter(x=>x.status==='READY').length},
{key:'blocked',label:'예외',value:rows.value.filter(x=>x.status==='BLOCKED').length,emphasis:true},
])
const exceptionCounters=computed(()=>[{key:'blocked',label:'확인 필요한 작업',count:rows.value.filter(x=>x.status==='BLOCKED').length,severity:'warning' as const}])
async function filterException(key:string|null){activeExceptionKey.value=key;search.status=key==='blocked'?'BLOCKED':'READY';await executeSearch()}
</script>
<template><KbxQueuePage :screen="wmsWorkScreen" :selection-count="selection.length" :content-state="error?'error':loading&&!searched?'loading':!searched?'idle':rows.length===0?'empty':'ready'" :refreshing="loading&&searched" :summary-items="summaryItems" :exception-counters="exceptionCounters" :active-exception-key="activeExceptionKey" breadcrumb="WMS > 작업관리" @command="executeCommand" @exception-filter="filterException"><template #search><KbxSearchPanel v-model="search" :fields="wmsWorkSearchFields" @search="executeSearch"/></template><template #content><KbxDataGrid :rows="rows" :columns="wmsWorkColumns" row-key="taskId" selection="single" :loading="loading" @selection-changed="selection=$event" @row-double-clicked="row=>router.push(routeForWmsTask(row))"/></template></KbxQueuePage></template>
@@ -0,0 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const wmsWorkRoutes: RouteRecordRaw[] = [{ path:'/wms/work', name:'wms-work', component:()=>import('./WmsWorkPage.vue'), meta:{ screenId:'WMS-WORK-001' } }]
@@ -0,0 +1,47 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
export interface WmsWorkRow {
taskId: string
taskNo: string
taskType: 'RECEIVING' | 'PUTAWAY' | 'PICKING' | 'CHECKING' | 'COUNTING'
waveNo?: string
ownerName?: string
progress: number
totalLines: number
completedLines: number
slaAt?: string
status: string
exceptionCount: number
}
export const wmsWorkScreen = defineKbxScreen({
id: 'WMS-WORK-001', version: '1.0.0', module: 'WMS', type: 'queue', templateCode:'T06', title: '물류 작업',
description: '입고·적치·피킹·검수·실사 작업과 예외를 작업자 관점에서 조회합니다.',
helpKey: 'WMS-WORK-001', permissions: ['wms.work.read'], telemetry: { enabled: true },
commands: [
{ id:'search', label:'조회', group:'query', shortcut:'F3' },
{ id:'start', label:'작업 시작', group:'workflow', variant:'primary', requiresSelection:true, minSelection:1, maxSelection:1, permission:'wms.work.execute' },
],
})
export const wmsWorkSearchFields: KbxSearchField[] = [
{ key:'taskType', label:'작업유형', type:'select', primary:true, options:[
{value:'RECEIVING',label:'입고검수'},{value:'PUTAWAY',label:'적치'},{value:'PICKING',label:'피킹'},{value:'CHECKING',label:'검수'},{value:'COUNTING',label:'실사'},
] },
{ key:'status', label:'상태', type:'select', primary:true, options:[{value:'READY',label:'대기'},{value:'IN_PROGRESS',label:'진행'},{value:'BLOCKED',label:'예외'}] },
{ key:'owner', label:'작업자', type:'select', primary:true, options:[{value:'mine',label:'내 작업'},{value:'unassigned',label:'미배정'}] },
{ key:'keyword', label:'검색', type:'text', primary:true, width:'lg', placeholder:'작업번호 / Wave / 주문번호' },
]
export const wmsWorkColumns: KbxGridColumn<WmsWorkRow>[] = [
{ field:'taskNo', header:'작업번호', type:'code', width:150, pinned:'left' },
{ field:'taskType', header:'작업유형', width:100 },
{ field:'waveNo', header:'Wave', type:'code', width:130 },
{ field:'ownerName', header:'작업자', width:100 },
{ field:'progress', header:'진행률(%)', type:'percent', width:100 },
{ field:'completedLines', header:'완료', type:'integer', width:80 },
{ field:'totalLines', header:'전체', type:'integer', width:80 },
{ field:'slaAt', header:'SLA', type:'datetime', width:155 },
{ field:'status', header:'상태', type:'status', width:100 },
{ field:'exceptionCount', header:'예외', type:'integer', width:80 },
]
@@ -0,0 +1,12 @@
import type { WmsWorkRow } from './work.definition'
export interface WmsWorkSearch { taskType?: string; status?: string; owner?: string; keyword?: string }
export async function searchWmsWork(_search: WmsWorkSearch): Promise<WmsWorkRow[]> { return [] }
export function routeForWmsTask(row: WmsWorkRow): string {
switch (row.taskType) {
case 'RECEIVING': return `/wms/receiving/${row.taskId}`
case 'PUTAWAY': return `/wms/putaway/${row.taskId}`
case 'PICKING': return `/wms/picking/${row.taskId}`
case 'COUNTING': return `/wms/counting/${row.taskId}`
default: return `/wms/work?task=${encodeURIComponent(row.taskId)}`
}
}