feat: KBX v60 Phase 4 complete — KbxQuantityField + index exports

Add KbxQuantityField (increment/decrement spinner) + update index exports
for all Phase 3.5–4 components (wrapper, form, specialized fields).

Components shipped:
- KbxScreenFrame, KbxTemplateStateBoundary, KbxSummaryBar (wrapper)
- KbxFormGrid, KbxFormSection (layout)
- KbxInput, KbxSelect, KbxDateField, KbxNumberField, KbxTextarea, KbxCheckbox (basic fields)
- KbxMoneyField, KbxQuantityField, KbxRadio (specialized fields)
- 9 template/composite/advanced (T02, T03, T06, T07, DataGrid, Dialog, Drawer, Tabs, Lookup)

Total Phase 1–4: 30 components, ~3500 LOC, contracts, registries, composables, tokens, app init complete.
Ready for page implementation using KbxScreenFrame wrapper pattern.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 10:43:10 +09:00
parent 60daf2c9c7
commit 889212d643
59 changed files with 6610 additions and 5 deletions
+163
View File
@@ -0,0 +1,163 @@
# @kbx — KBX Foundation v60
## Overview
**Phase 1: Core Contracts + Template Components**
이것은 KBX Foundation v60의 **실용적 구현**입니다.
- v52 (FE Operational Navigation & Screen Anatomy Hardening) 원칙 준수
- 자체 포함된 컴포넌트 (의존성 최소화)
- 점진적 확대 가능
## 구조
```
@kbx/
├── contracts/ # 11개 핵심 contract 파일
│ ├── screen.ts # Screen definitions (T01-T09)
│ ├── ui.ts # UI state & presentation
│ ├── problem.ts # Error handling
│ ├── field.ts # Form field metadata
│ ├── workflow.ts # Record lifecycle
│ ├── command.ts # Command definitions
│ ├── permission.ts # Authorization
│ ├── help.ts # Help system
│ ├── status.ts # Status representation
│ ├── grid.ts # Data grid config
│ └── index.ts # Export barrel
├── ui/ # UI Components
│ ├── components/ # 6개 컴포넌트
│ │ ├── KbxSectionHeader.vue # Section header (표준)
│ │ ├── KbxValidationSummary.vue # Error display
│ │ ├── KbxTransactionTemplate.vue # T03 Header+Detail
│ │ ├── KbxMasterTemplate.vue # T02 List+Detail
│ │ ├── KbxQueueTemplate.vue # T06 Task Queue
│ │ └── KbxReconcileTemplate.vue # T07 Comparison
│ ├── contracts.ts # Re-export contracts
│ └── index.ts # Export barrel
└── index.ts # Main export
```
## v52 Screen Anatomy
### T02 Master (KbxMasterTemplate)
**용도**: CRUD 목록 + 상세
**예시**: 품목 관리, 고객 관리
**구성**:
- List pane: 목록 + 건수 표시
- Detail pane: 상세 정보 + 탭
### T03 Transaction (KbxTransactionTemplate)
**용도**: Header + Detail 트랜잭션
**예시**: 주문 등록, 구매 등록
**구성**:
- Header section: 거래처, 배송지 등
- Detail section: 상품 목록 (Grid)
### T06 Queue (KbxQueueTemplate)
**용도**: 작업 대기열
**예시**: WMS 작업, 승인 대기, 예외 처리
**구성**:
- Queue title + count
- Queue body (actionable items)
### T07 Reconcile (KbxReconcileTemplate)
**용도**: 데이터 대사
**예시**: OMS ↔ WMS 대사, Expected ↔ Actual
**구성**:
- Column labels: Expected / Difference / Actual
- Comparison body + aligned state
## 사용 예제
### Transaction 화면
```vue
<script setup lang="ts">
import { KbxTransactionTemplate } from '@/shared/@kbx'
const headerTitle = '주문 정보'
const detailTitle = '주문 상품'
const items = ref([])
</script>
<template>
<KbxTransactionTemplate
:header-title="headerTitle"
:detail-title="detailTitle"
:detail-count="items.length"
>
<template #header>
<!-- Header form -->
</template>
<template #detail>
<!-- Detail grid -->
</template>
</KbxTransactionTemplate>
</template>
```
### Master 화면
```vue
<script setup lang="ts">
import { KbxMasterTemplate } from '@/shared/@kbx'
const items = ref([])
const selectedItem = ref(null)
</script>
<template>
<KbxMasterTemplate
list-title="품목 목록"
:list-count="items.length"
detail-title="품목 상세"
>
<template #list>
<!-- List grid -->
</template>
<template #detail>
<!-- Detail form -->
</template>
</KbxMasterTemplate>
</template>
```
## Next Steps
### Phase 2: Support Components
- Form components (Input, Select, DateField, etc.)
- Data grid component (AG Grid wrapper)
- Dialog, Drawer, Tabs
- Lookup, Status tag
### Phase 3: Integration
- Registry system (screen definitions)
- Router integration
- Global composables (validation, dirty state)
- App initialization
## Design Tokens
Template들은 다음 CSS variables를 사용합니다:
```css
--kbx-color-surface /* Background */
--kbx-color-border /* Border color */
--kbx-color-text /* Text */
--kbx-color-text-muted /* Muted text */
--kbx-color-section-heading /* Section background */
--kbx-color-module-accent /* Module brand color */
--kbx-color-success /* Success tone */
--kbx-color-danger /* Error tone */
--kbx-color-danger-light /* Error background */
```
## 참고
- **v60 Reference**: `docs/Design/kbx-foundation-v60-status-canonical-contract-hardening/`
- **v52 Design Doc**: `KBX-FE-Operational-Navigation-Screen-Anatomy-v52.md`
- **CLAUDE.md**: 프로젝트 아키텍처 가이드
@@ -0,0 +1,8 @@
/**
* @kbx Composables — v60
* Global state management and utilities
*/
export * from './useKbxValidation'
export * from './useKbxDirtyState'
export * from './useKbxPermission'
@@ -0,0 +1,104 @@
/**
* useKbxDirtyState — v60
* Track unsaved changes in forms
*/
import { ref, computed } from 'vue'
export interface DirtyState {
[key: string]: boolean
}
export function useKbxDirtyState(initialState: DirtyState = {}) {
const dirtyFields = ref<DirtyState>(initialState)
/**
* Check if form is dirty (has unsaved changes)
*/
const dirty = computed(() => Object.values(dirtyFields.value).some(v => v))
/**
* Check if specific field is dirty
*/
const isFieldDirty = (field: string): boolean => {
return dirtyFields.value[field] ?? false
}
/**
* Mark field as dirty
*/
const markFieldDirty = (field: string): void => {
dirtyFields.value[field] = true
}
/**
* Mark field as clean
*/
const markFieldClean = (field: string): void => {
dirtyFields.value[field] = false
}
/**
* Mark all fields as clean
*/
const markAllClean = (): void => {
Object.keys(dirtyFields.value).forEach(key => {
dirtyFields.value[key] = false
})
}
/**
* Mark all fields as dirty
*/
const markAllDirty = (): void => {
Object.keys(dirtyFields.value).forEach(key => {
dirtyFields.value[key] = true
})
}
/**
* Reset to initial state
*/
const reset = (newInitialState: DirtyState = {}): void => {
dirtyFields.value = newInitialState
}
/**
* Touch field (mark as visited)
*/
const touchField = (field: string): void => {
if (!(field in dirtyFields.value)) {
dirtyFields.value[field] = false
}
}
/**
* Get dirty field names
*/
const getDirtyFields = (): string[] => {
return Object.entries(dirtyFields.value)
.filter(([, isDirty]) => isDirty)
.map(([field]) => field)
}
/**
* Set dirty state of multiple fields
*/
const setDirtyFields = (fields: DirtyState): void => {
dirtyFields.value = { ...dirtyFields.value, ...fields }
}
return {
dirty,
dirtyFields,
isFieldDirty,
markFieldDirty,
markFieldClean,
markAllClean,
markAllDirty,
touchField,
getDirtyFields,
setDirtyFields,
reset,
}
}
@@ -0,0 +1,135 @@
/**
* useKbxPermission — v60
* Permission checking and RBAC utilities
*/
import { ref, readonly } from 'vue'
export function useKbxPermission() {
const userPermissions = ref<Set<string>>(new Set())
/**
* Set user permissions (call after login)
*/
const setPermissions = (permissions: string[]): void => {
userPermissions.value = new Set(permissions)
}
/**
* Add permission to user
*/
const addPermission = (permission: string): void => {
userPermissions.value.add(permission)
}
/**
* Remove permission from user
*/
const removePermission = (permission: string): void => {
userPermissions.value.delete(permission)
}
/**
* Check if user has permission
*/
const has = (permission: string): boolean => {
return userPermissions.value.has(permission)
}
/**
* Check if user has any of the permissions
*/
const hasAny = (permissions: string[]): boolean => {
return permissions.some(p => userPermissions.value.has(p))
}
/**
* Check if user has all permissions
*/
const hasAll = (permissions: string[]): boolean => {
return permissions.every(p => userPermissions.value.has(p))
}
/**
* Get all user permissions
*/
const getPermissions = (): string[] => {
return Array.from(userPermissions.value)
}
/**
* Check if user has at least one permission (for showing UI)
*/
const canView = (requiredPermissions?: string[]): boolean => {
if (!requiredPermissions || requiredPermissions.length === 0) {
return true
}
return hasAny(requiredPermissions)
}
/**
* Check if user can edit (requires specific permission)
*/
const canEdit = (permission: string): boolean => {
return has(permission)
}
/**
* Check if user can delete (requires specific permission)
*/
const canDelete = (permission: string): boolean => {
return has(permission)
}
/**
* Guard function for route navigation
*/
const guard = (requiredPermissions?: string[]): boolean => {
return canView(requiredPermissions)
}
/**
* Clear all permissions (call on logout)
*/
const clear = (): void => {
userPermissions.value.clear()
}
return {
permissions: readonly(userPermissions),
setPermissions,
addPermission,
removePermission,
has,
hasAny,
hasAll,
getPermissions,
canView,
canEdit,
canDelete,
guard,
clear,
}
}
/**
* Global permission instance (singleton)
*/
let globalPermissions: ReturnType<typeof useKbxPermission> | null = null
/**
* Get or create global permission instance
*/
export function getGlobalPermissions(): ReturnType<typeof useKbxPermission> {
if (!globalPermissions) {
globalPermissions = useKbxPermission()
}
return globalPermissions
}
/**
* Helper for components to use global permissions
*/
export function useGlobalPermission() {
return getGlobalPermissions()
}
@@ -0,0 +1,124 @@
/**
* useKbxValidation — v60
* Form validation state management
*/
import { ref, computed } from 'vue'
import type { KbxValidationError, KbxProblem } from '../contracts'
export function useKbxValidation() {
const errors = ref<KbxValidationError[]>([])
/**
* Get errors for a specific field
*/
const getFieldError = (field: string): string | undefined => {
const error = errors.value.find(e => e.field === field && !e.rowKey)
return error?.message
}
/**
* Get errors for a specific row field (in grid)
*/
const getRowFieldError = (rowKey: string, field: string): string | undefined => {
const error = errors.value.find(e => e.rowKey === rowKey && e.field === field)
return error?.message
}
/**
* Check if field has error
*/
const hasFieldError = (field: string): boolean => {
return errors.value.some(e => e.field === field && !e.rowKey)
}
/**
* Check if row field has error
*/
const hasRowFieldError = (rowKey: string, field: string): boolean => {
return errors.value.some(e => e.rowKey === rowKey && e.field === field)
}
/**
* Get all errors
*/
const hasErrors = computed(() => errors.value.length > 0)
/**
* Set errors (typically from API response)
*/
const setErrors = (newErrors: KbxValidationError[]): void => {
errors.value = newErrors
}
/**
* Add error for field
*/
const addError = (field: string, message: string, code: string = 'validation.error'): void => {
errors.value.push({ field, code, message })
}
/**
* Add error for row field (in grid)
*/
const addRowError = (
rowKey: string,
field: string,
message: string,
code: string = 'validation.error'
): void => {
errors.value.push({ rowKey, field, code, message })
}
/**
* Clear errors for a field
*/
const clearFieldErrors = (field: string): void => {
errors.value = errors.value.filter(e => e.field !== field)
}
/**
* Clear errors for a row field
*/
const clearRowFieldErrors = (rowKey: string, field?: string): void => {
if (field) {
errors.value = errors.value.filter(e => !(e.rowKey === rowKey && e.field === field))
} else {
errors.value = errors.value.filter(e => e.rowKey !== rowKey)
}
}
/**
* Clear all errors
*/
const clear = (): void => {
errors.value = []
}
/**
* Apply errors from KbxProblem (API response)
*/
const applyProblem = (problem: KbxProblem): void => {
if (problem.type === 'validation') {
errors.value = problem.errors
} else {
errors.value = []
}
}
return {
errors,
hasErrors,
getFieldError,
getRowFieldError,
hasFieldError,
hasRowFieldError,
setErrors,
addError,
addRowError,
clearFieldErrors,
clearRowFieldErrors,
clear,
applyProblem,
}
}
@@ -0,0 +1,21 @@
/**
* Command Definition Contracts — v60
* Screen commands (Save, Delete, Approve, etc.)
*/
export type KbxCommandGroup = 'query' | 'edit' | 'workflow' | 'output' | 'more'
export type KbxCommandVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
export type KbxRecipePermissionKind = 'none' | 'write' | 'execute'
export interface KbxCommandDefinition {
id: string
label: string
group: KbxCommandGroup
shortcut?: string
variant?: KbxCommandVariant
permissionKind: KbxRecipePermissionKind
icon?: string
disabled?: boolean
}
@@ -0,0 +1,45 @@
/**
* Field Definition Contracts — v60
* Form field metadata, validation, and state
*/
export type KbxFieldType =
| 'text'
| 'number'
| 'currency'
| 'percentage'
| 'date'
| 'datetime'
| 'time'
| 'select'
| 'multi-select'
| 'checkbox'
| 'radio'
| 'textarea'
| 'lookup'
| 'barcode'
export interface KbxFieldDefinition {
name: string
type: KbxFieldType
label: string
required?: boolean
readonly?: boolean
hidden?: boolean
placeholder?: string
helpText?: string
pattern?: string
minLength?: number
maxLength?: number
min?: number
max?: number
format?: string
}
export interface KbxFieldReadonlyPolicy {
[fieldName: string]: boolean
}
export function kbxFieldReadonly(policy: KbxFieldReadonlyPolicy, fieldName: string): boolean {
return policy[fieldName] ?? false
}
@@ -0,0 +1,32 @@
/**
* Data Grid Definition Contracts — v60
*/
export type KbxGridColumnType =
| 'text'
| 'number'
| 'currency'
| 'percentage'
| 'date'
| 'datetime'
| 'status'
| 'link'
| 'action'
export interface KbxGridColumnDefinition {
field: string
header: string
type: KbxGridColumnType
width?: number | string
pinned?: 'left' | 'right'
sortable?: boolean
filterable?: boolean
editable?: boolean
}
export interface KbxGridDefinition {
columnDefs: KbxGridColumnDefinition[]
pageSize?: number
serverSideDatasource?: boolean
rowHeight?: number | string
}
@@ -0,0 +1,18 @@
/**
* Help & Documentation Contracts — v60
*/
export interface KbxHelpContent {
key: string
title: string
purpose: string
steps?: string[]
shortcuts?: { key: string; description: string }[]
cautions?: string[]
relatedScreens?: { id: string; title: string }[]
}
export interface KbxHelpDefinition {
screenId: string
content: KbxHelpContent
}
@@ -0,0 +1,15 @@
/**
* @kbx/contracts — KBX Foundation v60
* Core contract definitions for screens, UI, validation, and workflow
*/
export * from './screen'
export * from './ui'
export * from './command'
export * from './field'
export * from './workflow'
export * from './permission'
export * from './help'
export * from './problem'
export * from './status'
export * from './grid'
@@ -0,0 +1,16 @@
/**
* Permission & Authorization Contracts — v60
*/
export interface KbxPermissionContext {
has(permission: string): boolean
hasAny(permissions: readonly string[]): boolean
hasAll(permissions: readonly string[]): boolean
}
export interface KbxPermissionDefinition {
id: string
label: string
description?: string
category: string
}
@@ -0,0 +1,81 @@
/**
* Problem/Error Handling Contracts — v60
* Hierarchical error representation for UI and business logic
*/
export interface KbxProblemBase {
type: string
title: string
detail?: string | null
correlationId?: string | null
}
export interface KbxValidationError {
field?: string | null
rowKey?: string | null
code: string
message: string
}
export interface KbxValidationProblem extends KbxProblemBase {
type: 'validation'
errors: KbxValidationError[]
}
export interface KbxProblemAction {
id: string
label: string
}
export interface KbxBusinessProblem extends KbxProblemBase {
type: 'business-rule'
code: string
actions?: KbxProblemAction[]
}
export interface KbxConflictProblem extends KbxProblemBase {
type: 'conflict'
code: string
currentVersion?: number | null
}
export interface KbxPermissionProblem extends KbxProblemBase {
type: 'permission'
code: string
}
export interface KbxNotFoundProblem extends KbxProblemBase {
type: 'not-found'
code: string
}
export interface KbxIntegrationProblem extends KbxProblemBase {
type: 'integration'
code: string
retryable: boolean
}
export interface KbxSystemProblem extends KbxProblemBase {
type: 'system'
code: string
correlationId: string
retryable?: boolean
}
export type KbxProblem =
| KbxValidationProblem
| KbxBusinessProblem
| KbxConflictProblem
| KbxPermissionProblem
| KbxNotFoundProblem
| KbxIntegrationProblem
| KbxSystemProblem
export function isKbxProblem(value: unknown): value is KbxProblem {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
'title' in value
)
}
@@ -0,0 +1,38 @@
/**
* Screen Definition Contracts — v60
* Fundamental screen identity and template binding
*/
import type { KbxCommandDefinition } from './command'
export type KbxScreenType =
| 'list'
| 'master'
| 'transaction'
| 'fast-entry'
| 'master-detail'
| 'queue'
| 'reconcile'
| 'import'
| 'wms-mobile'
export type KbxScreenTemplateCode = 'T01' | 'T02' | 'T03' | 'T04' | 'T05' | 'T06' | 'T07' | 'T08' | 'T09'
export interface KbxScreenDefinition {
id: string
version: string
module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
type: KbxScreenType
/** Explicit template/recipe identity. Type and templateCode must agree. */
templateCode: KbxScreenTemplateCode
title: string
description?: string
permissions?: string[]
commands?: KbxCommandDefinition[]
helpKey?: string
telemetry?: { enabled: boolean }
}
export function defineKbxScreen<T extends KbxScreenDefinition>(definition: T): T {
return definition
}
@@ -0,0 +1,16 @@
/**
* Status & State Representation Contracts — v60
*/
export type KbxStatusTone = 'default' | 'info' | 'success' | 'warning' | 'danger' | 'muted'
export interface KbxStatusDefinition {
id: string
label: string
tone: KbxStatusTone
icon?: string
}
export interface KbxStatusCatalog {
[categoryKey: string]: KbxStatusDefinition[]
}
+56
View File
@@ -0,0 +1,56 @@
/**
* UI State & Presentation Contracts — v60
*/
export type KbxFieldState = 'default' | 'changed' | 'warning' | 'ai-suggested'
export type KbxAsyncState = 'idle' | 'ready' | 'loading' | 'empty' | 'error'
export interface KbxAsyncStateDefinition {
state: KbxAsyncState
title?: string
detail?: string
actionLabel?: string
}
export interface KbxSummaryItem {
key: string
label: string
value: string | number
emphasis?: boolean
}
export interface KbxQuickFilterItem {
key: string
label: string
count: number
active?: boolean
tone?: 'default' | 'warning' | 'danger'
}
export type KbxShortcutScope = 'application' | 'page' | 'grid' | 'dialog' | 'editor'
export interface KbxShortcutDefinition {
key: string
scope: KbxShortcutScope
priority?: number
enabled?: () => boolean
execute(): void | Promise<void>
}
export type KbxTemplateMetricTone = 'default' | 'info' | 'success' | 'warning' | 'danger'
export interface KbxTemplateMetric {
key: string
label: string
value: string | number
tone?: KbxTemplateMetricTone
emphasis?: boolean
}
export interface KbxTemplateContext {
label?: string
hint?: string
updatedAt?: string
metrics?: KbxTemplateMetric[]
}
@@ -0,0 +1,38 @@
/**
* Workflow Definition Contracts — v60
* Record lifecycle, state transitions, approvals
*/
export type KbxRecordState = 'draft' | 'submitted' | 'approved' | 'rejected' | 'completed' | 'cancelled'
export interface KbxWorkflowTransition {
from: KbxRecordState
to: KbxRecordState
label: string
requiredPermission?: string
requiresReason?: boolean
}
export interface KbxWorkflowDefinition {
states: KbxRecordState[]
transitions: KbxWorkflowTransition[]
initialState: KbxRecordState
terminalStates: KbxRecordState[]
}
export interface KbxAuditEntry {
timestamp: string
actor: string
action: string
changes?: Record<string, [unknown, unknown]>
reason?: string
correlationId?: string
}
export interface KbxConflictSnapshot {
version: number
currentVersion: number
reason: 'concurrent-edit' | 'external-change'
lastModifiedAt: string
lastModifiedBy: string
}
+27
View File
@@ -0,0 +1,27 @@
/**
* @kbx — KBX Foundation v60
* Complete integration of contracts, components, registries, and composables
*
* v52 Screen Anatomy: Operational Navigation & Screen Hardening
* - Standardized screen templates (T01-T09)
* - Contract-driven design
* - Module identity + visual clarity
*/
// Core Contracts
export * from './contracts'
// UI Components & Templates (Phase 1 + 2)
export * from './ui'
// Registry System (Screen, Permission, Help)
export * from './registry'
// Composables (Validation, Dirty State, Permission)
export * from './composables'
// Installation
export * from './installKbx'
// Design Tokens
import './tokens.css'
+154
View File
@@ -0,0 +1,154 @@
/**
* @kbx Installation — v60
* Initialize KBX system in Vue app
*/
import type { App } from 'vue'
import type { KbxScreenDefinition, KbxPermissionDefinition, KbxHelpDefinition } from './contracts'
import { screenRegistry } from './registry/screenRegistry'
import { permissionRegistry } from './registry/permissionRegistry'
import { helpRegistry } from './registry/helpRegistry'
import { getGlobalPermissions } from './composables/useKbxPermission'
export interface KbxInstallOptions {
/**
* Initial screen definitions to register
*/
screens?: KbxScreenDefinition[]
/**
* Initial permission definitions to register
*/
permissions?: KbxPermissionDefinition[]
/**
* Initial help definitions to register
*/
help?: KbxHelpDefinition[]
/**
* User's initial permissions
*/
userPermissions?: string[]
/**
* Default density (compact, comfortable, touch)
*/
density?: 'compact' | 'comfortable' | 'touch'
/**
* Theme (light, dark, auto)
*/
theme?: 'light' | 'dark' | 'auto'
}
/**
* Install KBX system into Vue app
*/
export function installKbx(app: App, options: KbxInstallOptions = {}) {
// Register screens
if (options.screens) {
screenRegistry.registerMany(options.screens)
}
// Register permissions
if (options.permissions) {
permissionRegistry.registerMany(options.permissions)
}
// Register help
if (options.help) {
helpRegistry.registerMany(options.help)
}
// Set user permissions
if (options.userPermissions) {
getGlobalPermissions().setPermissions(options.userPermissions)
}
// Set density
if (options.density) {
setDensity(options.density)
}
// Set theme
if (options.theme && options.theme !== 'auto') {
setTheme(options.theme)
}
// Import design tokens
import('./tokens.css')
// Provide registries to components
app.provide('kbx-screens', screenRegistry)
app.provide('kbx-permissions', permissionRegistry)
app.provide('kbx-help', helpRegistry)
// Global properties
app.config.globalProperties.$kbx = {
screenRegistry,
permissionRegistry,
helpRegistry,
permissions: getGlobalPermissions(),
}
}
/**
* Set density (compact, comfortable, touch)
*/
export function setDensity(density: 'compact' | 'comfortable' | 'touch'): void {
document.documentElement.setAttribute('data-density', density)
}
/**
* Get current density
*/
export function getDensity(): 'compact' | 'comfortable' | 'touch' {
const density = document.documentElement.getAttribute('data-density')
return (density as any) || 'compact'
}
/**
* Set theme (light, dark)
*/
export function setTheme(theme: 'light' | 'dark'): void {
document.documentElement.setAttribute('data-theme', theme)
}
/**
* Get current theme
*/
export function getTheme(): 'light' | 'dark' | 'auto' {
const theme = document.documentElement.getAttribute('data-theme')
if (theme === 'light' || theme === 'dark') {
return theme
}
return 'auto'
}
/**
* Toggle theme
*/
export function toggleTheme(): void {
const current = getTheme()
if (current === 'light') {
setTheme('dark')
} else if (current === 'dark') {
document.documentElement.removeAttribute('data-theme')
} else {
// Auto -> light
setTheme('light')
}
}
/**
* Check if dark mode is active
*/
export function isDarkMode(): boolean {
const theme = getTheme()
if (theme !== 'auto') {
return theme === 'dark'
}
// Check system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
@@ -0,0 +1,68 @@
/**
* KBX Help Registry — v60
* Central management of help content (contextual help system)
*/
import type { KbxHelpContent, KbxHelpDefinition } from '../contracts'
class HelpRegistry {
private helpByScreenId = new Map<string, KbxHelpContent>()
/**
* Register help content for a screen
*/
register(definition: KbxHelpDefinition): void {
this.helpByScreenId.set(definition.screenId, definition.content)
}
/**
* Register multiple help definitions
*/
registerMany(definitions: KbxHelpDefinition[]): void {
definitions.forEach(def => this.register(def))
}
/**
* Get help content by screen ID
*/
getHelp(screenId: string): KbxHelpContent | undefined {
return this.helpByScreenId.get(screenId)
}
/**
* Get all help content
*/
getAllHelp(): KbxHelpContent[] {
return Array.from(this.helpByScreenId.values())
}
/**
* Check if help exists for screen
*/
hasHelp(screenId: string): boolean {
return this.helpByScreenId.has(screenId)
}
/**
* Clear all help
*/
clear(): void {
this.helpByScreenId.clear()
}
}
/**
* Global registry instance
*/
export const helpRegistry = new HelpRegistry()
/**
* Composable for Vue components
*/
export function useHelpRegistry() {
return {
getHelp: (screenId: string) => helpRegistry.getHelp(screenId),
getAllHelp: () => helpRegistry.getAllHelp(),
hasHelp: (screenId: string) => helpRegistry.hasHelp(screenId),
}
}
@@ -0,0 +1,8 @@
/**
* @kbx Registry System — v60
* Central registries for screens, permissions, help
*/
export * from './screenRegistry'
export * from './permissionRegistry'
export * from './helpRegistry'
@@ -0,0 +1,85 @@
/**
* KBX Permission Registry — v60
* Central management of permissions
*/
import type { KbxPermissionDefinition } from '../contracts'
class PermissionRegistry {
private permissions = new Map<string, KbxPermissionDefinition>()
private permissionsByCategory = new Map<string, KbxPermissionDefinition[]>()
/**
* Register a permission
*/
register(permission: KbxPermissionDefinition): void {
this.permissions.set(permission.id, permission)
// Index by category
if (!this.permissionsByCategory.has(permission.category)) {
this.permissionsByCategory.set(permission.category, [])
}
this.permissionsByCategory.get(permission.category)!.push(permission)
}
/**
* Register multiple permissions
*/
registerMany(permissions: KbxPermissionDefinition[]): void {
permissions.forEach(perm => this.register(perm))
}
/**
* Get permission by ID
*/
getPermission(id: string): KbxPermissionDefinition | undefined {
return this.permissions.get(id)
}
/**
* Get all permissions
*/
getAllPermissions(): KbxPermissionDefinition[] {
return Array.from(this.permissions.values())
}
/**
* Get permissions by category
*/
getPermissionsByCategory(category: string): KbxPermissionDefinition[] {
return this.permissionsByCategory.get(category) ?? []
}
/**
* Check if permission exists
*/
hasPermission(id: string): boolean {
return this.permissions.has(id)
}
/**
* Clear all permissions
*/
clear(): void {
this.permissions.clear()
this.permissionsByCategory.clear()
}
}
/**
* Global registry instance
*/
export const permissionRegistry = new PermissionRegistry()
/**
* Composable for Vue components
*/
export function usePermissionRegistry() {
return {
getPermission: (id: string) => permissionRegistry.getPermission(id),
getAllPermissions: () => permissionRegistry.getAllPermissions(),
getPermissionsByCategory: (category: string) =>
permissionRegistry.getPermissionsByCategory(category),
hasPermission: (id: string) => permissionRegistry.hasPermission(id),
}
}
@@ -0,0 +1,132 @@
/**
* KBX Screen Registry — v60
* Central management of screen definitions for routing, permissions, help
*/
import type { KbxScreenDefinition, KbxScreenType, KbxScreenTemplateCode } from '../contracts'
export interface ScreenRegistryEntry {
screen: KbxScreenDefinition
templateCode: KbxScreenTemplateCode
module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
}
class ScreenRegistry {
private screens = new Map<string, ScreenRegistryEntry>()
private screensByModule = new Map<string, ScreenRegistryEntry[]>()
private screensByTemplate = new Map<KbxScreenTemplateCode, ScreenRegistryEntry[]>()
/**
* Register a screen definition
*/
register(screen: KbxScreenDefinition): void {
const entry: ScreenRegistryEntry = {
screen,
templateCode: screen.templateCode,
module: screen.module,
}
// Store by ID
this.screens.set(screen.id, entry)
// Index by module
if (!this.screensByModule.has(screen.module)) {
this.screensByModule.set(screen.module, [])
}
this.screensByModule.get(screen.module)!.push(entry)
// Index by template
if (!this.screensByTemplate.has(screen.templateCode)) {
this.screensByTemplate.set(screen.templateCode, [])
}
this.screensByTemplate.get(screen.templateCode)!.push(entry)
}
/**
* Register multiple screens
*/
registerMany(screens: KbxScreenDefinition[]): void {
screens.forEach(screen => this.register(screen))
}
/**
* Get screen by ID
*/
getScreen(id: string): ScreenRegistryEntry | undefined {
return this.screens.get(id)
}
/**
* Get all screens
*/
getAllScreens(): ScreenRegistryEntry[] {
return Array.from(this.screens.values())
}
/**
* Get screens by module
*/
getScreensByModule(module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'): ScreenRegistryEntry[] {
return this.screensByModule.get(module) ?? []
}
/**
* Get screens by template code
*/
getScreensByTemplate(templateCode: KbxScreenTemplateCode): ScreenRegistryEntry[] {
return this.screensByTemplate.get(templateCode) ?? []
}
/**
* Get screens by type
*/
getScreensByType(type: KbxScreenType): ScreenRegistryEntry[] {
return this.getAllScreens().filter(entry => entry.screen.type === type)
}
/**
* Check if screen exists
*/
hasScreen(id: string): boolean {
return this.screens.has(id)
}
/**
* Get screen count by module
*/
getCountByModule(module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'): number {
return this.getScreensByModule(module).length
}
/**
* Clear all screens
*/
clear(): void {
this.screens.clear()
this.screensByModule.clear()
this.screensByTemplate.clear()
}
}
/**
* Global registry instance
*/
export const screenRegistry = new ScreenRegistry()
/**
* Composable for Vue components
*/
export function useScreenRegistry() {
return {
getScreen: (id: string) => screenRegistry.getScreen(id),
getAllScreens: () => screenRegistry.getAllScreens(),
getScreensByModule: (module: 'OMS' | 'ERP' | 'WMS' | 'COMMON') =>
screenRegistry.getScreensByModule(module),
getScreensByTemplate: (templateCode: KbxScreenTemplateCode) =>
screenRegistry.getScreensByTemplate(templateCode),
getScreensByType: (type: KbxScreenType) => screenRegistry.getScreensByType(type),
hasScreen: (id: string) => screenRegistry.hasScreen(id),
getCountByModule: (module: 'OMS' | 'ERP' | 'WMS' | 'COMMON') =>
screenRegistry.getCountByModule(module),
}
}
+192
View File
@@ -0,0 +1,192 @@
/**
* @kbx Design Tokens — v60
* Color, spacing, typography, density
*/
:root {
/* Colors */
--kbx-color-primary: #3b82f6;
--kbx-color-success: #10b981;
--kbx-color-warning: #f59e0b;
--kbx-color-danger: #ef4444;
--kbx-color-info: #06b6d4;
/* Base Colors */
--kbx-color-surface: #ffffff;
--kbx-color-background: #f9fafb;
--kbx-color-border: #e5e7eb;
--kbx-color-text: #000000;
--kbx-color-text-muted: #6b7280;
--kbx-color-section-heading: #f9fafb;
/* Module Colors */
--kbx-color-module-accent: #3b82f6;
--kbx-module-oms: #3b82f6;
--kbx-module-erp: #a78bfa;
--kbx-module-wms: #14b8a6;
--kbx-module-common: #6b7280;
/* Light Tones (for backgrounds) */
--kbx-color-danger-light: #fee2e2;
--kbx-color-warning-light: #fef3c7;
--kbx-color-success-light: #dcfce7;
--kbx-color-info-light: #cffafe;
/* Spacing */
--kbx-space-1: 4px;
--kbx-space-2: 8px;
--kbx-space-3: 12px;
--kbx-space-4: 16px;
--kbx-space-5: 20px;
--kbx-space-6: 24px;
/* Typography */
--kbx-font-family: system-ui, -apple-system, sans-serif;
--kbx-font-xs: 12px;
--kbx-font-sm: 13px;
--kbx-font-base: 14px;
--kbx-font-lg: 16px;
--kbx-font-xl: 18px;
--kbx-font-2xl: 20px;
/* Line Heights */
--kbx-line-height-tight: 1.4;
--kbx-line-height-normal: 1.5;
--kbx-line-height-relaxed: 1.6;
/* Component Heights (compact density) */
--kbx-control-xs: 28px;
--kbx-control-sm: 32px;
--kbx-control-md: 36px;
--kbx-control-lg: 44px;
/* Grid Row Height */
--kbx-grid-row-height: 34px;
--kbx-grid-header-height: 36px;
/* Master/Detail Grid */
--kbx-master-list-min-width: 280px;
--kbx-master-list-compact-width: 32%;
/* Borders */
--kbx-border-width: 1px;
--kbx-border-radius: 4px;
/* Shadows */
--kbx-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--kbx-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--kbx-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--kbx-shadow-shell: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
/* Transitions */
--kbx-transition-fast: 0.15s ease;
--kbx-transition-base: 0.2s ease;
--kbx-transition-slow: 0.3s ease;
/* Density: compact (default) */
--kbx-density: compact;
--kbx-input-height: 34px;
--kbx-input-padding: 8px 12px;
--kbx-touch-target: 44px;
--kbx-font-size: 14px;
}
/* Comfortable Density */
:root[data-density="comfortable"] {
--kbx-input-height: 36px;
--kbx-input-padding: 10px 12px;
--kbx-control-md: 40px;
--kbx-grid-row-height: 36px;
--kbx-touch-target: 48px;
--kbx-font-size: 14px;
}
/* Touch Density (WMS/Mobile) */
:root[data-density="touch"] {
--kbx-input-height: 48px;
--kbx-input-padding: 12px 16px;
--kbx-control-md: 52px;
--kbx-grid-row-height: 48px;
--kbx-touch-target: 52px;
--kbx-font-size: 16px;
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
:root {
--kbx-color-surface: #1f2937;
--kbx-color-background: #111827;
--kbx-color-border: #374151;
--kbx-color-text: #f9fafb;
--kbx-color-text-muted: #d1d5db;
--kbx-color-section-heading: #111827;
--kbx-color-danger-light: #7f1d1d;
--kbx-color-warning-light: #92400e;
--kbx-color-success-light: #15803d;
--kbx-color-info-light: #0c4a6e;
}
}
/* Explicit Dark Theme Override */
:root[data-theme="dark"] {
--kbx-color-surface: #1f2937;
--kbx-color-background: #111827;
--kbx-color-border: #374151;
--kbx-color-text: #f9fafb;
--kbx-color-text-muted: #d1d5db;
--kbx-color-section-heading: #111827;
--kbx-color-danger-light: #7f1d1d;
--kbx-color-warning-light: #92400e;
--kbx-color-success-light: #15803d;
--kbx-color-info-light: #0c4a6e;
}
/* Light Theme Override */
:root[data-theme="light"] {
--kbx-color-surface: #ffffff;
--kbx-color-background: #f9fafb;
--kbx-color-border: #e5e7eb;
--kbx-color-text: #000000;
--kbx-color-text-muted: #6b7280;
--kbx-color-section-heading: #f9fafb;
--kbx-color-danger-light: #fee2e2;
--kbx-color-warning-light: #fef3c7;
--kbx-color-success-light: #dcfce7;
--kbx-color-info-light: #cffafe;
}
/* Global Base Styles */
* {
box-sizing: border-box;
}
body {
font-family: var(--kbx-font-family);
font-size: var(--kbx-font-size);
line-height: var(--kbx-line-height-normal);
color: var(--kbx-color-text);
background: var(--kbx-color-background);
transition: color var(--kbx-transition-fast), background var(--kbx-transition-fast);
}
/* Focus Visible (Accessibility) */
:focus-visible {
outline: 2px solid var(--kbx-color-primary);
outline-offset: 2px;
}
/* Forced Colors Mode (High Contrast) */
@media (forced-colors: active) {
:root {
--kbx-color-primary: CanvasText;
--kbx-color-text: CanvasText;
--kbx-color-border: CanvasText;
}
button {
border: 1px solid CanvasText;
}
}
@@ -0,0 +1,175 @@
<script setup lang="ts">
/**
* KBX Button Component — v60
* Flexible button with variants and states
*/
type KbxButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type KbxButtonSize = 'sm' | 'md' | 'lg'
withDefaults(
defineProps<{
label?: string
variant?: KbxButtonVariant
size?: KbxButtonSize
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
icon?: string
}>(),
{
variant: 'secondary',
size: 'md',
type: 'button',
disabled: false,
loading: false,
}
)
defineEmits<{
click: []
}>()
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="[
'kbx-button',
`kbx-button--${variant}`,
`kbx-button--${size}`,
{ 'is-loading': loading, 'is-disabled': disabled },
]"
@click="$emit('click')"
>
<span v-if="loading" class="kbx-button__spinner" />
<span v-if="icon" class="kbx-button__icon">{{ icon }}</span>
<span v-if="label || $slots.default">
<slot>{{ label }}</slot>
</span>
</button>
</template>
<style scoped>
.kbx-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
/* Variants */
.kbx-button--primary {
background: var(--kbx-color-primary, #3b82f6);
color: white;
}
.kbx-button--primary:hover:not(:disabled) {
background: #2563eb;
}
.kbx-button--secondary {
background: var(--kbx-color-surface, #f9fafb);
color: var(--kbx-color-text, #000);
border: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-button--secondary:hover:not(:disabled) {
background: var(--kbx-color-border, #e5e7eb);
}
.kbx-button--danger {
background: var(--kbx-color-danger, #ef4444);
color: white;
}
.kbx-button--danger:hover:not(:disabled) {
background: #dc2626;
}
.kbx-button--ghost {
background: transparent;
color: var(--kbx-color-text, #000);
}
.kbx-button--ghost:hover:not(:disabled) {
background: var(--kbx-color-surface, #f9fafb);
}
/* Sizes */
.kbx-button--sm {
padding: 4px 12px;
font-size: 12px;
}
.kbx-button--lg {
padding: 12px 24px;
font-size: 16px;
}
/* States */
.kbx-button:disabled,
.kbx-button.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kbx-button.is-loading {
pointer-events: none;
}
.kbx-button__spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.kbx-button--secondary .kbx-button__spinner,
.kbx-button--ghost .kbx-button__spinner {
border-color: rgba(0, 0, 0, 0.2);
border-top-color: var(--kbx-color-text, #000);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.kbx-button__icon {
font-size: 1em;
}
@media (prefers-color-scheme: dark) {
.kbx-button--secondary {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.kbx-button--secondary:hover:not(:disabled) {
background: #4b5563;
}
.kbx-button--ghost {
color: #f9fafb;
}
.kbx-button--ghost:hover:not(:disabled) {
background: #374151;
}
}
</style>
@@ -0,0 +1,113 @@
<script setup lang="ts">
/**
* KBX Checkbox Component — v60
*/
withDefaults(
defineProps<{
modelValue: boolean
label?: string
disabled?: boolean
error?: string
}>(),
{
modelValue: false,
}
)
defineEmits<{
'update:modelValue': [value: boolean]
change: []
}>()
</script>
<template>
<div class="kbx-checkbox-wrapper">
<label class="kbx-checkbox">
<input
:checked="modelValue"
type="checkbox"
:disabled="disabled"
class="kbx-checkbox__input"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked); $emit('change')"
/>
<span class="kbx-checkbox__box" />
<span v-if="label" class="kbx-checkbox__label">{{ label }}</span>
</label>
<div v-if="error" class="kbx-checkbox__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-checkbox-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.kbx-checkbox__input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.kbx-checkbox__box {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 3px;
background: var(--kbx-color-surface, #fff);
transition: all 0.2s ease;
flex-shrink: 0;
}
.kbx-checkbox__input:checked ~ .kbx-checkbox__box {
background: var(--kbx-color-primary, #3b82f6);
border-color: var(--kbx-color-primary, #3b82f6);
}
.kbx-checkbox__input:checked ~ .kbx-checkbox__box::after {
content: '✓';
color: white;
font-size: 14px;
font-weight: bold;
}
.kbx-checkbox__input:disabled ~ .kbx-checkbox__box {
background: var(--kbx-color-border, #e5e7eb);
cursor: not-allowed;
opacity: 0.6;
}
.kbx-checkbox__label {
font-size: 14px;
color: var(--kbx-color-text, #000);
}
.kbx-checkbox__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-checkbox__box {
background: #1f2937;
border-color: #374151;
}
.kbx-checkbox__label {
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,144 @@
<script setup lang="ts">
/**
* KBX Data Grid Component — v60 simplified
* Wrapper for displaying tabular data
*
* v52: Server-side data source pattern
*/
import type { KbxGridColumnDefinition } from '../contracts'
export interface KbxGridRow {
[key: string]: any
}
withDefaults(
defineProps<{
columns: KbxGridColumnDefinition[]
rows: KbxGridRow[]
loading?: boolean
empty?: boolean
selectedRows?: (string | number)[]
}>(),
{
loading: false,
empty: false,
selectedRows: () => [],
}
)
defineEmits<{
'row-click': [row: KbxGridRow]
'row-select': [rows: KbxGridRow[]]
}>()
</script>
<template>
<div class="kbx-data-grid">
<div v-if="loading" class="kbx-data-grid__loading">데이터를 불러오는 중...</div>
<div v-else-if="!rows.length && empty" class="kbx-data-grid__empty">
데이터가 없습니다.
</div>
<table v-else class="kbx-data-grid__table">
<thead>
<tr>
<th v-for="col in columns" :key="col.field" :style="{ width: col.width }">
{{ col.header }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in rows"
:key="idx"
class="kbx-data-grid__row"
@click="$emit('row-click', row)"
>
<td v-for="col in columns" :key="col.field">
{{ row[col.field] }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.kbx-data-grid {
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.kbx-data-grid__loading,
.kbx-data-grid__empty {
padding: 32px;
text-align: center;
color: var(--kbx-color-text-muted, #6b7280);
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.kbx-data-grid__table {
width: 100%;
border-collapse: collapse;
background: var(--kbx-color-surface, #fff);
}
.kbx-data-grid__table thead {
background: var(--kbx-color-section-heading, #f9fafb);
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-data-grid__table th {
padding: 12px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: var(--kbx-color-text, #000);
}
.kbx-data-grid__table td {
padding: 12px;
font-size: 13px;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
color: var(--kbx-color-text, #000);
}
.kbx-data-grid__row {
cursor: pointer;
transition: background 0.2s ease;
}
.kbx-data-grid__row:hover {
background: var(--kbx-color-border, #e5e7eb);
}
@media (prefers-color-scheme: dark) {
.kbx-data-grid {
border-color: #374151;
}
.kbx-data-grid__table {
background: #1f2937;
}
.kbx-data-grid__table thead {
background: #111827;
}
.kbx-data-grid__table th {
color: #f9fafb;
}
.kbx-data-grid__table td {
border-bottom-color: #374151;
color: #f9fafb;
}
.kbx-data-grid__row:hover {
background: #374151;
}
}
</style>
@@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* KBX Date Field Component — v60
*/
withDefaults(
defineProps<{
modelValue: string
placeholder?: string
label?: string
error?: string
required?: boolean
readonly?: boolean
disabled?: boolean
}>(),
{
modelValue: '',
placeholder: 'YYYY-MM-DD',
}
)
defineEmits<{
'update:modelValue': [value: string]
blur: []
}>()
</script>
<template>
<div class="kbx-date-wrapper">
<label v-if="label" class="kbx-date__label">
{{ label }}
<span v-if="required" class="kbx-date__required">*</span>
</label>
<input
:value="modelValue"
type="date"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:class="['kbx-date', { 'is-error': error }]"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur')"
/>
<div v-if="error" class="kbx-date__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-date-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-date__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-date__required {
color: var(--kbx-color-danger, #ef4444);
margin-left: 2px;
}
.kbx-date {
padding: 8px 12px;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
font-size: 14px;
background: var(--kbx-color-surface, #fff);
color: var(--kbx-color-text, #000);
min-height: 34px;
}
.kbx-date:focus {
outline: none;
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-date:disabled {
background: var(--kbx-color-border, #e5e7eb);
opacity: 0.6;
}
.kbx-date.is-error {
border-color: var(--kbx-color-danger, #ef4444);
}
.kbx-date__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-date__label {
color: #f9fafb;
}
.kbx-date {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,171 @@
<script setup lang="ts">
/**
* KBX Dialog Component — v60 simplified
* Modal dialog with title and actions
*/
withDefaults(
defineProps<{
open: boolean
title?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
}
)
defineEmits<{
'update:open': [open: boolean]
close: []
}>()
</script>
<template>
<teleport v-if="open" to="body">
<div class="kbx-dialog__backdrop" @click="$emit('update:open', false); $emit('close')">
<div class="kbx-dialog" :class="`kbx-dialog--${size}`" @click.stop>
<header v-if="title" class="kbx-dialog__header">
<h2>{{ title }}</h2>
<button class="kbx-dialog__close" @click="$emit('update:open', false); $emit('close')">
</button>
</header>
<div class="kbx-dialog__content">
<slot />
</div>
<footer v-if="$slots.footer" class="kbx-dialog__footer">
<slot name="footer" />
</footer>
</div>
</div>
</teleport>
</template>
<style scoped>
.kbx-dialog__backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.kbx-dialog {
background: var(--kbx-color-surface, #fff);
border-radius: 6px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
max-height: 90vh;
max-width: 90vw;
animation: slideUp 0.2s ease;
}
.kbx-dialog--sm {
width: 400px;
}
.kbx-dialog--md {
width: 600px;
}
.kbx-dialog--lg {
width: 800px;
}
.kbx-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-dialog__header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--kbx-color-text, #000);
}
.kbx-dialog__close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--kbx-color-text-muted, #6b7280);
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.kbx-dialog__content {
padding: 20px;
flex: 1;
overflow-y: auto;
}
.kbx-dialog__footer {
padding: 16px 20px;
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
display: flex;
gap: 8px;
justify-content: flex-end;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (prefers-color-scheme: dark) {
.kbx-dialog {
background: #1f2937;
}
.kbx-dialog__header {
border-bottom-color: #374151;
}
.kbx-dialog__header h2 {
color: #f9fafb;
}
.kbx-dialog__footer {
border-top-color: #374151;
}
}
@media (max-width: 600px) {
.kbx-dialog--sm,
.kbx-dialog--md,
.kbx-dialog--lg {
width: calc(100% - 32px);
}
}
</style>
@@ -0,0 +1,163 @@
<script setup lang="ts">
/**
* KBX Drawer Component — v60 simplified
* Side panel drawer
*/
withDefaults(
defineProps<{
open: boolean
title?: string
position?: 'left' | 'right'
}>(),
{
position: 'right',
}
)
defineEmits<{
'update:open': [open: boolean]
close: []
}>()
</script>
<template>
<teleport v-if="open" to="body">
<div class="kbx-drawer__backdrop" @click="$emit('update:open', false); $emit('close')">
<div
class="kbx-drawer"
:class="`kbx-drawer--${position}`"
@click.stop
>
<header v-if="title" class="kbx-drawer__header">
<h2>{{ title }}</h2>
<button class="kbx-drawer__close" @click="$emit('update:open', false); $emit('close')">
</button>
</header>
<div class="kbx-drawer__content">
<slot />
</div>
<footer v-if="$slots.footer" class="kbx-drawer__footer">
<slot name="footer" />
</footer>
</div>
</div>
</teleport>
</template>
<style scoped>
.kbx-drawer__backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.2s ease;
}
.kbx-drawer {
position: fixed;
top: 0;
bottom: 0;
width: 400px;
background: var(--kbx-color-surface, #fff);
display: flex;
flex-direction: column;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
animation: slideIn 0.3s ease;
z-index: 1000;
}
.kbx-drawer--right {
right: 0;
animation: slideInRight 0.3s ease;
}
.kbx-drawer--left {
left: 0;
animation: slideInLeft 0.3s ease;
}
.kbx-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-drawer__header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.kbx-drawer__close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 0;
}
.kbx-drawer__content {
padding: 20px;
flex: 1;
overflow-y: auto;
}
.kbx-drawer__footer {
padding: 16px 20px;
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@media (prefers-color-scheme: dark) {
.kbx-drawer {
background: #1f2937;
}
.kbx-drawer__header {
border-bottom-color: #374151;
}
.kbx-drawer__footer {
border-top-color: #374151;
}
}
@media (max-width: 600px) {
.kbx-drawer {
width: calc(100% - 32px);
}
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* KBX Form Grid — v60
* Layout grid for form fields
*/
withDefaults(
defineProps<{
columns?: 1 | 2 | 3
gap?: number
}>(),
{
columns: 1,
gap: 16,
}
)
</script>
<template>
<div class="kbx-form-grid" :style="{ gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: gap + 'px' }">
<slot />
</div>
</template>
<style scoped>
.kbx-form-grid {
display: grid;
}
@media (max-width: 768px) {
.kbx-form-grid {
grid-template-columns: 1fr !important;
}
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
/**
* KBX Form Section — v60
* Group form fields with header
*/
withDefaults(
defineProps<{
title?: string
description?: string
}>(),
{}
)
</script>
<template>
<fieldset class="kbx-form-section">
<legend v-if="title" class="kbx-form-section__title">{{ title }}</legend>
<p v-if="description" class="kbx-form-section__description">{{ description }}</p>
<div class="kbx-form-section__content">
<slot />
</div>
</fieldset>
</template>
<style scoped>
.kbx-form-section {
border: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.kbx-form-section__title {
font-size: 14px;
font-weight: 600;
color: var(--kbx-color-text);
margin: 0;
padding: 0;
padding-top: 12px;
border-top: 1px solid var(--kbx-color-border);
}
.kbx-form-section__description {
font-size: 12px;
color: var(--kbx-color-text-muted);
margin: 0;
}
.kbx-form-section__content {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (prefers-color-scheme: dark) {
.kbx-form-section__title {
color: #f9fafb;
border-top-color: #374151;
}
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* KBX Input Component — v60
* Text input field with validation state
*/
withDefaults(
defineProps<{
modelValue: string | number
placeholder?: string
label?: string
error?: string
required?: boolean
readonly?: boolean
disabled?: boolean
type?: string
}>(),
{
type: 'text',
modelValue: '',
}
)
defineEmits<{
'update:modelValue': [value: string]
blur: []
focus: []
}>()
</script>
<template>
<div class="kbx-input-wrapper">
<label v-if="label" class="kbx-input__label">
{{ label }}
<span v-if="required" class="kbx-input__required">*</span>
</label>
<input
:value="modelValue"
:type="type"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:class="['kbx-input', { 'is-error': error }]"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur')"
@focus="$emit('focus')"
/>
<div v-if="error" class="kbx-input__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-input-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-input__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-input__required {
color: var(--kbx-color-danger, #ef4444);
margin-left: 2px;
}
.kbx-input {
padding: 8px 12px;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
font-size: 14px;
background: var(--kbx-color-surface, #fff);
color: var(--kbx-color-text, #000);
transition: all 0.2s ease;
min-height: 34px;
}
.kbx-input:focus {
outline: none;
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-input:disabled {
background: var(--kbx-color-border, #e5e7eb);
cursor: not-allowed;
opacity: 0.6;
}
.kbx-input.is-error {
border-color: var(--kbx-color-danger, #ef4444);
}
.kbx-input.is-error:focus {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.kbx-input__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-input__label {
color: #f9fafb;
}
.kbx-input {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}
.kbx-input:focus {
border-color: #60a5fa;
}
.kbx-input:disabled {
background: #374151;
}
}
</style>
@@ -0,0 +1,265 @@
<script setup lang="ts">
/**
* KBX Lookup Component — v60 simplified
* Search and select from a list of items (used in forms and grids)
*/
import { ref, computed } from 'vue'
export interface KbxLookupItem {
id: string | number
label: string
code?: string
disabled?: boolean
}
withDefaults(
defineProps<{
items: KbxLookupItem[]
label?: string
placeholder?: string
required?: boolean
loading?: boolean
error?: string
modelValue?: string | number
}>(),
{
placeholder: '검색...',
modelValue: '',
}
)
defineEmits<{
'update:modelValue': [id: string | number]
select: [item: KbxLookupItem]
}>()
const search = ref('')
const open = ref(false)
const props = defineProps<{
items: KbxLookupItem[]
label?: string
placeholder?: string
required?: boolean
loading?: boolean
error?: string
modelValue?: string | number
}>()
const filtered = computed(() => {
if (!search.value) return props.items
const q = search.value.toLowerCase()
return props.items.filter(
item => item.label.toLowerCase().includes(q) || item.code?.toLowerCase().includes(q)
)
})
const selectedItem = computed(() => props.items.find(item => item.id === props.modelValue))
const selectItem = (item: KbxLookupItem) => {
emit('update:modelValue', item.id)
emit('select', item)
open.value = false
search.value = ''
}
const emit = defineEmits<{
'update:modelValue': [id: string | number]
select: [item: KbxLookupItem]
}>()
</script>
<template>
<div class="kbx-lookup-wrapper">
<label v-if="label" class="kbx-lookup__label">
{{ label }}
<span v-if="required" class="kbx-lookup__required">*</span>
</label>
<div class="kbx-lookup__input-wrapper">
<input
v-model="search"
:placeholder="selectedItem ? selectedItem.label : placeholder"
:disabled="loading"
class="kbx-lookup__input"
@focus="open = true"
@input="open = true"
/>
<span v-if="selectedItem" class="kbx-lookup__code">{{ selectedItem.code }}</span>
</div>
<div v-if="open" class="kbx-lookup__dropdown">
<div v-if="loading" class="kbx-lookup__loading">로드 중...</div>
<div v-else-if="!filtered.length" class="kbx-lookup__empty">
검색 결과가 없습니다.
</div>
<ul v-else class="kbx-lookup__list">
<li
v-for="item in filtered"
:key="item.id"
:class="['kbx-lookup__item', { 'is-disabled': item.disabled }]"
@click="!item.disabled && selectItem(item)"
>
<span class="kbx-lookup__item-label">{{ item.label }}</span>
<span v-if="item.code" class="kbx-lookup__item-code">{{ item.code }}</span>
</li>
</ul>
</div>
<div v-if="error" class="kbx-lookup__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-lookup-wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-lookup__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-lookup__required {
color: var(--kbx-color-danger, #ef4444);
margin-left: 2px;
}
.kbx-lookup__input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
background: var(--kbx-color-surface, #fff);
min-height: 34px;
}
.kbx-lookup__input {
flex: 1;
border: none;
padding: 8px 12px;
font-size: 14px;
background: transparent;
color: var(--kbx-color-text, #000);
}
.kbx-lookup__input:focus {
outline: none;
}
.kbx-lookup__input-wrapper:focus-within {
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-lookup__code {
padding: 0 8px;
font-size: 11px;
color: var(--kbx-color-text-muted, #6b7280);
border-left: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-lookup__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--kbx-color-surface, #fff);
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 300px;
overflow-y: auto;
z-index: 10;
margin-top: -1px;
}
.kbx-lookup__loading,
.kbx-lookup__empty {
padding: 12px;
text-align: center;
font-size: 13px;
color: var(--kbx-color-text-muted, #6b7280);
}
.kbx-lookup__list {
list-style: none;
margin: 0;
padding: 0;
}
.kbx-lookup__item {
padding: 8px 12px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
font-size: 13px;
transition: background 0.2s ease;
}
.kbx-lookup__item:last-child {
border-bottom: none;
}
.kbx-lookup__item:hover:not(.is-disabled) {
background: var(--kbx-color-border, #e5e7eb);
}
.kbx-lookup__item.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kbx-lookup__item-label {
flex: 1;
}
.kbx-lookup__item-code {
font-size: 11px;
color: var(--kbx-color-text-muted, #6b7280);
margin-left: 8px;
}
.kbx-lookup__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-lookup__label {
color: #f9fafb;
}
.kbx-lookup__input-wrapper {
background: #1f2937;
border-color: #374151;
}
.kbx-lookup__input {
color: #f9fafb;
}
.kbx-lookup__code {
border-left-color: #374151;
}
.kbx-lookup__dropdown {
background: #1f2937;
border-color: #374151;
}
.kbx-lookup__item {
border-bottom-color: #374151;
}
.kbx-lookup__item:hover:not(.is-disabled) {
background: #374151;
}
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
/**
* KBX Master Template — v60 (T02)
* List + Detail (Master-Detail / CRUD)
*
* v52 Anatomy:
* - listTitle: 목록 제목 (e.g., "품목 목록")
* - listCount: 항목 수
* - listDescription: 목록 설명
* - detailTitle: 상세 제목 (e.g., "품목 상세")
* - detailDescription: 상세 설명
*/
import type { KbxValidationError } from '../contracts'
import KbxSectionHeader from './KbxSectionHeader.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
withDefaults(
defineProps<{
listTitle?: string
listCount?: number
listCountUnit?: string
listDescription?: string
detailTitle?: string
detailDescription?: string
errors?: KbxValidationError[]
status?: string
dirty?: boolean
}>(),
{
listTitle: '목록',
listCountUnit: '건',
listDescription: '',
detailTitle: '상세',
detailDescription: '',
errors: () => [],
status: '',
dirty: false,
}
)
defineEmits<{
command: [string]
}>()
</script>
<template>
<div class="kbx-master-template">
<!-- Status/Error Feedback -->
<KbxValidationSummary v-if="errors.length" :errors="errors" />
<!-- List Pane -->
<aside class="kbx-master-template__list" data-kbx-surface="master-list">
<KbxSectionHeader
:title="listTitle"
:count="listCount"
:count-unit="listCountUnit"
:description="listDescription"
>
<template #actions>
<slot name="list-actions" />
</template>
</KbxSectionHeader>
<div class="kbx-master-template__list-body">
<slot name="list" />
</div>
</aside>
<!-- Detail Pane -->
<main class="kbx-master-template__detail" data-kbx-surface="detail">
<KbxSectionHeader
:title="detailTitle"
:description="detailDescription"
>
<template #actions>
<slot name="detail-actions" />
</template>
</KbxSectionHeader>
<div class="kbx-master-template__detail-body">
<slot name="detail" />
</div>
<!-- Optional Tabs Section -->
<section v-if="$slots.tabs" class="kbx-master-template__tabs" data-kbx-surface="tabs">
<slot name="tabs" />
</section>
</main>
</div>
</template>
<style scoped>
.kbx-master-template {
display: grid;
grid-template-columns: minmax(280px, 32%) minmax(0, 1fr);
gap: 12px;
height: 100%;
overflow: hidden;
}
.kbx-master-template__list,
.kbx-master-template__detail {
min-height: 0;
border: 1px solid var(--kbx-color-border, #e5e7eb);
background: var(--kbx-color-surface, #fff);
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.kbx-master-template__list {
border-top: 3px solid var(--kbx-color-module-accent, #3b82f6);
}
.kbx-master-template__detail {
border-top: 3px solid var(--kbx-color-module-accent, #3b82f6);
}
.kbx-master-template__list :deep(.kbx-section-header),
.kbx-master-template__detail :deep(.kbx-section-header) {
background: var(--kbx-color-section-heading, #f9fafb);
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-master-template__list-body,
.kbx-master-template__detail-body {
padding: 12px;
flex: 1;
overflow: auto;
}
.kbx-master-template__tabs {
margin-top: auto;
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
padding: 12px;
}
/* Mobile: Single column */
@media (max-width: 991px) {
.kbx-master-template {
grid-template-columns: 1fr;
}
}
@media (prefers-color-scheme: dark) {
.kbx-master-template__list,
.kbx-master-template__detail {
background: #1f2937;
border-color: #374151;
}
.kbx-master-template__list :deep(.kbx-section-header),
.kbx-master-template__detail :deep(.kbx-section-header) {
background: #111827;
}
}
</style>
@@ -0,0 +1,129 @@
<script setup lang="ts">
/**
* KBX Money Field — v60
* Currency input with formatting
*/
withDefaults(
defineProps<{
modelValue: number | string
label?: string
error?: string
required?: boolean
disabled?: boolean
currency?: string
}>(),
{
modelValue: '',
currency: '₩',
}
)
defineEmits<{
'update:modelValue': [value: number]
blur: []
}>()
const formatCurrency = (value: number | string): string => {
if (!value) return ''
const num = typeof value === 'string' ? parseFloat(value) : value
return num.toLocaleString('ko-KR')
}
</script>
<template>
<div class="kbx-money-wrapper">
<label v-if="label" class="kbx-money__label">
{{ label }}
<span v-if="required" class="kbx-money__required">*</span>
</label>
<div class="kbx-money__input-wrapper">
<span class="kbx-money__currency">{{ currency }}</span>
<input
:value="formatCurrency(modelValue)"
type="text"
inputmode="numeric"
:disabled="disabled"
:class="['kbx-money__input', { 'is-error': error }]"
@input="$emit('update:modelValue', Number(($event.target as HTMLInputElement).value.replace(/[^\d]/g, '')))"
@blur="$emit('blur')"
/>
</div>
<div v-if="error" class="kbx-money__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-money-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-money__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text);
}
.kbx-money__required {
color: var(--kbx-color-danger);
margin-left: 2px;
}
.kbx-money__input-wrapper {
position: relative;
display: flex;
align-items: center;
border: 1px solid var(--kbx-color-border);
border-radius: 4px;
background: var(--kbx-color-surface);
min-height: 34px;
}
.kbx-money__currency {
padding: 0 10px;
color: var(--kbx-color-text-muted);
font-size: 14px;
font-weight: 500;
}
.kbx-money__input {
flex: 1;
border: none;
padding: 8px 0 8px 0;
font-size: 14px;
background: transparent;
color: var(--kbx-color-text);
text-align: right;
}
.kbx-money__input:focus {
outline: none;
}
.kbx-money__input-wrapper:focus-within {
border-color: var(--kbx-color-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-money__error {
font-size: 12px;
color: var(--kbx-color-danger);
}
@media (prefers-color-scheme: dark) {
.kbx-money__label {
color: #f9fafb;
}
.kbx-money__input-wrapper {
background: #1f2937;
border-color: #374151;
}
.kbx-money__input {
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* KBX Number Field Component — v60
*/
withDefaults(
defineProps<{
modelValue: number | string
placeholder?: string
label?: string
error?: string
required?: boolean
readonly?: boolean
disabled?: boolean
min?: number
max?: number
step?: number
}>(),
{
modelValue: '',
step: 1,
}
)
defineEmits<{
'update:modelValue': [value: number]
blur: []
}>()
</script>
<template>
<div class="kbx-number-wrapper">
<label v-if="label" class="kbx-number__label">
{{ label }}
<span v-if="required" class="kbx-number__required">*</span>
</label>
<input
:value="modelValue"
type="number"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:min="min"
:max="max"
:step="step"
:class="['kbx-number', { 'is-error': error }]"
@input="$emit('update:modelValue', Number(($event.target as HTMLInputElement).value))"
@blur="$emit('blur')"
/>
<div v-if="error" class="kbx-number__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-number-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-number__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-number__required {
color: var(--kbx-color-danger, #ef4444);
margin-left: 2px;
}
.kbx-number {
padding: 8px 12px;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
font-size: 14px;
background: var(--kbx-color-surface, #fff);
color: var(--kbx-color-text, #000);
text-align: right;
min-height: 34px;
}
.kbx-number:focus {
outline: none;
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-number:disabled {
background: var(--kbx-color-border, #e5e7eb);
opacity: 0.6;
}
.kbx-number.is-error {
border-color: var(--kbx-color-danger, #ef4444);
}
.kbx-number__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-number__label {
color: #f9fafb;
}
.kbx-number {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* KBX Quantity Field — v60
* Quantity input with increment/decrement
*/
withDefaults(
defineProps<{
modelValue: number | string
label?: string
error?: string
required?: boolean
disabled?: boolean
min?: number
max?: number
}>(),
{
modelValue: 0,
min: 0,
}
)
defineEmits<{
'update:modelValue': [value: number]
blur: []
}>()
</script>
<template>
<div class="kbx-quantity-wrapper">
<label v-if="label" class="kbx-quantity__label">
{{ label }}
<span v-if="required" class="kbx-quantity__required">*</span>
</label>
<div class="kbx-quantity__input-group">
<button
class="kbx-quantity__btn"
:disabled="disabled || (min !== undefined && Number(modelValue) <= min)"
@click="$emit('update:modelValue', Math.max(min ?? 0, Number(modelValue) - 1))"
>
</button>
<input
:value="modelValue"
type="number"
:disabled="disabled"
:min="min"
:max="max"
:class="['kbx-quantity__input', { 'is-error': error }]"
@input="$emit('update:modelValue', Number(($event.target as HTMLInputElement).value))"
@blur="$emit('blur')"
/>
<button
class="kbx-quantity__btn"
:disabled="disabled || (max !== undefined && Number(modelValue) >= max)"
@click="$emit('update:modelValue', max ? Math.min(max, Number(modelValue) + 1) : Number(modelValue) + 1)"
>
+
</button>
</div>
<div v-if="error" class="kbx-quantity__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-quantity-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-quantity__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text);
}
.kbx-quantity__required {
color: var(--kbx-color-danger);
margin-left: 2px;
}
.kbx-quantity__input-group {
display: flex;
align-items: center;
border: 1px solid var(--kbx-color-border);
border-radius: 4px;
background: var(--kbx-color-surface);
overflow: hidden;
}
.kbx-quantity__btn {
padding: 8px 12px;
border: none;
background: transparent;
cursor: pointer;
font-weight: 600;
color: var(--kbx-color-text);
border-right: 1px solid var(--kbx-color-border);
}
.kbx-quantity__btn:last-child {
border-right: none;
border-left: 1px solid var(--kbx-color-border);
}
.kbx-quantity__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kbx-quantity__input {
flex: 1;
border: none;
padding: 8px 12px;
font-size: 14px;
text-align: center;
background: transparent;
color: var(--kbx-color-text);
}
.kbx-quantity__input:focus {
outline: none;
}
.kbx-quantity__error {
font-size: 12px;
color: var(--kbx-color-danger);
}
@media (prefers-color-scheme: dark) {
.kbx-quantity__label {
color: #f9fafb;
}
.kbx-quantity__input-group {
background: #1f2937;
border-color: #374151;
}
.kbx-quantity__btn {
color: #f9fafb;
border-right-color: #374151;
}
.kbx-quantity__btn:last-child {
border-left-color: #374151;
}
.kbx-quantity__input {
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
/**
* KBX Queue Template — v60 (T06)
* Work Queue / Task Queue (WMS 작업, 승인 대기, 예외 처리)
*
* v52 Principle: Queue는 "지금 처리할 업무"를 보여준다.
*
* v52 Anatomy:
* - queueTitle: 대기열 제목 (e.g., "현재 작업 Queue")
* - queueDescription: 대기열 설명
* - queueCount: 현재 대기 중인 항목 수
*/
import { computed } from 'vue'
import KbxSectionHeader from './KbxSectionHeader.vue'
withDefaults(
defineProps<{
queueTitle?: string
queueDescription?: string
queueCount?: number
queueCountUnit?: string
empty?: boolean
}>(),
{
queueTitle: '대기열',
queueCountUnit: '건',
empty: false,
}
)
const hasItems = computed(() => {
if (typeof props.queueCount === 'number') return props.queueCount > 0
return !props.empty
})
const props = defineProps<{
queueTitle?: string
queueDescription?: string
queueCount?: number
queueCountUnit?: string
empty?: boolean
}>()
</script>
<template>
<div class="kbx-queue-template">
<KbxSectionHeader
:title="queueTitle"
:count="queueCount"
:count-unit="queueCountUnit"
:description="queueDescription"
/>
<div class="kbx-queue-template__body">
<div v-if="hasItems" class="kbx-queue-template__queue-body">
<slot name="queue-body" />
</div>
<div v-else class="kbx-queue-template__empty-state">
<div class="kbx-queue-template__empty-icon"></div>
<p class="kbx-queue-template__empty-message">
처리할 작업이 없습니다.
</p>
</div>
</div>
<div v-if="$slots['queue-footer']" class="kbx-queue-template__footer">
<slot name="queue-footer" />
</div>
</div>
</template>
<style scoped>
.kbx-queue-template {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--kbx-color-border, #e5e7eb);
background: var(--kbx-color-surface, #fff);
border-radius: 4px;
overflow: hidden;
border-top: 3px solid var(--kbx-color-module-accent, #3b82f6);
}
.kbx-queue-template :deep(.kbx-section-header) {
background: var(--kbx-color-section-heading, #f9fafb);
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-queue-template__body {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.kbx-queue-template__queue-body {
flex: 1;
overflow: auto;
padding: 12px;
}
.kbx-queue-template__empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
text-align: center;
color: var(--kbx-color-text-muted, #6b7280);
}
.kbx-queue-template__empty-icon {
font-size: 48px;
line-height: 1;
margin-bottom: 12px;
opacity: 0.5;
}
.kbx-queue-template__empty-message {
margin: 0;
font-size: 14px;
line-height: 1.5;
}
.kbx-queue-template__footer {
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
padding: 12px;
}
@media (prefers-color-scheme: dark) {
.kbx-queue-template {
background: #1f2937;
border-color: #374151;
}
.kbx-queue-template :deep(.kbx-section-header) {
background: #111827;
}
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
/**
* KBX Radio Button — v60
*/
interface KbxRadioOption {
value: string | number
label: string
disabled?: boolean
}
withDefaults(
defineProps<{
modelValue: string | number
options: KbxRadioOption[]
label?: string
disabled?: boolean
}>(),
{
modelValue: '',
}
)
defineEmits<{
'update:modelValue': [value: string | number]
}>()
</script>
<template>
<div class="kbx-radio-wrapper">
<label v-if="label" class="kbx-radio__label">{{ label }}</label>
<div class="kbx-radio-group">
<label v-for="opt in options" :key="opt.value" class="kbx-radio" :class="{ 'is-disabled': opt.disabled || disabled }">
<input
:checked="modelValue === opt.value"
type="radio"
:value="opt.value"
:disabled="opt.disabled || disabled"
@change="$emit('update:modelValue', opt.value)"
/>
<span class="kbx-radio__box" />
<span class="kbx-radio__text">{{ opt.label }}</span>
</label>
</div>
</div>
</template>
<style scoped>
.kbx-radio-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.kbx-radio__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text);
}
.kbx-radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.kbx-radio {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.kbx-radio input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.kbx-radio__box {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid var(--kbx-color-border);
border-radius: 50%;
background: var(--kbx-color-surface);
flex-shrink: 0;
}
.kbx-radio input:checked ~ .kbx-radio__box {
border-color: var(--kbx-color-primary);
background: var(--kbx-color-primary);
}
.kbx-radio input:checked ~ .kbx-radio__box::after {
content: '';
width: 6px;
height: 6px;
background: white;
border-radius: 50%;
}
.kbx-radio__text {
font-size: 14px;
color: var(--kbx-color-text);
}
.kbx-radio.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (prefers-color-scheme: dark) {
.kbx-radio__label {
color: #f9fafb;
}
.kbx-radio__box {
background: #1f2937;
border-color: #374151;
}
.kbx-radio__text {
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
/**
* KBX Reconcile Template — v60 (T07)
* Reconciliation / Comparison (OMS ↔ WMS, Expected ↔ Actual)
*
* v52 Principle: 대사 화면은 "비교 목적"을 명시해야 한다.
*
* v52 Anatomy:
* - comparisonTitle: 대사 제목 (e.g., "OMS ↔ WMS 대사")
* - comparisonDescription: 대사 설명
* - comparisonCount: 불일치 항목 수
*/
import { computed } from 'vue'
import KbxSectionHeader from './KbxSectionHeader.vue'
withDefaults(
defineProps<{
comparisonTitle?: string
comparisonDescription?: string
comparisonCount?: number
comparisonCountUnit?: string
leftLabel?: string
rightLabel?: string
empty?: boolean
}>(),
{
comparisonTitle: '대사',
comparisonCountUnit: '건',
leftLabel: 'Expected',
rightLabel: 'Actual',
empty: false,
}
)
const props = defineProps<{
comparisonTitle?: string
comparisonDescription?: string
comparisonCount?: number
comparisonCountUnit?: string
leftLabel?: string
rightLabel?: string
empty?: boolean
}>()
const hasDiscrepancies = computed(() => {
if (typeof props.comparisonCount === 'number') return props.comparisonCount > 0
return !props.empty
})
</script>
<template>
<div class="kbx-reconcile-template">
<KbxSectionHeader
:title="comparisonTitle"
:count="comparisonCount"
:count-unit="comparisonCountUnit"
:description="comparisonDescription"
/>
<div class="kbx-reconcile-template__column-labels">
<div class="kbx-reconcile-template__label">{{ leftLabel }}</div>
<div class="kbx-reconcile-template__label">Difference</div>
<div class="kbx-reconcile-template__label">{{ rightLabel }}</div>
</div>
<div class="kbx-reconcile-template__body">
<div v-if="hasDiscrepancies" class="kbx-reconcile-template__comparison-body">
<slot name="comparison-body" />
</div>
<div v-else class="kbx-reconcile-template__aligned-state">
<div class="kbx-reconcile-template__aligned-icon"></div>
<p class="kbx-reconcile-template__aligned-message">
모든 항목이 일치합니다.
</p>
</div>
</div>
<div v-if="$slots['comparison-footer']" class="kbx-reconcile-template__footer">
<slot name="comparison-footer" />
</div>
</div>
</template>
<style scoped>
.kbx-reconcile-template {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--kbx-color-border, #e5e7eb);
background: var(--kbx-color-surface, #fff);
border-radius: 4px;
overflow: hidden;
border-top: 3px solid var(--kbx-color-module-accent, #3b82f6);
}
.kbx-reconcile-template :deep(.kbx-section-header) {
background: var(--kbx-color-section-heading, #f9fafb);
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-reconcile-template__column-labels {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
color: var(--kbx-color-text-muted, #6b7280);
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
background: var(--kbx-color-section-heading, #f9fafb);
}
.kbx-reconcile-template__label {
text-align: center;
}
.kbx-reconcile-template__body {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.kbx-reconcile-template__comparison-body {
flex: 1;
overflow: auto;
padding: 12px;
}
.kbx-reconcile-template__aligned-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
text-align: center;
color: var(--kbx-color-text-muted, #6b7280);
}
.kbx-reconcile-template__aligned-icon {
font-size: 48px;
line-height: 1;
margin-bottom: 12px;
opacity: 0.5;
color: var(--kbx-color-success, #10b981);
}
.kbx-reconcile-template__aligned-message {
margin: 0;
font-size: 14px;
line-height: 1.5;
}
.kbx-reconcile-template__footer {
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
padding: 12px 16px;
}
@media (prefers-color-scheme: dark) {
.kbx-reconcile-template {
background: #1f2937;
border-color: #374151;
}
.kbx-reconcile-template :deep(.kbx-section-header),
.kbx-reconcile-template__column-labels {
background: #111827;
border-bottom-color: #374151;
}
}
@media (max-width: 767px) {
.kbx-reconcile-template__column-labels {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.kbx-reconcile-template__label:nth-child(2) {
display: none;
}
}
</style>
@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* KBX Screen Frame — v60
* Main wrapper for all screen templates (T01-T09)
*/
import type { KbxScreenDefinition } from '../contracts'
withDefaults(
defineProps<{
screen?: KbxScreenDefinition
status?: string
dirty?: boolean
expectedType?: string
templateCode?: string
breadcrumb?: string
suppressDefaultUtility?: boolean
}>(),
{
status: '',
dirty: false,
suppressDefaultUtility: false,
}
)
defineEmits<{
command: [string]
}>()
</script>
<template>
<div class="kbx-screen-frame" :data-status="status" :data-dirty="dirty">
<header class="kbx-screen-frame__header">
<div class="kbx-screen-frame__breadcrumb" v-if="breadcrumb">
{{ breadcrumb }}
</div>
<div class="kbx-screen-frame__title" v-if="screen">
{{ screen.title }}
</div>
<div class="kbx-screen-frame__utility" v-if="!suppressDefaultUtility">
<slot name="utility" />
</div>
</header>
<div class="kbx-screen-frame__notice">
<slot name="notice" />
</div>
<main class="kbx-screen-frame__content">
<slot />
</main>
<div v-if="$slots.drawer" class="kbx-screen-frame__drawer">
<slot name="drawer" />
</div>
</div>
</template>
<style scoped>
.kbx-screen-frame {
display: flex;
flex-direction: column;
height: 100%;
background: var(--kbx-color-background);
}
.kbx-screen-frame__header {
padding: 12px 16px;
border-bottom: 1px solid var(--kbx-color-border);
background: var(--kbx-color-surface);
display: flex;
align-items: center;
gap: 12px;
}
.kbx-screen-frame__breadcrumb {
font-size: 12px;
color: var(--kbx-color-text-muted);
flex-shrink: 0;
}
.kbx-screen-frame__title {
font-size: 16px;
font-weight: 600;
color: var(--kbx-color-text);
flex: 1;
}
.kbx-screen-frame__utility {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.kbx-screen-frame__notice {
min-height: auto;
padding: 0 16px;
}
.kbx-screen-frame__content {
flex: 1;
overflow: auto;
padding: 16px;
}
.kbx-screen-frame__drawer {
position: fixed;
right: 0;
top: 0;
bottom: 0;
}
@media (prefers-color-scheme: dark) {
.kbx-screen-frame {
background: #111827;
}
.kbx-screen-frame__header {
background: #1f2937;
border-bottom-color: #374151;
}
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* KBX Section Header — v60 simplified
* Standard header for screen sections
*/
withDefaults(
defineProps<{
title: string
count?: number
countUnit?: string
description?: string
}>(),
{ countUnit: '건' }
)
</script>
<template>
<header class="kbx-section-header">
<div>
<h2>
{{ title }}
<span v-if="count != null"> {{ count.toLocaleString('ko-KR') }}{{ countUnit }}</span>
</h2>
<p v-if="description">{{ description }}</p>
</div>
<div class="kbx-section-header__actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.kbx-section-header {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
padding: 0 16px;
}
.kbx-section-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--kbx-color-text, #000);
}
.kbx-section-header h2 span {
font-size: 13px;
color: var(--kbx-color-text-muted, #6b7280);
font-weight: 500;
margin-left: 6px;
}
.kbx-section-header p {
margin: 4px 0 0;
color: var(--kbx-color-text-muted, #6b7280);
font-size: 12px;
}
.kbx-section-header__actions {
display: flex;
gap: 8px;
}
@media (prefers-color-scheme: dark) {
.kbx-section-header {
border-bottom-color: #374151;
}
.kbx-section-header h2 {
color: #f9fafb;
}
.kbx-section-header h2 span,
.kbx-section-header p {
color: #d1d5db;
}
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
/**
* KBX Select Component — v60
*/
export interface KbxSelectOption {
value: string | number
label: string
disabled?: boolean
}
withDefaults(
defineProps<{
modelValue: string | number
options: KbxSelectOption[]
placeholder?: string
label?: string
error?: string
required?: boolean
disabled?: boolean
}>(),
{
modelValue: '',
}
)
defineEmits<{
'update:modelValue': [value: string | number]
change: []
}>()
</script>
<template>
<div class="kbx-select-wrapper">
<label v-if="label" class="kbx-select__label">
{{ label }}
<span v-if="required" class="kbx-select__required">*</span>
</label>
<select
:value="modelValue"
:disabled="disabled"
:class="['kbx-select', { 'is-error': error }]"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value); $emit('change')"
>
<option v-if="placeholder" value="">{{ placeholder }}</option>
<option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
{{ opt.label }}
</option>
</select>
<div v-if="error" class="kbx-select__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-select-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-select__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-select__required {
color: var(--kbx-color-danger, #ef4444);
margin-left: 2px;
}
.kbx-select {
padding: 8px 12px;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
font-size: 14px;
background: var(--kbx-color-surface, #fff);
color: var(--kbx-color-text, #000);
cursor: pointer;
min-height: 34px;
}
.kbx-select:focus {
outline: none;
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-select:disabled {
background: var(--kbx-color-border, #e5e7eb);
cursor: not-allowed;
opacity: 0.6;
}
.kbx-select.is-error {
border-color: var(--kbx-color-danger, #ef4444);
}
.kbx-select__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-select__label {
color: #f9fafb;
}
.kbx-select {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* KBX Status Tag Component — v60
* Display status with tone (success, warning, danger, etc.)
*/
type KbxStatusTone = 'default' | 'info' | 'success' | 'warning' | 'danger' | 'muted'
withDefaults(
defineProps<{
label: string
tone?: KbxStatusTone
icon?: string
}>(),
{
tone: 'default',
}
)
</script>
<template>
<span :class="['kbx-status-tag', `kbx-status-tag--${tone}`]">
<span v-if="icon" class="kbx-status-tag__icon">{{ icon }}</span>
<span class="kbx-status-tag__label">{{ label }}</span>
</span>
</template>
<style scoped>
.kbx-status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
line-height: 1.4;
}
.kbx-status-tag--default {
background: var(--kbx-color-surface, #f9fafb);
color: var(--kbx-color-text, #000);
border: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-status-tag--info {
background: #dbeafe;
color: #1e40af;
}
.kbx-status-tag--success {
background: #dcfce7;
color: #166534;
}
.kbx-status-tag--warning {
background: #fef3c7;
color: #92400e;
}
.kbx-status-tag--danger {
background: #fee2e2;
color: #991b1b;
}
.kbx-status-tag--muted {
background: var(--kbx-color-border, #e5e7eb);
color: var(--kbx-color-text-muted, #6b7280);
}
.kbx-status-tag__icon {
font-size: 1em;
line-height: 1;
}
@media (prefers-color-scheme: dark) {
.kbx-status-tag--default {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.kbx-status-tag--info {
background: #1e3a8a;
color: #93c5fd;
}
.kbx-status-tag--success {
background: #15803d;
color: #86efac;
}
.kbx-status-tag--warning {
background: #92400e;
color: #fcd34d;
}
.kbx-status-tag--danger {
background: #7f1d1d;
color: #fca5a5;
}
.kbx-status-tag--muted {
background: #4b5563;
color: #d1d5db;
}
}
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
/**
* KBX Summary Bar — v60
* Display summary items
*/
import type { KbxSummaryItem } from '../contracts'
withDefaults(
defineProps<{
items: KbxSummaryItem[]
align?: 'start' | 'end'
}>(),
{
align: 'start',
}
)
</script>
<template>
<div class="kbx-summary-bar" :class="`kbx-summary-bar--${align}`">
<div v-for="item in items" :key="item.key" class="kbx-summary-bar__item" :class="{ 'is-emphasis': item.emphasis }">
<span class="kbx-summary-bar__label">{{ item.label }}</span>
<span class="kbx-summary-bar__value">{{ item.value }}</span>
</div>
</div>
</template>
<style scoped>
.kbx-summary-bar {
display: flex;
gap: 24px;
padding: 12px 16px;
border-top: 1px solid var(--kbx-color-border);
background: var(--kbx-color-surface);
}
.kbx-summary-bar--end {
justify-content: flex-end;
}
.kbx-summary-bar__item {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.kbx-summary-bar__label {
color: var(--kbx-color-text-muted);
font-weight: 500;
}
.kbx-summary-bar__value {
color: var(--kbx-color-text);
font-weight: 600;
}
.kbx-summary-bar__item.is-emphasis .kbx-summary-bar__value {
color: var(--kbx-color-primary);
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
.kbx-summary-bar {
background: #1f2937;
border-top-color: #374151;
}
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
/**
* KBX Tabs Component — v60 simplified
*/
export interface KbxTab {
id: string
label: string
disabled?: boolean
}
withDefaults(
defineProps<{
tabs: KbxTab[]
activeTab?: string
}>(),
{
activeTab: '',
}
)
defineEmits<{
'update:activeTab': [id: string]
}>()
</script>
<template>
<div class="kbx-tabs">
<div class="kbx-tabs__header" role="tablist">
<button
v-for="tab in tabs"
:key="tab.id"
:aria-selected="(activeTab || tabs[0]?.id) === tab.id"
:class="[
'kbx-tabs__tab',
{ 'is-active': (activeTab || tabs[0]?.id) === tab.id, 'is-disabled': tab.disabled },
]"
:disabled="tab.disabled"
@click="$emit('update:activeTab', tab.id)"
>
{{ tab.label }}
</button>
</div>
<div class="kbx-tabs__content">
<slot />
</div>
</div>
</template>
<style scoped>
.kbx-tabs {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-tabs__header {
display: flex;
gap: 0;
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-tabs__tab {
padding: 12px 16px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
color: var(--kbx-color-text-muted, #6b7280);
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
}
.kbx-tabs__tab:hover:not(:disabled) {
color: var(--kbx-color-text, #000);
}
.kbx-tabs__tab.is-active {
color: var(--kbx-color-primary, #3b82f6);
border-bottom-color: var(--kbx-color-primary, #3b82f6);
}
.kbx-tabs__tab.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kbx-tabs__content {
padding: 12px 0;
}
@media (prefers-color-scheme: dark) {
.kbx-tabs__tab {
color: #d1d5db;
}
.kbx-tabs__tab:hover:not(:disabled) {
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,140 @@
<script setup lang="ts">
/**
* KBX Template State Boundary — v60
* Manage loading, error, empty states
*/
import type { KbxAsyncState } from '../contracts'
withDefaults(
defineProps<{
state?: KbxAsyncState
refreshing?: boolean
errorRetryable?: boolean
idleActionLabel?: string
}>(),
{
state: 'ready',
refreshing: false,
errorRetryable: true,
}
)
defineEmits<{
retry: []
idleAction: []
}>()
</script>
<template>
<div class="kbx-state-boundary">
<div v-if="state === 'loading'" class="kbx-state-boundary__state">
<div class="kbx-state-boundary__spinner" />
<p>로드 ...</p>
</div>
<div v-else-if="state === 'error'" class="kbx-state-boundary__state kbx-state-boundary__state--error">
<p>오류가 발생했습니다</p>
<button v-if="errorRetryable" @click="$emit('retry')">
다시 시도
</button>
</div>
<div v-else-if="state === 'empty'" class="kbx-state-boundary__state">
<p>데이터가 없습니다</p>
<button v-if="idleActionLabel" @click="$emit('idleAction')">
{{ idleActionLabel }}
</button>
</div>
<div v-else-if="state === 'idle'" class="kbx-state-boundary__state">
<p>조회할 데이터를 선택하세요</p>
<button v-if="idleActionLabel" @click="$emit('idleAction')">
{{ idleActionLabel }}
</button>
</div>
<div v-else class="kbx-state-boundary__content">
<div v-if="refreshing" class="kbx-state-boundary__overlay">
새로고침 중...
</div>
<slot />
</div>
</div>
</template>
<style scoped>
.kbx-state-boundary {
position: relative;
min-height: 200px;
}
.kbx-state-boundary__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--kbx-color-text-muted);
gap: 12px;
}
.kbx-state-boundary__state--error {
color: var(--kbx-color-danger);
}
.kbx-state-boundary__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: var(--kbx-color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.kbx-state-boundary__content {
position: relative;
}
.kbx-state-boundary__overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--kbx-color-text-muted);
z-index: 10;
}
button {
padding: 8px 16px;
border: 1px solid var(--kbx-color-border);
border-radius: 4px;
background: var(--kbx-color-surface);
cursor: pointer;
font-size: 14px;
}
button:hover {
background: var(--kbx-color-border);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-color-scheme: dark) {
.kbx-state-boundary__overlay {
background: rgba(0, 0, 0, 0.5);
}
button {
background: #374151;
}
button:hover {
background: #4b5563;
}
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* KBX Textarea Component — v60
*/
withDefaults(
defineProps<{
modelValue: string
placeholder?: string
label?: string
error?: string
required?: boolean
readonly?: boolean
disabled?: boolean
rows?: number
}>(),
{
modelValue: '',
rows: 3,
}
)
defineEmits<{
'update:modelValue': [value: string]
blur: []
}>()
</script>
<template>
<div class="kbx-textarea-wrapper">
<label v-if="label" class="kbx-textarea__label">
{{ label }}
<span v-if="required" class="kbx-textarea__required">*</span>
</label>
<textarea
:value="modelValue"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:rows="rows"
:class="['kbx-textarea', { 'is-error': error }]"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('blur')"
/>
<div v-if="error" class="kbx-textarea__error">{{ error }}</div>
</div>
</template>
<style scoped>
.kbx-textarea-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.kbx-textarea__label {
font-size: 13px;
font-weight: 500;
color: var(--kbx-color-text, #000);
}
.kbx-textarea__required {
color: var(--kbx-color-danger, #ef4444);
margin-left: 2px;
}
.kbx-textarea {
padding: 10px 12px;
border: 1px solid var(--kbx-color-border, #e5e7eb);
border-radius: 4px;
font-size: 14px;
font-family: inherit;
background: var(--kbx-color-surface, #fff);
color: var(--kbx-color-text, #000);
resize: vertical;
}
.kbx-textarea:focus {
outline: none;
border-color: var(--kbx-color-primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.kbx-textarea:disabled {
background: var(--kbx-color-border, #e5e7eb);
opacity: 0.6;
}
.kbx-textarea.is-error {
border-color: var(--kbx-color-danger, #ef4444);
}
.kbx-textarea__error {
font-size: 12px;
color: var(--kbx-color-danger, #ef4444);
}
@media (prefers-color-scheme: dark) {
.kbx-textarea__label {
color: #f9fafb;
}
.kbx-textarea {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
/**
* KBX Transaction Template — v60 (T03)
* Header + Detail transaction (Order, Purchase, Inventory Move, etc.)
*
* v52 Anatomy:
* - headerTitle: Header 섹션 제목 (e.g., "주문 정보")
* - headerDescription: Header 설명
* - detailTitle: Detail 섹션 제목 (e.g., "주문 상품")
* - detailDescription: Detail 설명
* - detailCount: Detail 항목 수
*/
import type { KbxValidationError } from '../contracts'
import KbxSectionHeader from './KbxSectionHeader.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
withDefaults(
defineProps<{
headerTitle?: string
headerDescription?: string
detailTitle?: string
detailDescription?: string
detailCount?: number
detailCountUnit?: string
errors?: KbxValidationError[]
status?: string
dirty?: boolean
}>(),
{
headerTitle: '업무정보',
headerDescription: '',
detailTitle: '상세내역',
detailDescription: '',
detailCountUnit: '건',
errors: () => [],
status: '',
dirty: false,
}
)
defineEmits<{
command: [string]
}>()
</script>
<template>
<div class="kbx-transaction-template">
<!-- Status/Error Feedback -->
<KbxValidationSummary v-if="errors.length" :errors="errors" />
<!-- Header Section -->
<section class="kbx-transaction-template__header" :aria-label="headerTitle">
<KbxSectionHeader
:title="headerTitle"
:description="headerDescription"
>
<template #actions>
<slot name="header-actions" />
</template>
</KbxSectionHeader>
<div class="kbx-transaction-template__header-body">
<slot name="header" />
</div>
</section>
<!-- Detail Section -->
<section class="kbx-transaction-template__detail" :aria-label="detailTitle">
<KbxSectionHeader
:title="detailTitle"
:count="detailCount"
:count-unit="detailCountUnit"
:description="detailDescription"
>
<template #actions>
<slot name="detail-actions" />
</template>
</KbxSectionHeader>
<div class="kbx-transaction-template__detail-body">
<slot name="detail" />
</div>
</section>
<!-- Summary Section (Optional) -->
<section v-if="$slots.summary" class="kbx-transaction-template__summary">
<slot name="summary" />
</section>
</div>
</template>
<style scoped>
.kbx-transaction-template {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
overflow: auto;
}
.kbx-transaction-template__header,
.kbx-transaction-template__detail {
display: flex;
flex-direction: column;
border: 1px solid var(--kbx-color-border, #e5e7eb);
background: var(--kbx-color-surface, #fff);
border-radius: 4px;
overflow: hidden;
}
.kbx-transaction-template__header {
border-top: 3px solid var(--kbx-color-module-accent, #3b82f6);
}
.kbx-transaction-template__header :deep(.kbx-section-header),
.kbx-transaction-template__detail :deep(.kbx-section-header) {
background: var(--kbx-color-section-heading, #f9fafb);
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
}
.kbx-transaction-template__header-body,
.kbx-transaction-template__detail-body {
padding: 16px;
flex: 1;
overflow: auto;
}
.kbx-transaction-template__detail {
min-height: 200px;
}
.kbx-transaction-template__summary {
padding: 12px 16px;
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
background: var(--kbx-color-surface, #fff);
position: sticky;
bottom: 0;
z-index: 10;
}
@media (prefers-color-scheme: dark) {
.kbx-transaction-template__header,
.kbx-transaction-template__detail {
background: #1f2937;
border-color: #374151;
}
.kbx-transaction-template__header :deep(.kbx-section-header),
.kbx-transaction-template__detail :deep(.kbx-section-header) {
background: #111827;
}
.kbx-transaction-template__summary {
background: #1f2937;
border-top-color: #374151;
}
}
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
/**
* KBX Validation Summary — v60 simplified
* Display validation errors from forms
*/
import type { KbxValidationError } from '../contracts'
defineProps<{
errors: KbxValidationError[]
}>()
</script>
<template>
<div v-if="errors.length" class="kbx-validation-summary">
<div class="kbx-validation-summary__header">
<span class="kbx-validation-summary__icon"></span>
<h3>{{ errors.length }} 오류 발생</h3>
</div>
<ul class="kbx-validation-summary__list">
<li v-for="(error, idx) in errors" :key="idx" class="kbx-validation-summary__item">
<strong v-if="error.field">{{ error.field }}:</strong>
{{ error.message }}
</li>
</ul>
</div>
</template>
<style scoped>
.kbx-validation-summary {
background: var(--kbx-color-danger-light, #fee2e2);
border: 1px solid var(--kbx-color-danger, #ef4444);
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 12px;
}
.kbx-validation-summary__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.kbx-validation-summary__icon {
font-size: 18px;
}
.kbx-validation-summary__header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--kbx-color-danger, #ef4444);
}
.kbx-validation-summary__list {
margin: 0;
padding-left: 24px;
font-size: 13px;
color: var(--kbx-color-text, #000);
line-height: 1.6;
}
.kbx-validation-summary__item {
margin: 4px 0;
}
@media (prefers-color-scheme: dark) {
.kbx-validation-summary {
background: #7f1d1d;
border-color: #dc2626;
}
.kbx-validation-summary__header h3 {
color: #fca5a5;
}
.kbx-validation-summary__list {
color: #f9fafb;
}
}
</style>
+4
View File
@@ -0,0 +1,4 @@
/**
* Re-export contracts for UI components
*/
export * from '../contracts'
+50
View File
@@ -0,0 +1,50 @@
/**
* @kbx/ui — KBX Foundation v60 UI Components
* Phase 1 + Phase 2 + Phase 3.5 + Phase 4: Complete library
*/
// Core Components (Phase 1)
export { default as KbxSectionHeader } from './components/KbxSectionHeader.vue'
export { default as KbxValidationSummary } from './components/KbxValidationSummary.vue'
// Template Components (Phase 1)
export { default as KbxTransactionTemplate } from './components/KbxTransactionTemplate.vue'
export { default as KbxMasterTemplate } from './components/KbxMasterTemplate.vue'
export { default as KbxQueueTemplate } from './components/KbxQueueTemplate.vue'
export { default as KbxReconcileTemplate } from './components/KbxReconcileTemplate.vue'
// Basic Components (Phase 2)
export { default as KbxButton } from './components/KbxButton.vue'
export { default as KbxStatusTag } from './components/KbxStatusTag.vue'
// Form Fields (Phase 2)
export { default as KbxInput } from './components/KbxInput.vue'
export { default as KbxSelect } from './components/KbxSelect.vue'
export { default as KbxDateField } from './components/KbxDateField.vue'
export { default as KbxNumberField } from './components/KbxNumberField.vue'
export { default as KbxTextarea } from './components/KbxTextarea.vue'
export { default as KbxCheckbox } from './components/KbxCheckbox.vue'
// Specialized Fields (Phase 4)
export { default as KbxMoneyField } from './components/KbxMoneyField.vue'
export { default as KbxQuantityField } from './components/KbxQuantityField.vue'
export { default as KbxRadio } from './components/KbxRadio.vue'
// Form Layout (Phase 4)
export { default as KbxFormGrid } from './components/KbxFormGrid.vue'
export { default as KbxFormSection } from './components/KbxFormSection.vue'
// Composite Components (Phase 2)
export { default as KbxDataGrid } from './components/KbxDataGrid.vue'
export { default as KbxDialog } from './components/KbxDialog.vue'
export { default as KbxDrawer } from './components/KbxDrawer.vue'
export { default as KbxTabs } from './components/KbxTabs.vue'
export { default as KbxLookup } from './components/KbxLookup.vue'
// Wrapper Components (Phase 3.5)
export { default as KbxScreenFrame } from './components/KbxScreenFrame.vue'
export { default as KbxTemplateStateBoundary } from './components/KbxTemplateStateBoundary.vue'
export { default as KbxSummaryBar } from './components/KbxSummaryBar.vue'
// Re-export contracts for component usage
export * from './contracts'