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,9 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
import { inventoryMoveColumns, inventoryMoveScreen, inventoryMoveWorkflow, type InventoryMoveLine } from './inventory-move.definition'
const status=ref('DRAFT'); const header=reactive({moveDate:new Date().toISOString().slice(0,10),fromWarehouseId:null as string|null,toWarehouseId:null as string|null}); const lines=ref<InventoryMoveLine[]>([])
function command(id:string){ const t=inventoryMoveWorkflow.transitions.find(x=>x.id===id && x.from.includes(status.value)); if(t) status.value=t.to }
</script>
<template><KbxTransactionPage :screen="inventoryMoveScreen" :status="status" :summary-items="[{key:'items',label:'품목',value:`${lines.length}종`}]" @command="command"><template #header><KbxWorkflowBar :workflow="inventoryMoveWorkflow" :current="status" @transition="command"/><KbxFormSection title="이동정보"><KbxFormGrid><KbxDateField v-model="header.moveDate" label="이동일" required/><KbxLookup v-model="header.fromWarehouseId" entity="warehouse" label="출발창고" required/><KbxLookup v-model="header.toWarehouseId" entity="warehouse" label="도착창고" required/></KbxFormGrid></KbxFormSection></template><template #detail><KbxDataGrid :rows="lines" :columns="inventoryMoveColumns" row-key="clientId" editable clipboard/></template></KbxTransactionPage></template>
@@ -0,0 +1,8 @@
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
export interface InventoryMoveLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; availableQty:number; moveQty:number; lotNo:string; remark:string }
export const inventoryMoveScreen=defineKbxScreen({ id:'ERP-INV-MOVE-001',version:'1.0.0',module:'ERP',type:'transaction', templateCode:'T03',title:'재고이동',helpKey:'ERP-INV-MOVE-001',permissions:['erp.inventory.move.read'],telemetry:{enabled:true}, description:'창고 간 재고이동을 등록하고 출고·입고 상태를 추적합니다.', commands:[
{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.inventory.move.write'},{id:'confirm',label:'이동확정',group:'workflow',variant:'primary',permission:'erp.inventory.move.confirm'},{id:'excel',label:'엑셀',group:'output'}] })
export const inventoryMoveColumns:KbxGridColumn<InventoryMoveLine>[]=[
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'},{field:'itemName',header:'품목명',width:200},{field:'availableQty',header:'가용재고',type:'quantity',width:100},{field:'moveQty',header:'이동수량',type:'quantity',width:100,editable:true},{field:'lotNo',header:'LOT',type:'code',width:120,editable:true},{field:'remark',header:'비고',width:220,editable:true}]
export const inventoryMoveWorkflow:KbxWorkflowDefinition={id:'erp.inventory-move',version:'1.0.0',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'CONFIRMED',label:'확정',semantic:'pending'},{value:'IN_TRANSIT',label:'이동중',semantic:'processing'},{value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true},{value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}],transitions:[{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'이동확정',permission:'erp.inventory.move.confirm',confirm:true},{id:'ship',from:['CONFIRMED'],to:'IN_TRANSIT',label:'출고',permission:'erp.inventory.move.ship'},{id:'receive',from:['IN_TRANSIT'],to:'RECEIVED',label:'입고완료',permission:'erp.inventory.move.receive',confirm:true}]}
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const erpInventoryMoveRoutes: RouteRecordRaw[] = [
{ path:'/erp/inventory-moves/new', name:'erp-inventory-move-new', component:()=>import('./InventoryMovePage.vue'), meta:{ screenId:'ERP-INV-MOVE-001' } },
]
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { KbxDataGrid, KbxDrawer, KbxMasterDetailPage, KbxSearchPanel } from '@kbx/ui'
import { inventoryApi } from './inventoryApi'
import { inventoryHistoryColumns, inventoryItemColumns, inventoryLocationColumns, inventoryScreen, inventorySearchFields, type InventoryItemRow } from './inventory.definition'
const appliedKeyword=ref('')
const searchModel=ref<Record<string, unknown>>({ keyword: '' })
const selectedItemId=ref<string|null>(null)
const selectedItem=ref<InventoryItemRow|null>(null)
const breakdownOpen=ref(false)
const itemsQuery=useQuery({queryKey:computed(()=>['erp','inventory',appliedKeyword.value]),queryFn:()=>inventoryApi.search(appliedKeyword.value),enabled:false})
const locationsQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'locations']),queryFn:()=>inventoryApi.locations(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
const historyQuery=useQuery({queryKey:computed(()=>['erp','inventory',selectedItemId.value,'history']),queryFn:()=>inventoryApi.history(selectedItemId.value!),enabled:computed(()=>Boolean(selectedItemId.value))})
async function search(){
const previous=selectedItemId.value; appliedKeyword.value=String(searchModel.value.keyword??'').trim(); await itemsQuery.refetch()
const items=itemsQuery.data.value?.items??[]; const next=items.find(x=>x.itemId===previous)??items[0]??null; activate(next)
}
function activate(row:InventoryItemRow|null){ selectedItem.value=row; selectedItemId.value=row?.itemId??null }
function select(rows:InventoryItemRow[]){ activate(rows[0]??null) }
function drill(row:InventoryItemRow){ activate(row); breakdownOpen.value=true }
function command(id:string){if(id==='search')return search()}
const contextText=computed(()=>selectedItem.value?`${selectedItem.value.itemCode} · ${selectedItem.value.itemName}${selectedItem.value.specification?` · ${selectedItem.value.specification}`:''}`:'품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')
const pageContext=computed(()=>({label:contextText.value,hint:selectedItem.value?'선택 품목 기준으로 위치와 이력을 동기화합니다.':'Master 품목을 선택하세요.',metrics:selectedItem.value?[{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true}]:[]}))
const summaryItems=computed(()=>[
{key:'items',label:'조회 품목',value:itemsQuery.data.value?.totalCount??0},
{key:'selected',label:'선택 품목',value:selectedItem.value?.itemCode??'-'},
])
const drawerSummary=computed(()=>selectedItem.value?[
{key:'onhand',label:'현재고',value:selectedItem.value.totalQty},
{key:'allocated',label:'할당',value:selectedItem.value.allocatedQty},
{key:'hold',label:'보류',value:selectedItem.value.holdQty},
{key:'available',label:'가용',value:selectedItem.value.availableQty,emphasis:true},
]:[])
</script>
<template>
<KbxMasterDetailPage :screen="inventoryScreen" breadcrumb="ERP > 재고" master-title="품목" detail-title="창고/로케이션" bottom-title="재고이력" :context="pageContext" :summary-items="summaryItems" :content-state="itemsQuery.error.value?'error':itemsQuery.isFetching.value&&!itemsQuery.data.value?'loading':!itemsQuery.data.value?'idle':itemsQuery.data.value.totalCount===0?'empty':'ready'" :refreshing="itemsQuery.isFetching.value&&Boolean(itemsQuery.data.value)" @command="command">
<template #search><KbxSearchPanel v-model="searchModel" :fields="inventorySearchFields" @search="search" /></template>
<template #master>
<KbxDataGrid :rows="itemsQuery.data.value?.items??[]" :columns="inventoryItemColumns" row-key="itemId" selection="single" :active-row-key="selectedItemId" :loading="itemsQuery.isFetching.value" :error-text="itemsQuery.error.value?'재고를 조회하지 못했습니다.':''" @selection-changed="select" @row-double-clicked="drill" @drill-down-requested="drill" @retry="search" />
</template>
<template #detail>
<KbxDataGrid :rows="locationsQuery.data.value?.items??[]" :columns="inventoryLocationColumns" row-key="key" :loading="locationsQuery.isFetching.value" :error-text="locationsQuery.error.value?'재고 위치를 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고 위치가 없습니다." @retry="()=>locationsQuery.refetch()" />
</template>
<template #bottom>
<KbxDataGrid :rows="historyQuery.data.value?.items??[]" :columns="inventoryHistoryColumns" row-key="entryId" :loading="historyQuery.isFetching.value" :error-text="historyQuery.error.value?'재고이력을 조회하지 못했습니다.':''" empty-text="선택한 품목의 재고이력이 없습니다." @retry="()=>historyQuery.refetch()" />
</template>
</KbxMasterDetailPage>
<KbxDrawer v-model:open="breakdownOpen" :title="selectedItem?`${selectedItem.itemCode} 재고 산정 근거`:'재고 산정 근거'">
<div v-if="selectedItem" class="inventory-breakdown">
<p>{{selectedItem.itemName}} {{selectedItem.specification}}</p>
<KbxSummaryBar :items="drawerSummary" />
<dl><dt>현재고</dt><dd>물리적으로 기록된 재고</dd><dt>할당</dt><dd>주문·작업에 예약된 수량</dd><dt>보류</dt><dd>검사·품질·업무 사유로 출고할 없는 수량</dd><dt>가용</dt><dd>현재 정책상 신규 업무에 사용할 있는 수량</dd></dl>
<p class="inventory-breakdown__note">수량 산정의 최종 기준은 서버 재고 정책이며, 화면 값은 조회 Projection입니다.</p>
</div>
</KbxDrawer>
</template>
<style scoped>
.inventory-breakdown{display:flex;flex-direction:column;gap:var(--kbx-space-3);font-size:var(--kbx-font-sm)}
.inventory-breakdown p{margin:0}.inventory-breakdown dl{display:grid;grid-template-columns:80px 1fr;gap:var(--kbx-space-2);margin:0}.inventory-breakdown dt{font-weight:600}.inventory-breakdown dd{margin:0;color:var(--kbx-color-text-muted)}.inventory-breakdown__note{padding:var(--kbx-space-2);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>
@@ -0,0 +1,49 @@
import { defineKbxScreen, type KbxGridColumn, type KbxSearchField } from '@kbx/ui'
export interface InventoryItemRow {
itemId: string; itemCode: string; itemName: string; specification: string
totalQty: number; availableQty: number; allocatedQty: number; holdQty: number
}
export interface InventoryLocationRow {
key: string; warehouseName: string; locationCode: string
onHandQty: number; allocatedQty: number; availableQty: number; holdQty: number
}
export interface InventoryHistoryRow {
entryId: string; occurredAt: string; businessType: string; referenceNo: string
warehouseName: string; locationCode: string; inboundQty: number; outboundQty: number; balanceQty: number; actor: string
}
export const inventoryScreen = defineKbxScreen({
id: 'ERP-INV-001', version: '1.1.0', module: 'ERP', type: 'master-detail', templateCode:'T05', title: '재고현황',
description: '품목 현재고에서 창고·로케이션 근거와 재고이력까지 조회 Context를 유지해 탐색합니다.',
helpKey: 'ERP-INV-001', permissions: ['erp.inventory.read'], telemetry: { enabled: true },
commands: [{ id: 'search', label: '조회', group: 'query', shortcut: 'F3' }, { id: 'excel', label: '엑셀', group: 'output' }],
})
export const inventoryItemColumns: KbxGridColumn<InventoryItemRow>[] = [
{ field: 'itemCode', header: '품목코드', type: 'code', width: 120, pinned: 'left' },
{ field: 'itemName', header: '품목명', width: 190 },
{ field: 'specification', header: '규격', width: 140 },
{ field: 'totalQty', header: '현재고', type: 'quantity', width: 100, drilldown:true },
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100, drilldown:true },
]
export const inventoryLocationColumns: KbxGridColumn<InventoryLocationRow>[] = [
{ field: 'warehouseName', header: '창고', width: 150 },
{ field: 'locationCode', header: '로케이션', type: 'code', width: 120 },
{ field: 'onHandQty', header: '현재고', type: 'quantity', width: 100 },
{ field: 'allocatedQty', header: '할당', type: 'quantity', width: 90 },
{ field: 'holdQty', header: '보류', type: 'quantity', width: 90 },
{ field: 'availableQty', header: '가용', type: 'quantity', width: 100 },
]
export const inventoryHistoryColumns: KbxGridColumn<InventoryHistoryRow>[] = [
{ field:'occurredAt', header:'일시', type:'datetime', width:165 },
{ field:'businessType', header:'업무', width:110 },
{ field:'referenceNo', header:'참조번호', type:'code', width:150 },
{ field:'warehouseName', header:'창고', width:130 },
{ field:'locationCode', header:'로케이션', type:'code', width:110 },
{ field:'inboundQty', header:'입고', type:'quantity', width:90 },
{ field:'outboundQty', header:'출고', type:'quantity', width:90 },
{ field:'balanceQty', header:'잔량', type:'quantity', width:100 },
{ field:'actor', header:'처리자', width:110 },
]
export const inventorySearchFields: KbxSearchField[] = [{ key: 'keyword', label: '품목', type: 'text', width: 'lg', placeholder: '품목코드/품목명' }]
@@ -0,0 +1,8 @@
import { kbxApi } from '../../../http/generated/kbxApiClient'
import type { InventoryHistoryRow, InventoryItemRow, InventoryLocationRow } from './inventory.definition'
export interface InventorySearchResponse { items: InventoryItemRow[]; totalCount: number }
export const inventoryApi = {
search(keyword = '') { return kbxApi.request<InventorySearchResponse>('erp.inventory.search', { query: { keyword, page: 1, pageSize: 200 } }) },
locations(itemId: string) { return kbxApi.request<{ items: InventoryLocationRow[] }>('erp.inventory.locations', { path: { itemId } }) },
history(itemId: string) { return kbxApi.request<{ items: InventoryHistoryRow[] }>('erp.inventory.history', { path: { itemId }, query:{ pageSize:100 } }) },
}
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxLookupItem } from '@kbx/contracts'
import { KbxDataGrid, KbxFastEntryPage, KbxLookupDialog, useKbxPageShortcuts } from '@kbx/ui'
import { itemPriceColumns, itemPriceScreen, type ItemPriceRow } from './item-price.definition'
import { useItemPriceFastEntry } from './useItemPriceFastEntry'
import { useKbxWorkspaceBinding } from '../../../shell/useKbxWorkspaceBinding'
const vm=useItemPriceFastEntry()
useKbxWorkspaceBinding(vm.dirty, async()=>await vm.save())
const lookupOpen=ref(false); const activeRow=ref<ItemPriceRow|null>(null)
function openLookup(event:{row:ItemPriceRow;entity:string}){ if(event.entity!=='item')return;activeRow.value=event.row;lookupOpen.value=true }
function selectItem(item:KbxLookupItem<string>){ if(activeRow.value)vm.applyItemLookup(activeRow.value,item) }
const pageContext=computed(()=>({label:'품목 단가 입력',hint:'저장 전 오류를 모두 해소한 뒤 F8로 일괄 저장합니다.',metrics:[{key:'rows',label:'입력',value:vm.enteredCount.value},{key:'errors',label:'오류',value:vm.errors.value.length,tone:vm.errors.value.length?'danger' as const:'default' as const,emphasis:vm.errors.value.length>0}]}))
const summary=computed(()=>[
{key:'rows',label:'입력',value:`${vm.enteredCount.value.toLocaleString('ko-KR')}`},
{key:'errors',label:'오류',value:`${vm.errors.value.length.toLocaleString('ko-KR')}`},
...(vm.lastResult.value?[{key:'saved',label:'최근 저장',value:`${vm.lastResult.value.saved.toLocaleString('ko-KR')}`,emphasis:true}]:[]),
])
useKbxPageShortcuts([{key:'F8',execute:()=>vm.save()}])
async function command(id:string){ if(id==='save')await vm.save(); if(id==='new')vm.clear() }
</script>
<template>
<KbxFastEntryPage :screen="itemPriceScreen" :selection-count="0" :dirty="vm.dirty.value" :context="pageContext" :errors="vm.errors.value" :summary-items="summary" breadcrumb="ERP > 기준정보" @command="command">
<template #guide>
<span><kbd>Enter</kbd> 다음 </span><span><kbd>F2</kbd> 품목 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span> 선택 아래 채우기/복제</span>
</template>
<template #notice>
<div v-if="vm.lastResult.value" class="price-save-result" role="status">저장 {{vm.lastResult.value.saved.toLocaleString('ko-KR')}} · 신규 {{vm.lastResult.value.created.toLocaleString('ko-KR')}} · 수정 {{vm.lastResult.value.updated.toLocaleString('ko-KR')}}</div>
</template>
<template #content>
<KbxDataGrid
:rows="vm.rows.value" :columns="itemPriceColumns" row-key="clientId" selection="multiple"
:errors="vm.errors.value" editable clipboard
:editing-policy="{allowRowAdd:true,allowRowDuplicate:true,fillDown:true,paste:true,errorNavigation:true}"
@row-add-requested="vm.addRow" @row-duplicate-requested="vm.duplicateRows" @cell-changed="vm.onCellChanged" @lookup-requested="openLookup"
/>
</template>
</KbxFastEntryPage>
<KbxLookupDialog v-model:visible="lookupOpen" entity="item" title="품목" @select="selectItem" />
</template>
<style scoped>
.price-save-result{min-height:var(--kbx-control-sm);display:flex;align-items:center;padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-success-border);background:var(--kbx-color-success-surface);color:var(--kbx-color-success-text);font-size:var(--kbx-font-sm)}
</style>
@@ -0,0 +1,36 @@
import { defineKbxScreen, type KbxGridColumn } from '@kbx/ui'
export interface ItemPriceRow {
clientId: string
itemId: string | null
itemCode: string
itemName: string
effectiveDate: string
unitPrice: number
remark: string
}
export const itemPriceScreen = defineKbxScreen({
id: 'ERP-PRICE-001',
version: '1.0.0',
module: 'ERP',
type: 'fast-entry', templateCode:'T04',
title: '품목 단가 일괄등록',
description: '품목별 적용일과 표준단가를 Excel처럼 연속 입력하고 한 번에 저장합니다.',
helpKey: 'ERP-PRICE-001',
permissions: ['erp.item.price.read', 'erp.item.read'],
commands: [
{ id: 'new', label: '신규', group: 'edit' },
{ id: 'save', label: '저장', group: 'edit', variant: 'primary', shortcut: 'F8', permission: 'erp.item.price.write' },
{ id: 'excel', label: '엑셀', group: 'output' },
],
telemetry: { enabled: true },
})
export const itemPriceColumns: KbxGridColumn<ItemPriceRow>[] = [
{ field:'itemCode', header:'품목코드', type:'lookup', width:140, pinned:'left', editable:true, lookup:{entity:'item'} },
{ field:'itemName', header:'품목명', width:220 },
{ field:'effectiveDate', header:'적용일', type:'date', width:120, editable:true },
{ field:'unitPrice', header:'단가', type:'money', width:130, editable:true },
{ field:'remark', header:'비고', width:260, editable:true },
]
@@ -0,0 +1,25 @@
import { isKbxProblem, type KbxProblem } from '@kbx/contracts'
import { kbxApi } from '../../../http/generated/kbxApiClient'
export interface SaveItemPriceRow {
clientId: string
itemId: string
effectiveDate: string
unitPrice: number
remark?: string
}
export interface SaveItemPricesRequest { rows: SaveItemPriceRow[] }
export interface SaveItemPricesResponse { requested: number; saved: number; created: number; updated: number }
export interface ItemLookupResult { id: string; code: string; displayName: string; status?: string }
export function toKbxProblem(error: unknown): KbxProblem | null { return isKbxProblem(error) ? error : null }
export const itemPriceApi = {
async resolveItemByCode(code: string) {
try { return await kbxApi.request<ItemLookupResult>('lookup.items.resolveByCode', { path:{ code } }) }
catch (error) { if (isKbxProblem(error) && error.type === 'not-found') return null; throw error }
},
save(request: SaveItemPricesRequest, idempotencyKey: string) {
return kbxApi.request<SaveItemPricesResponse, SaveItemPricesRequest>('erp.itemPrices.bulkSave', { body:request, idempotencyKey })
},
}
@@ -0,0 +1,65 @@
import { computed, ref } from 'vue'
import { useMutation } from '@tanstack/vue-query'
import type { KbxLookupItem, KbxValidationError } from '@kbx/contracts'
import { useKbxDirtyState, useKbxValidation } from '@kbx/ui'
import { itemPriceApi, toKbxProblem } from './itemPriceApi'
import type { ItemPriceRow } from './item-price.definition'
function localDate() {
const d=new Date(); const m=String(d.getMonth()+1).padStart(2,'0'); const day=String(d.getDate()).padStart(2,'0')
return `${d.getFullYear()}-${m}-${day}`
}
function newRow():ItemPriceRow { return { clientId:crypto.randomUUID(), itemId:null, itemCode:'', itemName:'', effectiveDate:localDate(), unitPrice:0, remark:'' } }
export function useItemPriceFastEntry() {
const rows=ref<ItemPriceRow[]>(Array.from({length:8},()=>newRow()))
const validation=useKbxValidation()
const { dirty, touch, markSaved, reset:resetDirty }=useKbxDirtyState()
const mutation=useMutation({ mutationFn:({request,key}:{request:{rows:Array<{clientId:string;itemId:string;effectiveDate:string;unitPrice:number;remark?:string}>};key:string})=>itemPriceApi.save(request,key) })
const lastResult=ref<{requested:number;saved:number;created:number;updated:number}|null>(null)
let idempotencyKey=crypto.randomUUID()
const enteredRows=computed(()=>rows.value.filter(x=>x.itemId||x.itemCode.trim()||Number(x.unitPrice)!==0||x.remark.trim()))
function invalidateCommandKey(){ idempotencyKey=crypto.randomUUID() }
function addRow(){ rows.value.push(newRow()); validation.clear(); touch(); invalidateCommandKey() }
function duplicateRows(source:ItemPriceRow[]){ rows.value.push(...source.map(x=>({...x,clientId:crypto.randomUUID()}))); touch(); invalidateCommandKey() }
function clear(){ rows.value=Array.from({length:8},()=>newRow()); validation.clear(); lastResult.value=null; resetDirty(); invalidateCommandKey() }
function localErrors():KbxValidationError[] {
const errors:KbxValidationError[]=[]
const active=enteredRows.value
if(!active.length) return [{code:'PRICE_ROWS_REQUIRED',message:'저장할 단가를 한 건 이상 입력하세요.'}]
const duplicate=new Map<string,string>()
for(const row of active){
if(!row.itemId) errors.push({rowKey:row.clientId,field:'itemCode',code:'ITEM_REQUIRED',message:'품목을 선택하세요.'})
if(!/^\d{4}-\d{2}-\d{2}$/.test(row.effectiveDate)) errors.push({rowKey:row.clientId,field:'effectiveDate',code:'EFFECTIVE_DATE_REQUIRED',message:'적용일을 YYYY-MM-DD 형식으로 입력하세요.'})
if(!Number.isFinite(Number(row.unitPrice))||Number(row.unitPrice)<0) errors.push({rowKey:row.clientId,field:'unitPrice',code:'PRICE_NONNEGATIVE',message:'단가는 0 이상이어야 합니다.'})
if(row.itemId){ const key=`${row.itemId}|${row.effectiveDate}`; if(duplicate.has(key)){ errors.push({rowKey:row.clientId,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}); const first=duplicate.get(key)!; if(!errors.some(x=>x.rowKey===first&&x.code==='DUPLICATE_ITEM_DATE')) errors.push({rowKey:first,field:'effectiveDate',code:'DUPLICATE_ITEM_DATE',message:'같은 품목과 적용일이 중복되었습니다.'}) } else duplicate.set(key,row.clientId) }
}
return errors
}
async function onCellChanged(event:{row:ItemPriceRow;field:keyof ItemPriceRow;newValue:unknown}){
if(event.field==='itemCode'){
const code=String(event.newValue??'').trim()
if(!code) Object.assign(event.row,{itemId:null,itemName:''})
else {
const item=await itemPriceApi.resolveItemByCode(code)
if(!item){ Object.assign(event.row,{itemId:null,itemName:''}); validation.setRowFieldError(event.row.clientId,'itemCode',{code:'ITEM_NOT_FOUND',message:'존재하지 않는 품목코드입니다. F2로 품목을 조회하세요.'}) }
else { Object.assign(event.row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(event.row.clientId,'itemCode') }
}
}
lastResult.value=null; touch(); invalidateCommandKey()
}
function applyItemLookup(row:ItemPriceRow,item:KbxLookupItem<string>){ Object.assign(row,{itemId:item.id,itemCode:item.code,itemName:item.displayName}); validation.setRowFieldError(row.clientId,'itemCode'); touch(); invalidateCommandKey() }
async function save(){
validation.clear(); const errors=localErrors(); if(errors.length){validation.setErrors(errors);return false}
const active=enteredRows.value
try{
const result=await mutation.mutateAsync({key:idempotencyKey,request:{rows:active.map(x=>({clientId:x.clientId,itemId:x.itemId!,effectiveDate:x.effectiveDate,unitPrice:Number(x.unitPrice),remark:x.remark||undefined}))}})
lastResult.value=result; markSaved(); idempotencyKey=crypto.randomUUID(); return true
}catch(error){ const problem=toKbxProblem(error); if(problem) validation.applyProblem(problem); throw error }
}
return {rows,errors:validation.errors,dirty,saving:computed(()=>mutation.isPending.value),lastResult,enteredCount:computed(()=>enteredRows.value.length),addRow,duplicateRows,onCellChanged,applyItemLookup,save,clear,touch}
}
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { isKbxProblem, kbxFieldReadonly, resolveKbxRecordStatePolicy, type KbxAuditEntry, type KbxConflictSnapshot, type KbxSearchField } from '@kbx/contracts'
import { KbxBarcodeField, KbxCheckbox, KbxDataGrid, KbxFormGrid, KbxFormSection, KbxLookup, KbxMasterPage, KbxInput, KbxSearchPanel, useKbxDirtyState, useKbxPageShortcuts } from '@kbx/ui'
import { itemApi } from './itemApi'
import { itemMasterColumns, itemMasterScreen, itemStatePolicies, itemWorkflow, type ItemMasterRow } from './item.definition'
const searchModel=reactive({keyword:''});const searchFields:KbxSearchField[]=[{key:'keyword',label:'검색',type:'text',width:'lg',placeholder:'품목코드/품목명'}]
const selected=ref<ItemMasterRow[]>([]);const form=reactive<ItemMasterRow>(emptyItem());const status=computed(()=>!form.id?'신규':form.active?'사용':'사용중지');const policy=computed(()=>resolveKbxRecordStatePolicy(itemStatePolicies,status.value))
const auditEntries=ref<KbxAuditEntry[]>([]);const conflict=ref<KbxConflictSnapshot|null>(null);const errors=ref<any[]>([]);const {dirty,touch,markSaved,reset:resetDirty}=useKbxDirtyState()
const query=useQuery({queryKey:['erp','items'],queryFn:()=>itemApi.search(searchModel.keyword),enabled:false});const rows=computed(()=>query.data.value?.items??[])
function emptyItem():ItemMasterRow{return{id:'',code:'',name:'',categoryName:'',specification:'',unit:'EA',barcode:'',defaultWarehouseId:null,defaultWarehouseName:'',lotManaged:false,expiryManaged:false,active:true,version:0}}
async function search(){selected.value=[];await query.refetch()}
async function choose(rows:ItemMasterRow[]){selected.value=rows;if(!rows[0])return;const latest=await itemApi.get(rows[0].id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(latest.id);errors.value=[];conflict.value=null;resetDirty()}
function state(field:string){return kbxFieldReadonly(policy.value,field)}
function createNew(){Object.assign(form,emptyItem());selected.value=[];auditEntries.value=[];conflict.value=null;errors.value=[];resetDirty()}
function copy(){const source={...form};Object.assign(form,{...source,id:'',code:'',barcode:'',version:0,active:true});auditEntries.value=[];conflict.value=null;resetDirty();touch()}
function payload(){return{id:form.id||undefined,version:form.version||undefined,code:form.code.trim(),name:form.name.trim(),categoryName:form.categoryName,specification:form.specification,unit:form.unit,barcode:form.barcode,defaultWarehouseId:form.defaultWarehouseId,lotManaged:form.lotManaged,expiryManaged:form.expiryManaged}}
async function save(){errors.value=[];if(!form.code.trim()||!form.name.trim()){errors.value=[...(!form.code.trim()?[{field:'code',code:'REQUIRED',message:'품목코드를 입력하세요.'}]:[]),...(!form.name.trim()?[{field:'name',code:'REQUIRED',message:'품목명을 입력하세요.'}]:[])];return}try{const r=form.id?await itemApi.update(form.id,payload()):await itemApi.create(payload());form.id=r.id;form.version=r.version;form.active=true;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='validation'){errors.value=e.errors;return}if(isKbxProblem(e)&&e.type==='conflict'&&form.id){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,detail:'저장하지 않은 입력은 유지됩니다. 최신 값과 비교한 뒤 다시 읽을 수 있습니다.',entityId:form.id,requestedVersion:form.version,currentVersion:e.currentVersion??latest.version,changes:[['code','품목코드'],['name','품목명'],['unit','단위'],['barcode','바코드']].map(([field,label])=>({field,label,mine:(form as any)[field],latest:(latest as any)[field]}))};return}throw e}}
async function deactivate(){if(!form.id)return;try{const r=await itemApi.deactivate(form.id,form.version);form.version=r.version;form.active=false;markSaved();auditEntries.value=await itemApi.audit(form.id);await query.refetch()}catch(e){if(isKbxProblem(e)&&e.type==='conflict'){const latest=await itemApi.get(form.id);conflict.value={code:e.code,title:e.title,currentVersion:e.currentVersion??latest.version,requestedVersion:form.version,entityId:form.id};return}throw e}}
async function reloadConflict(){if(!form.id)return;const latest=await itemApi.get(form.id);Object.assign(form,latest);auditEntries.value=await itemApi.audit(form.id);conflict.value=null;resetDirty()}
async function command(id:string){if(id==='search')return search();if(id==='new')return createNew();if(id==='copy')return copy();if(id==='save')return save();if(id==='deactivate')return deactivate()}
useKbxPageShortcuts([{key:'F3',execute:search},{key:'F8',execute:save}])
</script>
<template>
<KbxMasterPage :screen="itemMasterScreen" :status="status" :dirty="dirty" :version="form.version||undefined" :errors="errors" :workflow="itemWorkflow" :conflict="conflict" :audit-entries="auditEntries" :content-state="query.error.value?'error':query.isFetching.value&&!query.data.value?'loading':!query.data.value?'idle':query.data.value.items.length===0?'empty':'ready'" :refreshing="query.isFetching.value&&Boolean(query.data.value)" breadcrumb="ERP > 기준정보" @command="command" @transition="command" @reload-conflict="reloadConflict" @dismiss-conflict="conflict=null">
<template #list><KbxSearchPanel v-model="searchModel" :fields="searchFields" @search="search"/><KbxDataGrid :rows="rows" :columns="itemMasterColumns" row-key="id" selection="single" :active-row-key="form.id||undefined" :loading="query.isFetching.value" @selection-changed="choose"/></template>
<template #detail>
<KbxFormSection title="기본정보"><KbxFormGrid><KbxInput v-model="form.code" label="품목코드" required :readonly="Boolean(form.id)||state('code')" :error="errors.find(x=>x.field==='code')?.message" @update:model-value="touch"/><KbxInput v-model="form.name" label="품목명" required :readonly="state('name')" :error="errors.find(x=>x.field==='name')?.message" @update:model-value="touch"/><KbxInput v-model="form.categoryName" label="품목그룹" :readonly="state('categoryName')" @update:model-value="touch"/><KbxInput v-model="form.specification" label="규격" :readonly="state('specification')" @update:model-value="touch"/><KbxInput v-model="form.unit" label="단위" :readonly="state('unit')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
<KbxFormSection title="물류정보"><KbxFormGrid><KbxLookup v-model="form.defaultWarehouseId" entity="warehouse" label="기본창고" :readonly="state('defaultWarehouseId')" @selected="touch"/><KbxBarcodeField v-model="form.barcode" label="바코드" :readonly="state('barcode')" @update:model-value="touch"/><KbxCheckbox v-model="form.lotManaged" label="LOT 관리" :disabled="state('lotManaged')" @update:model-value="touch"/><KbxCheckbox v-model="form.expiryManaged" label="유통기한 관리" :disabled="state('expiryManaged')" @update:model-value="touch"/></KbxFormGrid></KbxFormSection>
</template>
</KbxMasterPage>
</template>
@@ -0,0 +1,20 @@
import { defineKbxScreen, type KbxGridColumn, type KbxRecordStatePolicy, type KbxWorkflowDefinition } from '@kbx/ui'
export interface ItemMasterRow { id:string; code:string; name:string; categoryName:string; specification:string; unit:string; barcode:string; defaultWarehouseId:string|null; defaultWarehouseName:string; lotManaged:boolean; expiryManaged:boolean; active:boolean; version:number }
export const itemWorkflow:KbxWorkflowDefinition={id:'erp.item.lifecycle',version:'1.0.0',states:[{value:'신규',label:'신규',semantic:'draft'},{value:'사용',label:'사용',semantic:'completed'},{value:'사용중지',label:'사용중지',semantic:'disabled',terminal:true}],transitions:[{id:'deactivate',from:['사용'],to:'사용중지',label:'사용중지',permission:'erp.item.write',confirm:true}]}
export const itemStatePolicies:KbxRecordStatePolicy[]=[{status:'신규',editability:'editable'},{status:'사용',editability:'editable'},{status:'사용중지',editability:'readonly',message:'사용중지된 품목은 조회·복사·이력확인만 가능합니다.'}]
export const itemMasterScreen = defineKbxScreen({
id: 'ERP-MST-ITEM-001', version: '1.1.0', module: 'ERP', type: 'master', templateCode:'T02', title: '품목관리',
description: '품목 기준정보와 물류 속성을 동일한 문법으로 관리합니다.', helpKey:'ERP-MST-ITEM-001', permissions:['erp.item.read'], telemetry:{enabled:true},
commands:[
{id:'search',label:'조회',group:'query',shortcut:'F3'},
{id:'new',label:'신규',group:'edit',permission:'erp.item.create'},
{id:'save',label:'저장',group:'edit',variant:'primary',shortcut:'F8',permissionByStatus:{'신규':'erp.item.create','사용':'erp.item.write'},allowedStatuses:['신규','사용'],requiresDirty:true},
{id:'copy',label:'복사',group:'edit',permission:'erp.item.create'},
{id:'deactivate',label:'사용중지',group:'workflow',permission:'erp.item.write',allowedStatuses:['사용'],confirm:{title:'품목을 사용중지하시겠습니까?',detail:'사용중지 후 신규 업무에서 이 품목을 선택할 수 없습니다. 기존 이력은 유지됩니다.',level:'high',confirmLabel:'사용중지'}},
{id:'excel',label:'엑셀',group:'output'},
],
})
export const itemMasterColumns:KbxGridColumn<ItemMasterRow>[]=[{field:'code',header:'품목코드',type:'code',width:120,pinned:'left'},{field:'name',header:'품목명',width:200},{field:'specification',header:'규격',width:160},{field:'unit',header:'단위',width:70},{field:'active',header:'사용',type:'boolean',width:70}]
@@ -0,0 +1,14 @@
import { kbxApi } from '../../../http/generated/kbxApiClient'
import type { KbxAuditEntry } from '@kbx/contracts'
import type { ItemMasterRow } from './item.definition'
export interface ItemSearchResponse { items: ItemMasterRow[]; totalCount: number }
export interface ItemSaveRequest { id?:string; version?:number; code:string; name:string; categoryName?:string; specification?:string; unit:string; barcode?:string; defaultWarehouseId?:string|null; lotManaged:boolean; expiryManaged:boolean }
export interface ItemSaveResponse { id:string; version:number; status:string }
export const itemApi = {
search(keyword = '') { return kbxApi.request<ItemSearchResponse>('erp.items.search', { query: { keyword, page: 1, pageSize: 200 } }) },
get(id: string) { return kbxApi.request<ItemMasterRow>('erp.items.get', { path: { id } }) },
create(body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.create', { body }) },
update(id:string,body:ItemSaveRequest) { return kbxApi.request<ItemSaveResponse>('erp.items.update', { path:{id}, body }) },
deactivate(id:string,version:number) { return kbxApi.request<ItemSaveResponse>('erp.items.deactivate', { path:{id}, body:{version} }) },
audit(id:string) { return kbxApi.request<KbxAuditEntry[]>('erp.items.audit', { path:{id} }) },
}
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { KbxDataGrid, KbxDateField, KbxFormGrid, KbxFormSection, KbxLookup, KbxInput, KbxTransactionPage, KbxWorkflowBar } from '@kbx/ui'
import { purchaseColumns, purchaseScreen, purchaseWorkflow, type PurchaseLine } from './purchase.definition'
const status=ref('DRAFT'); const lines=ref<PurchaseLine[]>([{clientId:crypto.randomUUID(),itemId:null,itemCode:'',itemName:'',quantity:1,unitPrice:0,amount:0,dueDate:''}])
const header=reactive({ purchaseDate:new Date().toISOString().slice(0,10), supplierId:null as string|null, warehouseId:null as string|null, buyer:'', remark:'' })
const total=computed(()=>lines.value.reduce((s,x)=>s+x.quantity*x.unitPrice,0))
function command(id:string){ if(id==='new'){ status.value='DRAFT'; lines.value=[] } if(id==='confirm' && status.value==='DRAFT') status.value='CONFIRMED' }
</script>
<template>
<KbxTransactionPage :screen="purchaseScreen" :status="status" :summary-items="[{key:'amount',label:' 구매금액',value:`${total.toLocaleString('ko-KR')}`,emphasis:true}]" @command="command">
<template #header>
<KbxWorkflowBar :workflow="purchaseWorkflow" :current="status" @transition="command" />
<KbxFormSection title="구매정보"><KbxFormGrid><KbxDateField v-model="header.purchaseDate" label="구매일" required/><KbxLookup v-model="header.supplierId" entity="customer" label="거래처" required/><KbxLookup v-model="header.warehouseId" entity="warehouse" label="입고창고" required/><KbxInput v-model="header.buyer" label="담당자"/></KbxFormGrid></KbxFormSection>
</template>
<template #detail><KbxDataGrid :rows="lines" :columns="purchaseColumns" row-key="clientId" editable clipboard /></template>
</KbxTransactionPage>
</template>
@@ -0,0 +1,17 @@
import { defineKbxScreen, type KbxGridColumn, type KbxWorkflowDefinition } from '@kbx/ui'
export interface PurchaseLine { clientId:string; itemId:string|null; itemCode:string; itemName:string; quantity:number; unitPrice:number; amount:number; dueDate:string }
export const purchaseScreen = defineKbxScreen({
id:'ERP-PUR-001', version:'1.0.0', module:'ERP', type:'transaction', templateCode:'T03', title:'구매등록', helpKey:'ERP-PUR-001', permissions:['erp.purchase.read'], telemetry:{enabled:true},
description:'거래처 구매를 Header/Detail 방식으로 입력하고 확정합니다.',
commands:[{id:'new',label:'신규',group:'edit'},{id:'save',label:'저장',group:'edit',shortcut:'F8',permission:'erp.purchase.write'},{id:'confirm',label:'구매확정',group:'workflow',variant:'primary',permission:'erp.purchase.confirm'},{id:'excel',label:'엑셀',group:'output'}]
})
export const purchaseColumns: KbxGridColumn<PurchaseLine>[] = [
{field:'itemCode',header:'품목코드',type:'lookup',lookup:{entity:'item'},width:130,editable:true,pinned:'left'}, {field:'itemName',header:'품목명',width:210},
{field:'quantity',header:'수량',type:'quantity',width:95,editable:true}, {field:'unitPrice',header:'단가',type:'money',width:120,editable:true}, {field:'amount',header:'금액',type:'money',width:130}, {field:'dueDate',header:'납기일',type:'date',width:110,editable:true}
]
export const purchaseWorkflow: KbxWorkflowDefinition = { id:'erp.purchase', version:'1.0.0', states:[
{value:'DRAFT',label:'작성',semantic:'draft'}, {value:'CONFIRMED',label:'확정',semantic:'processing'}, {value:'PARTIALLY_RECEIVED',label:'부분입고',semantic:'processing'}, {value:'RECEIVED',label:'입고완료',semantic:'completed',terminal:true}, {value:'CANCELLED',label:'취소',semantic:'cancelled',terminal:true}
], transitions:[
{id:'confirm',from:['DRAFT'],to:'CONFIRMED',label:'구매확정',permission:'erp.purchase.confirm',confirm:true}, {id:'cancel',from:['DRAFT','CONFIRMED'],to:'CANCELLED',label:'구매취소',permission:'erp.purchase.cancel',reasonRequired:true}
] }
@@ -0,0 +1,4 @@
import type { RouteRecordRaw } from 'vue-router'
export const erpPurchaseRoutes: RouteRecordRaw[] = [
{ path:'/erp/purchases/new', name:'erp-purchase-new', component:()=>import('./PurchasePage.vue'), meta:{ screenId:'ERP-PUR-001' } },
]