V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { KbxBarcodeEvent } from '@kbx/contracts'
import { isKbxBarcodeDuplicate, normalizeKbxBarcode } from './barcodeGuard'
const props = withDefaults(defineProps<{
enabled?: boolean
minLength?: number
maxLength?: number
interKeyTimeoutMs?: number
duplicateDebounceMs?: number
label?: string
}>(), {
enabled: true,
minLength: 3,
maxLength: 256,
interKeyTimeoutMs: 70,
duplicateDebounceMs: 180,
label: 'SCAN READY',
})
const emit = defineEmits<{
scan: [KbxBarcodeEvent]
duplicateIgnored: [KbxBarcodeEvent]
}>()
const buffer = ref('')
const lastKeyAt = ref(0)
const manualValue = ref('')
let lastSubmitted:KbxBarcodeEvent|null=null
function resetBuffer(){buffer.value='';lastKeyAt.value=0}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.dataset.kbxBarcodeCapture === 'true') return false
return target.matches('input, textarea, select, [contenteditable="true"]')
}
function submit(value: string, source: KbxBarcodeEvent['source']) {
if(!props.enabled)return
const scan=normalizeKbxBarcode(value,source,props.minLength,props.maxLength)
if(!scan)return
if(isKbxBarcodeDuplicate(scan,lastSubmitted,props.duplicateDebounceMs)){
emit('duplicateIgnored',scan)
return
}
lastSubmitted=scan
emit('scan', scan)
}
function onKeydown(event: KeyboardEvent) {
if (!props.enabled || event.ctrlKey || event.altKey || event.metaKey || isEditableTarget(event.target)) return
const now = performance.now()
if (lastKeyAt.value && now - lastKeyAt.value > props.interKeyTimeoutMs) resetBuffer()
lastKeyAt.value = now
if (event.key === 'Escape') { resetBuffer(); return }
if (event.key === 'Enter') {
const value = buffer.value
resetBuffer()
if (value.length >= props.minLength) {
event.preventDefault()
submit(value, 'keyboard-wedge')
}
return
}
if (event.key.length === 1) {
if(buffer.value.length>=props.maxLength){resetBuffer();return}
buffer.value += event.key
}
}
function submitManual() {
submit(manualValue.value, 'manual')
manualValue.value = ''
}
function submitCamera(value:string){submit(value,'camera')}
function reset(){resetBuffer();manualValue.value='';lastSubmitted=null}
function onVisibilityChange(){if(document.visibilityState!=='visible')resetBuffer()}
watch(()=>props.enabled,value=>{if(!value)resetBuffer()})
onMounted(() => {document.addEventListener('keydown', onKeydown, true);document.addEventListener('visibilitychange',onVisibilityChange)})
onBeforeUnmount(() => {document.removeEventListener('keydown', onKeydown, true);document.removeEventListener('visibilitychange',onVisibilityChange)})
defineExpose({submitCamera,reset})
</script>
<template>
<section class="capture" :class="{ disabled: !enabled }" aria-label="바코드 입력" :data-enabled="enabled">
<div class="scan-box" aria-live="polite">
<strong>{{ enabled ? label : 'SCAN PAUSED' }}</strong>
<small>스캐너 입력은 Enter 종결자를 기준으로 인식하며 매우 짧은 중복 신호는 번만 처리합니다.</small>
</div>
<details class="manual">
<summary>바코드 직접 입력</summary>
<div class="manual-row">
<input
v-model="manualValue"
data-kbx-barcode-capture="true"
inputmode="text"
autocomplete="off"
:maxlength="maxLength"
:disabled="!enabled"
aria-label="바코드 직접 입력"
@keyup.enter="submitManual"
>
<button type="button" :disabled="!enabled || manualValue.trim().length < minLength" @click="submitManual">입력</button>
</div>
</details>
</section>
</template>
<style scoped>
.capture{margin:var(--kbx-space-4) 0}.scan-box{min-height:var(--kbx-wms-scan-box-min-height);border:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-lg);display:flex;flex-direction:column;justify-content:center;align-items:center;gap:var(--kbx-space-1);background:var(--kbx-color-surface-muted)}.scan-box strong{font-size:var(--kbx-font-xl);letter-spacing:.06em}.scan-box small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);text-align:center;padding:0 var(--kbx-space-2)}.disabled{opacity:.6}.manual{margin-top:var(--kbx-space-2);font-size:var(--kbx-font-sm)}.manual-row{display:grid;grid-template-columns:1fr var(--kbx-wms-manual-button-width);gap:var(--kbx-space-2);margin-top:var(--kbx-space-2)}.manual-row input,.manual-row button{min-height:var(--kbx-control-touch);font:inherit}.manual-row input{border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);padding:0 var(--kbx-space-3)}.manual-row button{border:var(--kbx-border-width) solid var(--kbx-color-border-strong);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-md);font-weight:600}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
online: boolean
pendingCommands?: number
syncing?: boolean
}>()
const label = computed(() => {
if (!props.online) return props.pendingCommands ? `오프라인 · ${props.pendingCommands}건 대기` : '오프라인'
if (props.syncing) return `동기화 중${props.pendingCommands ? ` · ${props.pendingCommands}` : ''}`
if (props.pendingCommands) return `온라인 · ${props.pendingCommands}건 전송 대기`
return '온라인'
})
</script>
<template>
<span class="network" :class="{ offline: !online, syncing }" role="status" aria-live="polite">
<span class="dot" aria-hidden="true" />
{{ label }}
</span>
</template>
<style scoped>
.network { display:inline-flex; gap:6px; align-items:center; font-size:12px; white-space:nowrap; color:var(--kbx-color-text-muted); }
.dot { width:8px; height:8px; border-radius:50%; background:var(--kbx-color-success); }
.offline .dot { background:var(--kbx-color-danger); }
.syncing .dot { background:var(--kbx-color-warning); }
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(defineProps<{
label: string
variant?: 'primary' | 'secondary' | 'danger'
disabled?: boolean
busy?: boolean
}>(), { variant: 'primary' })
defineEmits<{ click: [] }>()
</script>
<template>
<button
class="action"
:class="variant"
type="button"
:disabled="disabled || busy"
@click="$emit('click')"
>
{{ busy ? '처리 중...' : label }}
</button>
</template>
<style scoped>
.action { width:100%; min-height:var(--kbx-control-touch); border-radius:var(--kbx-radius-md); border:var(--kbx-border-width) solid transparent; font-size:var(--kbx-font-lg); font-weight:600; padding:var(--kbx-space-2) var(--kbx-space-4); }
.primary { color:var(--kbx-color-surface); background:var(--kbx-color-primary); }
.secondary { color:var(--kbx-color-text); background:var(--kbx-color-surface); border-color:var(--kbx-color-border); }
.danger { color:var(--kbx-color-surface); background:var(--kbx-color-danger); }
.action:disabled { opacity:.55; cursor:not-allowed; }
</style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { KbxAsyncState, KbxScreenDefinition } from '@kbx/contracts'
import KbxNetworkIndicator from './KbxNetworkIndicator.vue'
import KbxDataState from '../components/KbxDataState.vue'
import KbxTemplateStateBoundary from '../components/KbxTemplateStateBoundary.vue'
import { KbxScreenUtilityHostKey } from '../utility/host'
import { KbxPermissionHostKey } from '../permission/host'
const props=defineProps<{
screen: KbxScreenDefinition
progress?: string
taskContext?: string
instruction?: string
online: boolean
pendingCommands?: number
syncing?: boolean
contentState?: KbxAsyncState
refreshing?: boolean
retryLabel?: string
errorRetryable?: boolean
emptyActionLabel?: string
can?: (permission:string)=>boolean
actionsVisible?: boolean
}>()
const actionsVisible=computed(()=>props.actionsVisible!==false)
const emit=defineEmits<{ retry:[]; emptyAction:[] }>()
const utilityHost=inject(KbxScreenUtilityHostKey,null)
const permissionHost=inject(KbxPermissionHostKey,null)
const helpAvailable=computed(()=>utilityHost?.available(props.screen).includes('help')??false)
const templateMismatch=computed(()=>props.screen.type!=='wms-mobile'||props.screen.templateCode!=='T09')
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
const permissionDenied=computed(()=>Boolean(props.screen.permissions?.some(permission=>!canPermission(permission))))
</script>
<template>
<main class="kbx-wms-page" :data-screen-id="screen.id" data-screen-type="wms-mobile" :data-access="permissionDenied?'denied':'allowed'">
<header class="kbx-wms-header" data-kbx-surface="mobile-header">
<div><h1>{{screen.title}}</h1><strong v-if="progress" class="kbx-wms-progress">{{progress}}</strong></div>
<div class="kbx-wms-header__tools" data-kbx-surface="network-state"><button v-if="helpAvailable" type="button" class="kbx-wms-help" @click="utilityHost?.open(screen,'help')">도움말</button><KbxNetworkIndicator :online="online" :pending-commands="pendingCommands ?? 0" :syncing="syncing ?? false" /></div>
</header>
<KbxDataState v-if="templateMismatch" class="kbx-wms-contract-error" state="error" title="현장 화면 구성이 올바르지 않습니다." detail="WMS Mobile 전용 화면 정의가 아니므로 작업 입력을 차단했습니다." />
<KbxDataState v-else-if="permissionDenied" class="kbx-wms-contract-error" state="error" title="이 현장 업무를 사용할 권한이 없습니다." detail="현재 권한으로 작업 입력을 할 수 없습니다. 관리자에게 권한을 확인하세요." />
<template v-else>
<section v-if="taskContext || $slots.context" class="kbx-wms-context" aria-label="현재 작업" data-kbx-surface="task-context"><slot name="context">{{taskContext}}</slot></section>
<section v-if="$slots.notice" class="kbx-wms-notice" aria-live="polite" data-kbx-surface="notice"><slot name="notice" /></section>
<section class="kbx-wms-content" data-kbx-surface="instruction/content">
<h2 v-if="instruction" class="kbx-wms-instruction">{{instruction}}</h2>
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" :retry-label="retryLabel || '다시 시도'" :error-retryable="errorRetryable !== false" :empty-action-label="emptyActionLabel" @retry="emit('retry')" @empty-action="emit('emptyAction')"><slot /></KbxTemplateStateBoundary>
</section>
<footer v-if="$slots.actions && actionsVisible" class="kbx-wms-actions" aria-label="현재 작업 행동" data-kbx-surface="sticky-actions"><slot name="actions" /></footer>
</template>
</main>
</template>
<style scoped>
.kbx-wms-page{min-height:100dvh;max-width:var(--kbx-wms-mobile-max-width);margin:0 auto;background:var(--kbx-color-surface);display:flex;flex-direction:column;color:var(--kbx-color-text)}.kbx-wms-header{min-height:var(--kbx-wms-header-min-height);padding:var(--kbx-space-3) var(--kbx-space-4);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);position:sticky;top:0;background:var(--kbx-color-surface);z-index:5}.kbx-wms-header h1{margin:0;font-size:var(--kbx-font-2xl);line-height:1.3}.kbx-wms-progress{display:block;margin-top:var(--kbx-space-1);font-size:var(--kbx-font-md);font-weight:600}.kbx-wms-context{min-height:var(--kbx-control-height);display:flex;align-items:center;padding:0 var(--kbx-space-4);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-sm);font-weight:500}.kbx-wms-notice{padding:var(--kbx-space-2) var(--kbx-space-4) 0}.kbx-wms-content{flex:1;padding:var(--kbx-space-4)}.kbx-wms-instruction{margin:0 0 var(--kbx-space-3);font-size:var(--kbx-font-lg);font-weight:600}.kbx-wms-actions{position:sticky;bottom:0;padding:var(--kbx-space-3) var(--kbx-space-4) max(var(--kbx-space-3),env(safe-area-inset-bottom));border-top:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}
.kbx-wms-contract-error{margin:var(--kbx-space-4)}.kbx-wms-header__tools{display:flex;align-items:center;gap:var(--kbx-space-2)}.kbx-wms-help{min-height:var(--kbx-control-sm);padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);font-size:var(--kbx-font-sm)}
</style>
@@ -0,0 +1,11 @@
import type { KbxBarcodeEvent, KbxBarcodeSource } from '@kbx/contracts'
export function normalizeKbxBarcode(rawValue:string,source:KbxBarcodeSource,minLength=3,maxLength=256,occurredAt=Date.now()):KbxBarcodeEvent|null{
const normalizedValue=rawValue.trim()
if(normalizedValue.length<minLength||normalizedValue.length>maxLength)return null
return {rawValue,normalizedValue,source,occurredAt}
}
export function isKbxBarcodeDuplicate(current:KbxBarcodeEvent,last:Pick<KbxBarcodeEvent,'normalizedValue'|'occurredAt'>|null,debounceMs:number){
return Boolean(last&&current.normalizedValue===last.normalizedValue&&current.occurredAt-last.occurredAt<debounceMs)
}
@@ -0,0 +1,48 @@
export type KbxWmsFeedbackKind = 'success' | 'warning' | 'error' | 'neutral'
function vibrate(pattern: number | number[]) {
// Vibration is progressive enhancement: unsupported browsers simply skip it.
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(pattern)
}
}
function beep(frequency: number, durationMs: number) {
if (typeof window === 'undefined') return
const AudioContextCtor = window.AudioContext ?? (window as any).webkitAudioContext
if (!AudioContextCtor) return
try {
const context = new AudioContextCtor()
const oscillator = context.createOscillator()
const gain = context.createGain()
oscillator.frequency.value = frequency
gain.gain.value = 0.035
oscillator.connect(gain)
gain.connect(context.destination)
oscillator.start()
oscillator.stop(context.currentTime + durationMs / 1000)
oscillator.addEventListener('ended', () => void context.close())
} catch {
// Sound is non-authoritative feedback. Never block WMS work because audio failed.
}
}
export function playKbxWmsFeedback(kind: KbxWmsFeedbackKind) {
switch (kind) {
case 'success':
beep(880, 80)
vibrate(50)
break
case 'warning':
beep(520, 120)
vibrate([70, 40, 70])
break
case 'error':
beep(220, 180)
vibrate([120, 60, 120])
break
default:
beep(660, 60)
}
}
@@ -0,0 +1,23 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
export function useKbxNetworkState() {
const online = ref(typeof navigator === 'undefined' ? true : navigator.onLine)
const refresh = () => {
online.value = navigator.onLine
}
onMounted(() => {
window.addEventListener('online', refresh)
window.addEventListener('offline', refresh)
})
onBeforeUnmount(() => {
window.removeEventListener('online', refresh)
window.removeEventListener('offline', refresh)
})
return {
online: computed(() => online.value),
}
}