V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,6 @@
{
"name": "@kbx/contracts",
"version": "0.1.0",
"type": "module",
"exports": { ".": "./src/index.ts" }
}
@@ -0,0 +1,65 @@
export type KbxAiCapability = 'explain' | 'suggest' | 'draft' | 'execute'
export interface KbxAiScreenContext {
screenId: string
screenVersion: string
entityId?: string
selectedIds?: string[]
filters?: Record<string, unknown>
allowedCapabilities: KbxAiCapability[]
}
export interface KbxAiEvidence {
label: string
sourceType: 'domain' | 'api' | 'audit' | 'document' | 'rule'
reference?: string
}
export interface KbxAiProposalChange {
field: string
label: string
before?: unknown
after?: unknown
}
export type KbxAiProposalValidationState = 'pending' | 'validated' | 'invalid' | 'stale'
export interface KbxAiProposalValidation {
state: KbxAiProposalValidationState
message?: string
validatedAt?: string
}
export interface KbxAiProposal {
id: string
type: string
title: string
explanation: string
capability: Exclude<KbxAiCapability, 'explain'>
targets?: { entityType: string; entityId: string }[]
proposedChanges?: KbxAiProposalChange[]
evidence?: KbxAiEvidence[]
confidence?: number
requiredPermission?: string
validation?: KbxAiProposalValidation
}
export interface KbxAiAskRequest {
question: string
context: KbxAiScreenContext
}
export interface KbxAiAnswerAction {
id: string
label: string
kind: 'navigate' | 'filter' | 'proposal'
requiredCapability?: KbxAiCapability
requiredPermission?: string
}
export interface KbxAiAnswer {
answer: string
actions?: KbxAiAnswerAction[]
proposal?: KbxAiProposal
evidence?: KbxAiEvidence[]
}
@@ -0,0 +1,24 @@
export type KbxHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export type KbxApiOperationKind = 'query' | 'command' | 'upload'
export type KbxIdempotencyPolicy = 'none' | 'supported' | 'required'
export interface KbxApiOperationDefinition {
id: string
method: KbxHttpMethod
path: string
permission?: string | null
kind: KbxApiOperationKind
idempotency: KbxIdempotencyPolicy
successStatuses: readonly number[]
contentType?: string
responseType?: 'json' | 'blob'
}
export interface KbxApiRequestOptions<TBody = unknown> {
path?: Record<string, string | number>
query?: object
body?: TBody
headers?: Record<string, string>
idempotencyKey?: string
responseType?: 'json' | 'blob'
}
@@ -0,0 +1,18 @@
export type KbxAuditSource = 'user' | 'system' | 'api' | 'import' | 'ai' | 'batch'
export interface KbxAuditEntry {
id: string
occurredAt: string
actor: {
type: KbxAuditSource
displayName: string
}
action: string
changes?: Array<{
field: string
label: string
before?: unknown
after?: unknown
}>
reason?: string
}
@@ -0,0 +1,33 @@
export type KbxPermissionRisk = 'low' | 'medium' | 'high'
export type KbxPermissionPresentation = 'hide' | 'disable'
export interface KbxPermissionDefinition {
id: string
module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
resource: string
action: string
risk: KbxPermissionRisk
description: string
presentation: KbxPermissionPresentation
backendRequired: boolean
}
export interface KbxSensitiveDataPolicy {
id: string
fields: string[]
defaultExposure: 'masked'
viewPermission: string
revealPermission: string
unmaskedExportPermission: string
auditRequired: boolean
aiExposure: 'masked-only' | 'deny'
telemetryExposure: 'never'
reasonRequired: boolean
}
export interface KbxAuthorizationDecision {
allowed: boolean
permission: string
presentation: KbxPermissionPresentation
reason?: string
}
@@ -0,0 +1,37 @@
import type { KbxDensity } from './preference'
export type KbxCatalogState =
| 'default'
| 'idle'
| 'ready'
| 'refreshing'
| 'readonly'
| 'disabled'
| 'required'
| 'error'
| 'loading'
| 'keyboard'
| 'changed'
| 'warning'
| 'ai-suggested'
| 'empty'
export interface KbxCatalogScenario {
id: string
label: string
state: KbxCatalogState
density?: KbxDensity
description?: string
}
export interface KbxComponentCatalogEntry {
component: string
group: 'input' | 'command' | 'grid' | 'feedback' | 'overlay' | 'template' | 'wms' | 'shell'
scenarios: KbxCatalogScenario[]
accessibility: {
keyboard: boolean
focusVisible: boolean
labelRequired?: boolean
colorIndependentStatus?: boolean
}
}
@@ -0,0 +1,26 @@
export type KbxCommandGroup = 'query' | 'edit' | 'workflow' | 'output' | 'more'
export type KbxCommandVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
export interface KbxCommandConfirmDefinition { title:string; detail:string; level?:'low'|'medium'|'high'; confirmLabel?:string }
export interface KbxCommandDefinition {
id: string
label: string
group: KbxCommandGroup
variant?: KbxCommandVariant
shortcut?: string
permission?: string
/** Optional state-specific authorization for one visual command (e.g. create vs update save). */
permissionByStatus?: Record<string, string>
icon?: string
requiresSelection?: boolean
minSelection?: number
maxSelection?: number
/** Keep the command visible but disabled outside these business states. */
allowedStatuses?: string[]
/** Save-like commands can be disabled until the page is dirty. */
requiresDirty?: boolean
/** Workflow commits can require the current record to be saved first. */
requiresClean?: boolean
disabledReason?: string
confirm?: KbxCommandConfirmDefinition
}
@@ -0,0 +1,34 @@
export type KbxConfigurationValueType =
| 'string'
| 'boolean'
| 'integer'
| 'enum'
| 'uri'
| 'connection-string'
export interface KbxConfigurationSettingDefinition {
key: string
env: string
category: string
type: KbxConfigurationValueType
allowedValues?: readonly string[]
default?: string | number | boolean
minimum?: number
maximum?: number
secret: boolean
restartRequired: boolean
requiredIn?: readonly string[]
requiredWhen?: { key: string; equals: string | number | boolean }
allowedSources?: readonly string[]
}
export interface KbxEnvironmentProfileDefinition {
id: 'Development' | 'Test' | 'Staging' | 'Production'
artifactPromotion: boolean
secretSources: readonly string[]
migrationStrategy: string
providerNetwork: 'forbidden' | 'opt-in'
requireHttps: boolean
providerEndpointOverrideAllowed: boolean
requiredGates: readonly string[]
}
@@ -0,0 +1,151 @@
import { getKbxField, type KbxKnownFieldKey } from './generated/fieldCatalog'
export type KbxImportStatus =
| 'created'
| 'uploaded'
| 'mapping-required'
| 'validating'
| 'validated'
| 'committing'
| 'completed'
| 'partially-completed'
| 'failed'
| 'cancelled'
export type KbxImportMappingSource = 'exact' | 'alias' | 'saved' | 'ai' | 'manual'
export interface KbxImportFieldDefinition {
key: string
label: string
aliases?: string[]
dataType:
| 'text'
| 'code'
| 'integer'
| 'decimal'
| 'quantity'
| 'money'
| 'date'
| 'datetime'
| 'boolean'
| 'lookup'
required?: boolean
maxLength?: number
precision?: number
scale?: number
lookupEntity?: string
importable?: boolean
exportable?: boolean
helpText?: string
sensitive?: boolean
masking?: 'name' | 'phone' | 'address' | 'partial'
}
export interface KbxImportDefinition {
id: string
screenId: string
entity: string
title: string
fields: KbxImportFieldDefinition[]
allowCreate: boolean
allowUpdate: boolean
maxFileSizeBytes?: number
maxRows?: number
}
export interface KbxImportMapping {
sourceColumn: string
targetField: string | null
source: KbxImportMappingSource
confidence?: number
reason?: string
}
export interface KbxImportValidationError {
rowNumber: number
field?: string
sourceColumn?: string
code: string
message: string
severity: 'error' | 'warning'
}
export interface KbxImportFailure {
code: string
title: string
detail?: string
recoverable?: boolean
}
export interface KbxImportSession {
id: string
importType: string
screenId: string
fileName: string
status: KbxImportStatus
totalRows: number
validRows: number
invalidRows: number
warningRows: number
createdRows: number
updatedRows: number
progressPercent: number
mapping: KbxImportMapping[]
sourceColumns: string[]
errors?: KbxImportValidationError[]
failure?: KbxImportFailure
createdAt: string
completedAt?: string
}
export interface KbxImportProgressEvent {
sessionId: string
status: KbxImportStatus
progressPercent: number
totalRows: number
processedRows: number
validRows: number
invalidRows: number
warningRows: number
message?: string
}
export interface KbxSavedImportMapping {
id: string
importType: string
name: string
mappings: KbxImportMapping[]
updatedAt: string
}
/**
* Materialize Excel metadata from the canonical KBX Field Dictionary.
* Screen/import definitions may select fields, but should not duplicate label/type/length/alias metadata.
*/
export function kbxImportField(
key: KbxKnownFieldKey,
overrides: Partial<KbxImportFieldDefinition> = {},
): KbxImportFieldDefinition {
const field = getKbxField(key) as any
if (!field.importable && overrides.importable !== true) {
throw new Error(`${key} is not importable in the KBX field dictionary.`)
}
return {
key,
label: field.label,
aliases: field.aliases ?? [],
dataType: field.dataType,
required: Boolean(field.required),
maxLength: field.maxLength,
precision: field.precision,
scale: field.scale,
lookupEntity: field.lookupEntity,
importable: Boolean(field.importable),
exportable: Boolean(field.exportable),
helpText: field.helpText,
sensitive: Boolean(field.sensitive),
masking: field.masking,
...overrides,
}
}
@@ -0,0 +1,36 @@
export type KbxExperimentRuntimeState = 'draft' | 'running' | 'paused' | 'rolled-back' | 'graduated' | 'completed'
export interface KbxExperimentAssignment {
experimentId: string
flagId: string
screenId: string
enrolled: boolean
variant: string
state: KbxExperimentRuntimeState
}
export interface KbxExperimentAssignmentsResponse { assignments: KbxExperimentAssignment[] }
export interface KbxExperimentVariantMetric {
variant: string
exposures: number
taskCompletions: number
interactionsPerTask?: number | null
taskCompletionP95Ms?: number | null
validationFailureRate?: number | null
}
export interface KbxExperimentOverviewRow {
experimentId: string
screenId: string
state: KbxExperimentRuntimeState
rolloutPercent: number
killSwitch: boolean
decision: 'inconclusive' | 'guardrail-breach' | 'candidate' | 'neutral' | 'rolled-back'
variants: KbxExperimentVariantMetric[]
updatedAt?: string | null
}
export interface KbxExperimentOverviewResponse { items: KbxExperimentOverviewRow[] }
export interface KbxExperimentRolloutRequest { rolloutPercent: number; state: 'running' | 'paused'; reason: string }
export interface KbxExperimentRollbackRequest { reason: string }
@@ -0,0 +1,67 @@
export type KbxExternalDataFreshnessMode = 'strict' | 'stale-while-revalidate' | 'provider-defined'
export type KbxExternalDataState = 'fresh' | 'stale' | 'expired' | 'unavailable'
export type KbxRawSnapshotRetention = 'none' | 'hash-only' | 'encrypted-raw'
export interface KbxExternalDataDatasetDefinition {
id: string
providerId: string
providerOperationId: string
canonicalType: string
normalizer: string
normalizerVersion: string
cacheKeyFields: readonly string[]
freshness: {
mode: KbxExternalDataFreshnessMode
freshForSeconds: number | null
maxStaleSeconds: number | null
backgroundRefresh: boolean
requireExplicitServicePolicy?: boolean
}
snapshot: {
rawRetention: KbxRawSnapshotRetention
normalizedRetentionDays: number
}
ui: {
sourceLabel: string
showProviderObservedAt: boolean
showReceivedAt: boolean
}
}
export interface KbxExternalDataProvenance {
datasetId: string
providerId: string
providerOperationId: string
sourceLabel: string
state: KbxExternalDataState
providerObservedAt?: string | null
requestedAt: string
receivedAt: string
ingestedAt: string
freshUntil?: string | null
usableUntil?: string | null
payloadSha256: string
normalizerVersion: string
cacheHit: boolean
refreshInProgress?: boolean
warning?: string
}
export interface KbxExternalDataEnvelope<T> {
data: T | null
provenance: KbxExternalDataProvenance
}
export interface KbxExternalDataStatusRow {
datasetId: string
providerId: string
sourceLabel: string
state: KbxExternalDataState
cacheEntries: number
lastReceivedAt?: string | null
oldestFreshUntil?: string | null
staleEntries: number
unavailableEntries: number
}
export interface KbxExternalDataStatusResponse { items: KbxExternalDataStatusRow[] }
@@ -0,0 +1,37 @@
import type { KbxLookupDefinition } from './lookup'
import type { KbxKnownFieldKey } from './generated/fieldCatalog'
export type KbxFieldDataType =
| 'text'
| 'code'
| 'integer'
| 'decimal'
| 'quantity'
| 'money'
| 'date'
| 'datetime'
| 'boolean'
| 'lookup'
| 'status'
export interface KbxFieldDefinition<T = unknown> {
/** Canonical fields should use KbxKnownFieldKey. string remains for local/read-model fields during staged adoption. */
key: KbxKnownFieldKey | (string & {})
label: string
aliases?: string[]
dataType: KbxFieldDataType
required?: boolean
maxLength?: number
precision?: number
scale?: number
readonly?: boolean
importable?: boolean
exportable?: boolean
sensitive?: boolean
masking?: 'name' | 'phone' | 'address' | 'partial'
deprecated?: boolean
replacementKey?: KbxKnownFieldKey
defaultValue?: T
lookup?: KbxLookupDefinition
helpText?: string
}
@@ -0,0 +1,75 @@
// Generated from contracts/api/kbx.api.json. Do not edit.
import type { KbxApiOperationDefinition } from '../api'
export const kbxApiSourceSha256 = '8a8008b0eca039ef22b703b142dd7fa2efeaf5b14784ba9f3a954f656a1a0c8f' as const
export const kbxApiCatalog = {
"common.ai.ask": {"id":"common.ai.ask","method":"POST","path":"/api/common/ai/ask","permission":"common.ai.use","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.experiments.assignments": {"id":"common.experiments.assignments","method":"GET","path":"/api/kbx/experiments/assignments","permission":"common.experiment.evaluate","kind":"query","idempotency":"none","successStatuses":[200]},
"common.experiments.overview": {"id":"common.experiments.overview","method":"GET","path":"/api/kbx/experiments","permission":"common.experiment.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.experiments.rollback": {"id":"common.experiments.rollback","method":"POST","path":"/api/kbx/experiments/{experimentId}/rollback","permission":"common.experiment.manage","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.experiments.rollout": {"id":"common.experiments.rollout","method":"POST","path":"/api/kbx/experiments/{experimentId}/rollout","permission":"common.experiment.manage","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.externalData.refresh": {"id":"common.externalData.refresh","method":"POST","path":"/api/kbx/external-data/{datasetId}/refresh","permission":"common.external-data.refresh","kind":"command","idempotency":"supported","successStatuses":[202]},
"common.externalData.status": {"id":"common.externalData.status","method":"GET","path":"/api/kbx/external-data/status","permission":"common.external-data.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.imports.commit": {"id":"common.imports.commit","method":"POST","path":"/api/imports/sessions/{sessionId:guid}/commit","permission":"imports.execute","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.imports.createSession": {"id":"common.imports.createSession","method":"POST","path":"/api/imports/sessions","permission":"imports.execute","kind":"upload","idempotency":"supported","successStatuses":[200],"contentType":"multipart/form-data"},
"common.imports.errorWorkbook": {"id":"common.imports.errorWorkbook","method":"GET","path":"/api/imports/sessions/{sessionId:guid}/errors.xlsx","permission":"imports.execute","kind":"query","idempotency":"none","successStatuses":[200],"responseType":"blob"},
"common.imports.getSession": {"id":"common.imports.getSession","method":"GET","path":"/api/imports/sessions/{sessionId:guid}","permission":"imports.execute","kind":"query","idempotency":"none","successStatuses":[200]},
"common.imports.saveMapping": {"id":"common.imports.saveMapping","method":"PUT","path":"/api/imports/sessions/{sessionId:guid}/mapping","permission":"imports.execute","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.imports.saveNamedMapping": {"id":"common.imports.saveNamedMapping","method":"POST","path":"/api/imports/sessions/{sessionId:guid}/saved-mappings","permission":"imports.execute","kind":"command","idempotency":"supported","successStatuses":[204]},
"common.imports.template": {"id":"common.imports.template","method":"GET","path":"/api/imports/{importType}/template","permission":"imports.execute","kind":"query","idempotency":"none","successStatuses":[200],"responseType":"blob"},
"common.imports.validate": {"id":"common.imports.validate","method":"POST","path":"/api/imports/sessions/{sessionId:guid}/validate","permission":"imports.execute","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.integrations.getAttempt": {"id":"common.integrations.getAttempt","method":"GET","path":"/api/integrations/attempts/{attemptId:guid}","permission":"common.integration.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.integrations.retryAttempt": {"id":"common.integrations.retryAttempt","method":"POST","path":"/api/integrations/attempts/{attemptId:guid}/retry","permission":"common.integration.retry","kind":"command","idempotency":"required","successStatuses":[200]},
"common.operations.claim": {"id":"common.operations.claim","method":"POST","path":"/api/operations/work-items/claim","permission":"common.operations.claim","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.operations.resolve": {"id":"common.operations.resolve","method":"POST","path":"/api/operations/work-items/resolve","permission":"common.operations.resolve","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.operations.retry": {"id":"common.operations.retry","method":"POST","path":"/api/operations/work-items/{id:guid}/retry","permission":"common.operations.retry","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.operations.search": {"id":"common.operations.search","method":"GET","path":"/api/operations/work-items","permission":"common.operations.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.reconcile.createExceptions": {"id":"common.reconcile.createExceptions","method":"POST","path":"/api/reconcile/items/create-exceptions","permission":"common.operations.create","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.reconcile.search": {"id":"common.reconcile.search","method":"GET","path":"/api/reconcile/items","permission":"common.reconcile.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.runtime.notice": {"id":"common.runtime.notice","method":"GET","path":"/api/kbx/runtime/notice","permission":"common.runtime.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.runtime.notificationRead": {"id":"common.runtime.notificationRead","method":"POST","path":"/api/kbx/runtime/notifications/{Id}/read","permission":"common.runtime.read","kind":"command","idempotency":"supported","successStatuses":[204]},
"common.runtime.notifications": {"id":"common.runtime.notifications","method":"GET","path":"/api/kbx/runtime/notifications","permission":"common.runtime.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.runtime.operations": {"id":"common.runtime.operations","method":"GET","path":"/api/kbx/runtime/operations","permission":"common.runtime.read","kind":"query","idempotency":"none","successStatuses":[200]},
"common.suggestions.submit": {"id":"common.suggestions.submit","method":"POST","path":"/api/common/suggestions","permission":"common.suggestion.create","kind":"command","idempotency":"supported","successStatuses":[200]},
"common.uxTelemetry.ingest": {"id":"common.uxTelemetry.ingest","method":"POST","path":"/api/kbx/ux/events","permission":"common.telemetry.write","kind":"command","idempotency":"none","successStatuses":[204]},
"common.uxTelemetry.metrics": {"id":"common.uxTelemetry.metrics","method":"GET","path":"/api/kbx/ux/metrics","permission":"common.ux.read","kind":"query","idempotency":"none","successStatuses":[200]},
"erp.inventory.history": {"id":"erp.inventory.history","method":"GET","path":"/api/erp/inventory/{itemId}/history","permission":"erp.inventory.read","kind":"query","idempotency":"none","successStatuses":[200]},
"erp.inventory.locations": {"id":"erp.inventory.locations","method":"GET","path":"/api/erp/inventory/{itemId}/locations","permission":"erp.inventory.read","kind":"query","idempotency":"none","successStatuses":[200]},
"erp.inventory.search": {"id":"erp.inventory.search","method":"GET","path":"/api/erp/inventory","permission":"erp.inventory.read","kind":"query","idempotency":"none","successStatuses":[200]},
"erp.itemPrices.bulkSave": {"id":"erp.itemPrices.bulkSave","method":"POST","path":"/api/erp/item-prices/bulk","permission":"erp.item.price.write","kind":"command","idempotency":"required","successStatuses":[200]},
"erp.items.get": {"id":"erp.items.get","method":"GET","path":"/api/erp/items/{id}","permission":"erp.item.read","kind":"query","idempotency":"none","successStatuses":[200]},
"erp.items.search": {"id":"erp.items.search","method":"GET","path":"/api/erp/items","permission":"erp.item.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.customers.resolveByCode": {"id":"lookup.customers.resolveByCode","method":"GET","path":"/api/lookups/customers/by-code/{code}","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.customers.resolveById": {"id":"lookup.customers.resolveById","method":"GET","path":"/api/lookups/customers/{id}","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.customers.search": {"id":"lookup.customers.search","method":"GET","path":"/api/lookups/customers","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.items.resolveByCode": {"id":"lookup.items.resolveByCode","method":"GET","path":"/api/lookups/items/by-code/{code}","permission":"erp.item.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.items.resolveById": {"id":"lookup.items.resolveById","method":"GET","path":"/api/lookups/items/{id}","permission":"erp.item.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.items.search": {"id":"lookup.items.search","method":"GET","path":"/api/lookups/items","permission":"erp.item.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.warehouses.resolveByCode": {"id":"lookup.warehouses.resolveByCode","method":"GET","path":"/api/lookups/warehouses/by-code/{code}","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.warehouses.resolveById": {"id":"lookup.warehouses.resolveById","method":"GET","path":"/api/lookups/warehouses/{id}","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"lookup.warehouses.search": {"id":"lookup.warehouses.search","method":"GET","path":"/api/lookups/warehouses","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"oms.claims.approve": {"id":"oms.claims.approve","method":"POST","path":"/api/oms/claims/{id:guid}/approve","permission":"oms.claim.approve","kind":"command","idempotency":"supported","successStatuses":[200]},
"oms.claims.complete": {"id":"oms.claims.complete","method":"POST","path":"/api/oms/claims/{id:guid}/complete","permission":"oms.claim.process","kind":"command","idempotency":"supported","successStatuses":[200]},
"oms.claims.hold": {"id":"oms.claims.hold","method":"POST","path":"/api/oms/claims/{id:guid}/hold","permission":"oms.claim.hold","kind":"command","idempotency":"supported","successStatuses":[200]},
"oms.claims.search": {"id":"oms.claims.search","method":"GET","path":"/api/oms/claims","permission":"oms.claim.read","kind":"query","idempotency":"none","successStatuses":[200]},
"oms.claims.start": {"id":"oms.claims.start","method":"POST","path":"/api/oms/claims/{id:guid}/start","permission":"oms.claim.process","kind":"command","idempotency":"supported","successStatuses":[200]},
"oms.orders.register": {"id":"oms.orders.register","method":"POST","path":"/api/oms/orders/register","permission":"oms.order.create","kind":"command","idempotency":"supported","successStatuses":[200]},
"oms.orders.search": {"id":"oms.orders.search","method":"GET","path":"/api/oms/orders","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"oms.orders.ship": {"id":"oms.orders.ship","method":"POST","path":"/api/oms/orders/ship","permission":"oms.order.ship","kind":"command","idempotency":"required","successStatuses":[200]},
"wms.picking.getTask": {"id":"wms.picking.getTask","method":"GET","path":"/api/wms/picking/tasks/{taskId:guid}","permission":"wms.picking.execute","kind":"query","idempotency":"none","successStatuses":[200]},
"wms.picking.reportException": {"id":"wms.picking.reportException","method":"POST","path":"/api/wms/picking/tasks/{taskId:guid}/exceptions","permission":"wms.picking.execute","kind":"command","idempotency":"required","successStatuses":[200]},
"wms.picking.scan": {"id":"wms.picking.scan","method":"POST","path":"/api/wms/picking/tasks/{taskId:guid}/scan","permission":"wms.picking.execute","kind":"command","idempotency":"required","successStatuses":[200,422]},
"wms.picking.setQuantity": {"id":"wms.picking.setQuantity","method":"POST","path":"/api/wms/picking/tasks/{taskId:guid}/quantity","permission":"wms.picking.execute","kind":"command","idempotency":"required","successStatuses":[200]},
"wms.picking.start": {"id":"wms.picking.start","method":"POST","path":"/api/wms/picking/tasks/{taskId:guid}/start","permission":"wms.picking.execute","kind":"command","idempotency":"supported","successStatuses":[200]},
"erp.items.create": {"id":"erp.items.create","method":"POST","path":"/api/erp/items","permission":"erp.item.create","kind":"command","idempotency":"none","successStatuses":[200]},
"erp.items.update": {"id":"erp.items.update","method":"PUT","path":"/api/erp/items/{id}","permission":"erp.item.write","kind":"command","idempotency":"none","successStatuses":[200]},
"erp.items.deactivate": {"id":"erp.items.deactivate","method":"POST","path":"/api/erp/items/{id}/deactivate","permission":"erp.item.write","kind":"command","idempotency":"none","successStatuses":[200]},
"erp.items.audit": {"id":"erp.items.audit","method":"GET","path":"/api/erp/items/{id}/audit","permission":"erp.item.read","kind":"query","idempotency":"none","successStatuses":[200]},
"oms.orders.get": {"id":"oms.orders.get","method":"GET","path":"/api/oms/orders/{id}","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"oms.orders.confirm": {"id":"oms.orders.confirm","method":"POST","path":"/api/oms/orders/{id}/confirm","permission":"oms.order.confirm","kind":"command","idempotency":"required","successStatuses":[200]},
"oms.orders.audit": {"id":"oms.orders.audit","method":"GET","path":"/api/oms/orders/{id}/audit","permission":"oms.order.read","kind":"query","idempotency":"none","successStatuses":[200]},
"oms.orders.update": {"id":"oms.orders.update","method":"PUT","path":"/api/oms/orders/{id}","permission":"oms.order.write","kind":"command","idempotency":"supported","successStatuses":[200]},
} as const satisfies Record<string, KbxApiOperationDefinition>
export type KbxApiOperationId = keyof typeof kbxApiCatalog
export type KbxApiOperation = (typeof kbxApiCatalog)[KbxApiOperationId]
@@ -0,0 +1,506 @@
// generated from contracts/configuration/kbx.configuration.json; do not edit.
import type { KbxConfigurationSettingDefinition, KbxEnvironmentProfileDefinition } from '../configuration'
export const kbxConfigurationSourceSha256='e09186d626b034a524b7cdf78f1e4bb6adbc112975b3baf4fd6194b1c1abd771' as const
export const kbxConfigurationCatalog={
"Kbx:Runtime:Environment": {
"key": "Kbx:Runtime:Environment",
"env": "KBX__Runtime__Environment",
"category": "runtime",
"type": "enum",
"allowedValues": [
"Development",
"Test",
"Staging",
"Production"
],
"default": "Development",
"secret": false,
"restartRequired": true,
"requiredIn": [
"Development",
"Test",
"Staging",
"Production"
]
},
"Kbx:Runtime:ReadOnly": {
"key": "Kbx:Runtime:ReadOnly",
"env": "KBX__Runtime__ReadOnly",
"category": "runtime",
"type": "boolean",
"default": false,
"secret": false,
"restartRequired": false,
"requiredIn": []
},
"ConnectionStrings:Main": {
"key": "ConnectionStrings:Main",
"env": "ConnectionStrings__Main",
"category": "database",
"type": "connection-string",
"secret": true,
"restartRequired": true,
"requiredIn": [
"Development",
"Test",
"Staging",
"Production"
],
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"Kbx:Database:MigrationsMode": {
"key": "Kbx:Database:MigrationsMode",
"env": "KBX__Database__MigrationsMode",
"category": "database",
"type": "enum",
"allowedValues": [
"validate",
"startup-apply",
"predeploy"
],
"default": "validate",
"secret": false,
"restartRequired": true,
"requiredIn": [
"Development",
"Test",
"Staging",
"Production"
]
},
"Kbx:Database:CommandTimeoutSeconds": {
"key": "Kbx:Database:CommandTimeoutSeconds",
"env": "KBX__Database__CommandTimeoutSeconds",
"category": "database",
"type": "integer",
"default": 30,
"minimum": 1,
"maximum": 300,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Hangfire:Enabled": {
"key": "Kbx:Hangfire:Enabled",
"env": "KBX__Hangfire__Enabled",
"category": "background-jobs",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Hangfire:WorkerCount": {
"key": "Kbx:Hangfire:WorkerCount",
"env": "KBX__Hangfire__WorkerCount",
"category": "background-jobs",
"type": "integer",
"default": 4,
"minimum": 1,
"maximum": 64,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:SignalR:Enabled": {
"key": "Kbx:SignalR:Enabled",
"env": "KBX__SignalR__Enabled",
"category": "realtime",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Outbox:DispatcherEnabled": {
"key": "Kbx:Outbox:DispatcherEnabled",
"env": "KBX__Outbox__DispatcherEnabled",
"category": "messaging",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Inbox:CleanupEnabled": {
"key": "Kbx:Inbox:CleanupEnabled",
"env": "KBX__Inbox__CleanupEnabled",
"category": "messaging",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Integration:DispatcherEnabled": {
"key": "Kbx:Integration:DispatcherEnabled",
"env": "KBX__Integration__DispatcherEnabled",
"category": "integration",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:ExternalData:RefreshEnabled": {
"key": "Kbx:ExternalData:RefreshEnabled",
"env": "KBX__ExternalData__RefreshEnabled",
"category": "external-data",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:ExternalData:ObservationRetentionEnabled": {
"key": "Kbx:ExternalData:ObservationRetentionEnabled",
"env": "KBX__ExternalData__ObservationRetentionEnabled",
"category": "external-data",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Experiments:Enabled": {
"key": "Kbx:Experiments:Enabled",
"env": "KBX__Experiments__Enabled",
"category": "experiments",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": false,
"requiredIn": []
},
"Kbx:Logging:MinimumLevel": {
"key": "Kbx:Logging:MinimumLevel",
"env": "KBX__Logging__MinimumLevel",
"category": "logging",
"type": "enum",
"allowedValues": [
"Debug",
"Information",
"Warning",
"Error"
],
"default": "Information",
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Logging:JsonConsoleEnabled": {
"key": "Kbx:Logging:JsonConsoleEnabled",
"env": "KBX__Logging__JsonConsoleEnabled",
"category": "logging",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Telemetry:Enabled": {
"key": "Kbx:Telemetry:Enabled",
"env": "KBX__Telemetry__Enabled",
"category": "observability",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Telemetry:OtlpEndpoint": {
"key": "Kbx:Telemetry:OtlpEndpoint",
"env": "KBX__Telemetry__OtlpEndpoint",
"category": "observability",
"type": "uri",
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Telegram:Enabled": {
"key": "Kbx:Telegram:Enabled",
"env": "KBX__Telegram__Enabled",
"category": "alerting",
"type": "boolean",
"default": false,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Telegram:BotToken": {
"key": "Kbx:Telegram:BotToken",
"env": "KBX__Telegram__BotToken",
"category": "alerting",
"type": "string",
"secret": true,
"restartRequired": true,
"requiredWhen": {
"key": "Kbx:Telegram:Enabled",
"equals": true
},
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"Kbx:Telegram:ChatId": {
"key": "Kbx:Telegram:ChatId",
"env": "KBX__Telegram__ChatId",
"category": "alerting",
"type": "string",
"secret": true,
"restartRequired": true,
"requiredWhen": {
"key": "Kbx:Telegram:Enabled",
"equals": true
},
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"ExternalProviders:Krx:Enabled": {
"key": "ExternalProviders:Krx:Enabled",
"env": "ExternalProviders__Krx__Enabled",
"category": "provider",
"type": "boolean",
"default": false,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"ExternalProviders:Krx:AuthKey": {
"key": "ExternalProviders:Krx:AuthKey",
"env": "ExternalProviders__Krx__AuthKey",
"category": "provider",
"type": "string",
"secret": true,
"restartRequired": true,
"requiredWhen": {
"key": "ExternalProviders:Krx:Enabled",
"equals": true
},
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"ExternalProviders:OpenDart:Enabled": {
"key": "ExternalProviders:OpenDart:Enabled",
"env": "ExternalProviders__OpenDart__Enabled",
"category": "provider",
"type": "boolean",
"default": false,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"ExternalProviders:OpenDart:ApiKey": {
"key": "ExternalProviders:OpenDart:ApiKey",
"env": "ExternalProviders__OpenDart__ApiKey",
"category": "provider",
"type": "string",
"secret": true,
"restartRequired": true,
"requiredWhen": {
"key": "ExternalProviders:OpenDart:Enabled",
"equals": true
},
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"ExternalProviders:Kis:Enabled": {
"key": "ExternalProviders:Kis:Enabled",
"env": "ExternalProviders__Kis__Enabled",
"category": "provider",
"type": "boolean",
"default": false,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"ExternalProviders:Kis:Environment": {
"key": "ExternalProviders:Kis:Environment",
"env": "ExternalProviders__Kis__Environment",
"category": "provider",
"type": "enum",
"allowedValues": [
"sandbox",
"production"
],
"default": "sandbox",
"secret": false,
"restartRequired": true,
"requiredWhen": {
"key": "ExternalProviders:Kis:Enabled",
"equals": true
}
},
"ExternalProviders:Kis:AppKey": {
"key": "ExternalProviders:Kis:AppKey",
"env": "ExternalProviders__Kis__AppKey",
"category": "provider",
"type": "string",
"secret": true,
"restartRequired": true,
"requiredWhen": {
"key": "ExternalProviders:Kis:Enabled",
"equals": true
},
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"ExternalProviders:Kis:AppSecret": {
"key": "ExternalProviders:Kis:AppSecret",
"env": "ExternalProviders__Kis__AppSecret",
"category": "provider",
"type": "string",
"secret": true,
"restartRequired": true,
"requiredWhen": {
"key": "ExternalProviders:Kis:Enabled",
"equals": true
},
"allowedSources": [
"environment",
"secret-store",
"user-secrets"
]
},
"Kbx:Security:RequireHttps": {
"key": "Kbx:Security:RequireHttps",
"env": "KBX__Security__RequireHttps",
"category": "security",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Health:ReadinessEnabled": {
"key": "Kbx:Health:ReadinessEnabled",
"env": "KBX__Health__ReadinessEnabled",
"category": "runtime",
"type": "boolean",
"default": true,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Import:MaxUploadBytes": {
"key": "Kbx:Import:MaxUploadBytes",
"env": "KBX__Import__MaxUploadBytes",
"category": "import",
"type": "integer",
"default": 52428800,
"minimum": 1048576,
"maximum": 209715200,
"secret": false,
"restartRequired": true,
"requiredIn": []
},
"Kbx:Import:MaxRows": {
"key": "Kbx:Import:MaxRows",
"env": "KBX__Import__MaxRows",
"category": "import",
"type": "integer",
"default": 100000,
"minimum": 1,
"maximum": 1000000,
"secret": false,
"restartRequired": true,
"requiredIn": []
}
} as const satisfies Record<string,KbxConfigurationSettingDefinition>
export const kbxEnvironmentProfiles={
"Development": {
"id": "Development",
"artifactPromotion": false,
"secretSources": [
"user-secrets",
"environment"
],
"migrationStrategy": "startup-apply-allowed",
"providerNetwork": "opt-in",
"requireHttps": false,
"providerEndpointOverrideAllowed": true,
"requiredGates": [
"static-governance"
]
},
"Test": {
"id": "Test",
"artifactPromotion": false,
"secretSources": [
"environment"
],
"migrationStrategy": "ephemeral-apply",
"providerNetwork": "forbidden",
"requireHttps": false,
"providerEndpointOverrideAllowed": true,
"requiredGates": [
"static-governance",
"build",
"unit",
"integration",
"scenario"
]
},
"Staging": {
"id": "Staging",
"artifactPromotion": true,
"secretSources": [
"secret-store",
"environment"
],
"migrationStrategy": "predeploy",
"providerNetwork": "opt-in",
"requireHttps": true,
"providerEndpointOverrideAllowed": false,
"requiredGates": [
"static-governance",
"build",
"unit",
"integration",
"scenario",
"migration-dry-run"
]
},
"Production": {
"id": "Production",
"artifactPromotion": true,
"secretSources": [
"secret-store",
"environment"
],
"migrationStrategy": "predeploy",
"providerNetwork": "opt-in",
"requireHttps": true,
"providerEndpointOverrideAllowed": false,
"requiredGates": [
"static-governance",
"build",
"unit",
"integration",
"scenario",
"migration-dry-run",
"configuration-validation",
"release-governance"
]
}
} as const satisfies Record<string,KbxEnvironmentProfileDefinition>
export type KbxConfigurationKey=keyof typeof kbxConfigurationCatalog
export type KbxEnvironmentProfile=keyof typeof kbxEnvironmentProfiles
@@ -0,0 +1,59 @@
// generated from contracts/experiments/kbx.experiments.json; do not edit.
export const kbxFeatureFlags = {
"oms.order-list.exception-summary-v2": {
"id": "oms.order-list.exception-summary-v2",
"screenId": "OMS-ORD-001",
"surface": "information-emphasis",
"description": "기존 주문 Quick Filter를 이동시키지 않고 확인 필요 예외 요약을 보조적으로 노출하는 되돌릴 수 있는 UX 변경",
"defaultVariant": "control",
"killSwitch": true,
"owner": "KBX"
}
} as const
export const kbxExperiments = {
"exp.oms.order-list.exception-summary-v2": {
"id": "exp.oms.order-list.exception-summary-v2",
"flagId": "oms.order-list.exception-summary-v2",
"screenId": "OMS-ORD-001",
"state": "draft",
"rolloutPercent": 0,
"minExposurePerVariant": 200,
"variants": [
{
"key": "control",
"weight": 50
},
{
"key": "exception-summary",
"weight": 50
}
],
"primaryMetric": {
"key": "semantic_interactions_per_task",
"direction": "lower",
"minimumImprovementPercent": 10
},
"guardrails": [
{
"key": "task_completion_time_ms",
"statistic": "p95",
"maxRegressionPercent": 20
},
{
"key": "validation_failure_rate",
"statistic": "rate",
"maxAbsoluteRegressionPercentagePoints": 2
}
],
"globalGuardrails": [
{
"key": "manual_intervention_rate",
"maxAbsoluteRegressionPercentagePoints": 0.5
}
],
"notes": "Reference experiment only. v17 ships at draft/0%; rollout requires an explicit runtime decision and can be killed immediately."
}
} as const
export type KbxFeatureFlagId = keyof typeof kbxFeatureFlags
export type KbxExperimentId = keyof typeof kbxExperiments
export const kbxExperimentSourceSha256 = '2510a2927bab297858e09e75c8af9bb766d91b92cc0daf99433f6377d399fbf9' as const
@@ -0,0 +1,118 @@
// generated from contracts/external-data/kbx.external-data.json; do not edit.
import type { KbxExternalDataDatasetDefinition } from '../externalData'
export const kbxExternalDataSourceSha256='da54ff739c6dc5ed6f9390d1fc8a8e138060d7cf19531a80cc91e421d62954a4' as const
export const kbxExternalDataCatalog={
"dataset.opendart.company-profile": {
"id": "dataset.opendart.company-profile",
"providerId": "provider.opendart",
"providerOperationId": "opendart.company",
"canonicalType": "company-profile",
"normalizer": "OpenDartCompanyProfileNormalizer",
"normalizerVersion": "1.0.0",
"cacheKeyFields": [
"corpCode"
],
"freshness": {
"mode": "stale-while-revalidate",
"freshForSeconds": 86400,
"maxStaleSeconds": 604800,
"backgroundRefresh": true
},
"snapshot": {
"rawRetention": "hash-only",
"normalizedRetentionDays": 365
},
"ui": {
"sourceLabel": "OPENDART",
"showProviderObservedAt": false,
"showReceivedAt": true
}
},
"dataset.opendart.disclosures": {
"id": "dataset.opendart.disclosures",
"providerId": "provider.opendart",
"providerOperationId": "opendart.disclosures",
"canonicalType": "disclosure-list",
"normalizer": "OpenDartDisclosureListNormalizer",
"normalizerVersion": "1.0.0",
"cacheKeyFields": [
"corpCode",
"beginDate",
"endDate",
"pageNo",
"pageCount"
],
"freshness": {
"mode": "stale-while-revalidate",
"freshForSeconds": 300,
"maxStaleSeconds": 3600,
"backgroundRefresh": true
},
"snapshot": {
"rawRetention": "hash-only",
"normalizedRetentionDays": 90
},
"ui": {
"sourceLabel": "OPENDART",
"showProviderObservedAt": false,
"showReceivedAt": true
}
},
"dataset.kis.domestic-stock.current-price": {
"id": "dataset.kis.domestic-stock.current-price",
"providerId": "provider.kis.market-data",
"providerOperationId": "kis.domestic-stock.current-price",
"canonicalType": "market-price",
"normalizer": "KisCurrentPriceNormalizer",
"normalizerVersion": "1.0.0",
"cacheKeyFields": [
"marketDivisionCode",
"stockCode",
"environment"
],
"freshness": {
"mode": "strict",
"freshForSeconds": 3,
"maxStaleSeconds": 0,
"backgroundRefresh": false
},
"snapshot": {
"rawRetention": "hash-only",
"normalizedRetentionDays": 7
},
"ui": {
"sourceLabel": "KIS",
"showProviderObservedAt": false,
"showReceivedAt": true
}
},
"dataset.krx.approved-service": {
"id": "dataset.krx.approved-service",
"providerId": "provider.krx.openapi",
"providerOperationId": "krx.approved-service.invoke",
"canonicalType": "provider-defined",
"normalizer": "registered-per-approved-service",
"normalizerVersion": "provider-defined",
"cacheKeyFields": [
"serviceId",
"querySignature"
],
"freshness": {
"mode": "provider-defined",
"freshForSeconds": null,
"maxStaleSeconds": null,
"backgroundRefresh": false,
"requireExplicitServicePolicy": true
},
"snapshot": {
"rawRetention": "hash-only",
"normalizedRetentionDays": 30
},
"ui": {
"sourceLabel": "KRX",
"showProviderObservedAt": false,
"showReceivedAt": true
}
}
} as const satisfies Record<string,KbxExternalDataDatasetDefinition>
export type KbxExternalDataDatasetId=keyof typeof kbxExternalDataCatalog
@@ -0,0 +1,66 @@
// GENERATED FILE. DO NOT EDIT.
// Source: contracts/fields/kbx.fields.json
// Source SHA256: f76e073cd8052e9fddb25cc73322686c889822a22fab712ff1a25bef29b32e3c
export const kbxFieldDictionaryVersion = "1.1.0" as const
export const kbxFieldCatalog = {
"address1": {"key":"address1","label":"주소","aliases":["기본주소","배송주소","Address1"],"dataType":"text","required":true,"maxLength":500,"importable":true,"exportable":true,"sensitive":true,"masking":"address"},
"address2": {"key":"address2","label":"상세주소","aliases":["주소2","Address2"],"dataType":"text","maxLength":500,"importable":true,"exportable":true,"sensitive":true,"masking":"address"},
"allocatedQty": {"key":"allocatedQty","label":"할당수량","aliases":["AllocatedQty"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"amount": {"key":"amount","label":"금액","aliases":["Amount","주문금액"],"dataType":"money","precision":18,"scale":2,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"availableQty": {"key":"availableQty","label":"가용재고","aliases":["AvailableQty","출고가능수량"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"barcode": {"key":"barcode","label":"바코드","aliases":["Barcode","EAN","JAN"],"dataType":"code","maxLength":100,"lookupEntity":"item","importable":true,"exportable":true,"sensitive":false},
"channelId": {"key":"channelId","label":"판매채널ID","aliases":["ChannelId"],"dataType":"lookup","lookupEntity":"salesChannel","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"channelName": {"key":"channelName","label":"판매채널","aliases":["ChannelName","채널"],"dataType":"text","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"createdAt": {"key":"createdAt","label":"등록일시","aliases":["CreatedAt"],"dataType":"datetime","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"createdBy": {"key":"createdBy","label":"등록자","aliases":["CreatedBy"],"dataType":"text","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"customerCode": {"key":"customerCode","label":"거래처코드","aliases":["거래처","업체코드","CustomerCode"],"dataType":"code","required":true,"maxLength":50,"lookupEntity":"customer","importable":true,"exportable":true,"sensitive":false},
"customerId": {"key":"customerId","label":"거래처ID","aliases":["CustomerId"],"dataType":"lookup","lookupEntity":"customer","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"customerName": {"key":"customerName","label":"거래처명","aliases":["CustomerName","업체명"],"dataType":"text","maxLength":200,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"damagedQty": {"key":"damagedQty","label":"불량수량","aliases":["DamagedQty"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"effectiveDate": {"key":"effectiveDate","label":"적용일","aliases":["EffectiveDate","시작일","적용시작일"],"dataType":"date","required":true,"importable":true,"exportable":true,"sensitive":false},
"exceptionCount": {"key":"exceptionCount","label":"예외건수","aliases":["ExceptionCount","오류건수"],"dataType":"integer","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"expiryDate": {"key":"expiryDate","label":"유통기한","aliases":["ExpiryDate","ExpirationDate"],"dataType":"date","importable":true,"exportable":true,"sensitive":false},
"holdQty": {"key":"holdQty","label":"보류수량","aliases":["HoldQty"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"itemCode": {"key":"itemCode","label":"품목코드","aliases":["상품코드","SKU","ItemCode"],"dataType":"code","required":true,"maxLength":80,"lookupEntity":"item","importable":true,"exportable":true,"sensitive":false},
"itemId": {"key":"itemId","label":"품목ID","aliases":["ItemId"],"dataType":"lookup","lookupEntity":"item","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"itemName": {"key":"itemName","label":"품목명","aliases":["상품명","ItemName"],"dataType":"text","maxLength":300,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"itemSummary": {"key":"itemSummary","label":"대표상품","aliases":["ItemSummary"],"dataType":"text","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"locationCode": {"key":"locationCode","label":"로케이션","aliases":["Location","LocationCode","위치"],"dataType":"code","maxLength":80,"lookupEntity":"location","importable":true,"exportable":true,"sensitive":false},
"locationId": {"key":"locationId","label":"로케이션ID","aliases":["LocationId"],"dataType":"lookup","lookupEntity":"location","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"lotNo": {"key":"lotNo","label":"LOT","aliases":["LotNo","LOT번호"],"dataType":"code","maxLength":100,"importable":true,"exportable":true,"sensitive":false},
"occurredAt": {"key":"occurredAt","label":"발생시각","aliases":["OccurredAt"],"dataType":"datetime","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"onHandQty": {"key":"onHandQty","label":"현재고","aliases":["OnHandQty","장부재고"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"orderDate": {"key":"orderDate","label":"주문일","aliases":["주문일자","OrderDate"],"dataType":"date","required":true,"importable":true,"exportable":true,"sensitive":false},
"orderedAt": {"key":"orderedAt","label":"주문일시","aliases":["OrderedAt","주문시간"],"dataType":"datetime","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"orderId": {"key":"orderId","label":"주문ID","aliases":["OrderId"],"dataType":"code","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"orderNo": {"key":"orderNo","label":"주문번호","aliases":["OrderNo","주문ID"],"dataType":"code","required":true,"maxLength":50,"importable":true,"exportable":true,"sensitive":false},
"orderQty": {"key":"orderQty","label":"주문수량","aliases":["주문수량","수량","Qty","OrderQty"],"dataType":"quantity","precision":18,"scale":4,"importable":true,"exportable":true,"sensitive":false,"required":true},
"ownerName": {"key":"ownerName","label":"담당자","aliases":["OwnerName","담당"],"dataType":"text","maxLength":100,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"phone": {"key":"phone","label":"연락처","aliases":["휴대폰","전화번호","Phone"],"dataType":"text","required":true,"maxLength":50,"importable":true,"exportable":true,"sensitive":true,"masking":"phone"},
"pickedQty": {"key":"pickedQty","label":"피킹수량","aliases":["PickedQty"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"postalCode": {"key":"postalCode","label":"우편번호","aliases":["ZipCode","PostalCode"],"dataType":"text","maxLength":20,"importable":true,"exportable":true,"sensitive":true,"masking":"address"},
"quantity": {"key":"quantity","label":"수량","aliases":["Qty"],"dataType":"quantity","precision":18,"scale":4,"importable":false,"exportable":false,"sensitive":false},
"receiverName": {"key":"receiverName","label":"수취인","aliases":["받는분","수령인","ReceiverName"],"dataType":"text","required":true,"maxLength":100,"importable":true,"exportable":true,"sensitive":true,"masking":"name"},
"referenceNo": {"key":"referenceNo","label":"참조번호","aliases":["ReferenceNo"],"dataType":"code","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"remark": {"key":"remark","label":"비고","aliases":["메모","배송메모","Remark"],"dataType":"text","maxLength":500,"importable":true,"exportable":true,"sensitive":false},
"shippedQty": {"key":"shippedQty","label":"출고수량","aliases":["ShippedQty"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"specification": {"key":"specification","label":"규격","aliases":["Specification","Spec"],"dataType":"text","maxLength":200,"importable":true,"exportable":true,"sensitive":false},
"status": {"key":"status","label":"상태","aliases":["Status"],"dataType":"status","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"totalQty": {"key":"totalQty","label":"총수량","aliases":["TotalQty"],"dataType":"quantity","precision":18,"scale":4,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
"unitPrice": {"key":"unitPrice","label":"단가","aliases":["판매단가","UnitPrice"],"dataType":"money","precision":18,"scale":4,"importable":true,"exportable":true,"sensitive":false},
"updatedAt": {"key":"updatedAt","label":"수정일시","aliases":["UpdatedAt"],"dataType":"datetime","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"updatedBy": {"key":"updatedBy","label":"수정자","aliases":["UpdatedBy"],"dataType":"text","readonly":true,"importable":false,"exportable":true,"sensitive":false},
"version": {"key":"version","label":"버전","aliases":["Version","RowVersion"],"dataType":"integer","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"warehouseCode": {"key":"warehouseCode","label":"창고코드","aliases":["창고","창고코드","출고창고","WarehouseCode"],"dataType":"code","required":true,"maxLength":50,"lookupEntity":"warehouse","importable":true,"exportable":true,"sensitive":false},
"warehouseId": {"key":"warehouseId","label":"창고ID","aliases":["WarehouseId"],"dataType":"lookup","lookupEntity":"warehouse","readonly":true,"importable":false,"exportable":false,"sensitive":false},
"warehouseName": {"key":"warehouseName","label":"창고명","aliases":["WarehouseName"],"dataType":"text","maxLength":200,"readonly":true,"importable":false,"exportable":true,"sensitive":false},
} as const
export type KbxKnownFieldKey = keyof typeof kbxFieldCatalog
export type KbxKnownFieldDefinition = (typeof kbxFieldCatalog)[KbxKnownFieldKey]
export function getKbxField<K extends KbxKnownFieldKey>(key: K): (typeof kbxFieldCatalog)[K] {
return kbxFieldCatalog[key]
}
@@ -0,0 +1,175 @@
// generated from contracts/integrations/kbx.integrations.json; do not edit.
import type { KbxIntegrationDefinition } from '../integration'
export const kbxIntegrationSourceSha256 = '607853e43ab35fdacc1adea7d5b470dfad791f6718afba215b5600c974b0642e' as const
export const kbxIntegrationCatalog = {
"integration.oms.wms.dispatch": {
"id": "integration.oms.wms.dispatch",
"title": "OMS → WMS 출고지시 전달",
"ownerModule": "OMS",
"direction": "outbound",
"transport": "outbox-http",
"criticality": "high",
"sourceEvent": "OrderShipmentRequested",
"target": "WMS",
"delivery": "at-least-once",
"ordering": "per-aggregate",
"idempotency": "event-id",
"timeoutMs": 3000,
"shortRetry": {
"maxRetryAttempts": 2,
"baseDelayMs": 250,
"backoff": "exponential"
},
"longRetry": {
"scheduler": "hangfire",
"maxAttempts": 8,
"scheduleSeconds": [
10,
30,
120,
300,
900,
1800,
3600,
7200
]
},
"circuitBreaker": {
"failureRatio": 0.5,
"samplingSeconds": 30,
"minimumThroughput": 10,
"breakSeconds": 30
},
"retryable": [
"network",
"timeout",
"http-408",
"http-429",
"http-5xx",
"circuit-open"
],
"permanent": [
"contract-invalid",
"http-400",
"http-401",
"http-403",
"http-404",
"domain-rejected"
],
"terminalAction": "operations-exception",
"userVisible": true
},
"integration.wms.oms.picking-result": {
"id": "integration.wms.oms.picking-result",
"title": "WMS → OMS 피킹결과 전달",
"ownerModule": "WMS",
"direction": "outbound",
"transport": "outbox-http",
"criticality": "high",
"sourceEvent": "PickingCompleted",
"target": "OMS",
"delivery": "at-least-once",
"ordering": "per-aggregate",
"idempotency": "event-id",
"timeoutMs": 3000,
"shortRetry": {
"maxRetryAttempts": 2,
"baseDelayMs": 250,
"backoff": "exponential"
},
"longRetry": {
"scheduler": "hangfire",
"maxAttempts": 8,
"scheduleSeconds": [
10,
30,
120,
300,
900,
1800,
3600,
7200
]
},
"circuitBreaker": {
"failureRatio": 0.5,
"samplingSeconds": 30,
"minimumThroughput": 10,
"breakSeconds": 30
},
"retryable": [
"network",
"timeout",
"http-408",
"http-429",
"http-5xx",
"circuit-open"
],
"permanent": [
"contract-invalid",
"http-400",
"http-401",
"http-403",
"http-404",
"domain-rejected"
],
"terminalAction": "operations-exception",
"userVisible": true
},
"integration.carrier.tracking": {
"id": "integration.carrier.tracking",
"title": "배송사 송장/배송상태 연계",
"ownerModule": "OMS",
"direction": "outbound",
"transport": "outbox-http",
"criticality": "medium",
"sourceEvent": "TrackingSubmissionRequested",
"target": "CarrierGateway",
"delivery": "at-least-once",
"ordering": "none",
"idempotency": "business-key",
"timeoutMs": 5000,
"shortRetry": {
"maxRetryAttempts": 2,
"baseDelayMs": 500,
"backoff": "exponential"
},
"longRetry": {
"scheduler": "hangfire",
"maxAttempts": 6,
"scheduleSeconds": [
30,
120,
600,
1800,
3600,
7200
]
},
"circuitBreaker": {
"failureRatio": 0.5,
"samplingSeconds": 60,
"minimumThroughput": 10,
"breakSeconds": 60
},
"retryable": [
"network",
"timeout",
"http-408",
"http-429",
"http-5xx",
"circuit-open"
],
"permanent": [
"contract-invalid",
"http-400",
"http-401",
"http-403",
"http-404",
"domain-rejected"
],
"terminalAction": "operations-exception",
"userVisible": true
}
} as const satisfies Record<string,KbxIntegrationDefinition>
export type KbxIntegrationId = keyof typeof kbxIntegrationCatalog
@@ -0,0 +1,574 @@
// generated from contracts/authorization/kbx.authorization.json
export const kbxPermissionCatalog = [
{
"id": "common.ai.use",
"module": "COMMON",
"resource": "ai",
"action": "use",
"risk": "medium",
"description": "AI 설명·추천·초안 기능 사용",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.ai.execute",
"module": "COMMON",
"resource": "ai",
"action": "execute",
"risk": "high",
"description": "AI 승인 제안의 실제 Command 실행 허용",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.operations.claim",
"module": "COMMON",
"resource": "operations",
"action": "claim",
"risk": "medium",
"description": "업무 예외를 내 처리건으로 지정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.operations.create",
"module": "COMMON",
"resource": "operations",
"action": "create",
"risk": "medium",
"description": "대사 결과에서 운영 예외 생성",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.operations.read",
"module": "COMMON",
"resource": "operations",
"action": "read",
"risk": "low",
"description": "업무 예외 센터 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.operations.resolve",
"module": "COMMON",
"resource": "operations",
"action": "resolve",
"risk": "high",
"description": "수동 해결이 허용된 운영 예외 해결",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.operations.retry",
"module": "COMMON",
"resource": "operations",
"action": "retry",
"risk": "high",
"description": "등록된 안전한 예외 Action 재처리",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.reconcile.read",
"module": "COMMON",
"resource": "reconcile",
"action": "read",
"risk": "low",
"description": "업무 데이터 대사 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.runtime.read",
"module": "COMMON",
"resource": "runtime",
"action": "read",
"risk": "low",
"description": "런타임 상태·작업·알림 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.suggestion.create",
"module": "COMMON",
"resource": "suggestion",
"action": "create",
"risk": "low",
"description": "현재 화면 사용자 제안 등록",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.inventory.move.confirm",
"module": "ERP",
"resource": "inventory-move",
"action": "confirm",
"risk": "high",
"description": "재고이동 확정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.inventory.move.read",
"module": "ERP",
"resource": "inventory-move",
"action": "read",
"risk": "low",
"description": "재고이동 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "erp.inventory.move.receive",
"module": "ERP",
"resource": "inventory-move",
"action": "receive",
"risk": "high",
"description": "이동재고 입고완료",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.inventory.move.ship",
"module": "ERP",
"resource": "inventory-move",
"action": "ship",
"risk": "high",
"description": "이동재고 출고",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.inventory.move.write",
"module": "ERP",
"resource": "inventory-move",
"action": "write",
"risk": "medium",
"description": "재고이동 작성·수정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.inventory.read",
"module": "ERP",
"resource": "inventory",
"action": "read",
"risk": "low",
"description": "재고현황 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "erp.item.price.read",
"module": "ERP",
"resource": "item-price",
"action": "read",
"risk": "low",
"description": "품목 단가 일괄등록 화면 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "erp.item.price.write",
"module": "ERP",
"resource": "item-price",
"action": "write",
"risk": "medium",
"description": "품목 단가 일괄등록·수정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.item.create",
"module": "ERP",
"resource": "item",
"action": "create",
"risk": "medium",
"description": "품목 신규·복사",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.item.read",
"module": "ERP",
"resource": "item",
"action": "read",
"risk": "low",
"description": "품목 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "erp.item.write",
"module": "ERP",
"resource": "item",
"action": "write",
"risk": "medium",
"description": "품목 수정·사용중지",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.purchase.cancel",
"module": "ERP",
"resource": "purchase",
"action": "cancel",
"risk": "high",
"description": "구매 취소",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.purchase.confirm",
"module": "ERP",
"resource": "purchase",
"action": "confirm",
"risk": "high",
"description": "구매 확정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "erp.purchase.read",
"module": "ERP",
"resource": "purchase",
"action": "read",
"risk": "low",
"description": "구매 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "erp.purchase.write",
"module": "ERP",
"resource": "purchase",
"action": "write",
"risk": "medium",
"description": "구매 작성·수정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "imports.execute",
"module": "COMMON",
"resource": "imports",
"action": "execute",
"risk": "high",
"description": "Excel Import 생성·검증·반영",
"presentation": "disable",
"backendRequired": true
},
{
"id": "kbx.design.read",
"module": "COMMON",
"resource": "design-system",
"action": "read",
"risk": "low",
"description": "KBX 내부 컴포넌트 카탈로그 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "oms.claim.approve",
"module": "OMS",
"resource": "claim",
"action": "approve",
"risk": "high",
"description": "클레임 승인",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.claim.hold",
"module": "OMS",
"resource": "claim",
"action": "hold",
"risk": "medium",
"description": "클레임 보류",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.claim.process",
"module": "OMS",
"resource": "claim",
"action": "process",
"risk": "high",
"description": "클레임 처리 시작·완료",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.claim.read",
"module": "OMS",
"resource": "claim",
"action": "read",
"risk": "low",
"description": "반품·클레임 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "oms.order.create",
"module": "OMS",
"resource": "order",
"action": "create",
"risk": "medium",
"description": "주문 신규 등록",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.order.import",
"module": "OMS",
"resource": "order",
"action": "import",
"risk": "high",
"description": "주문 Excel Import 실행",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.order.read",
"module": "OMS",
"resource": "order",
"action": "read",
"risk": "low",
"description": "주문 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "oms.order.ship",
"module": "OMS",
"resource": "order",
"action": "ship",
"risk": "high",
"description": "주문 출고지시",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.order.recipient.unmask",
"module": "OMS",
"resource": "order-recipient",
"action": "sensitive-read",
"risk": "high",
"description": "주문 수취인 개인정보 전체보기",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.order.recipient.export",
"module": "OMS",
"resource": "order-recipient",
"action": "sensitive-export",
"risk": "high",
"description": "주문 수취인 개인정보 비마스킹 Export",
"presentation": "disable",
"backendRequired": true
},
{
"id": "wms.inventory.count",
"module": "WMS",
"resource": "inventory",
"action": "count",
"risk": "high",
"description": "현장 재고실사 실행",
"presentation": "hide",
"backendRequired": true
},
{
"id": "wms.picking.execute",
"module": "WMS",
"resource": "picking",
"action": "execute",
"risk": "high",
"description": "출고 피킹 작업 실행",
"presentation": "hide",
"backendRequired": true
},
{
"id": "wms.putaway.execute",
"module": "WMS",
"resource": "putaway",
"action": "execute",
"risk": "high",
"description": "입고 적치 작업 실행",
"presentation": "hide",
"backendRequired": true
},
{
"id": "wms.receiving.execute",
"module": "WMS",
"resource": "receiving",
"action": "execute",
"risk": "high",
"description": "입고 검수 작업 실행",
"presentation": "hide",
"backendRequired": true
},
{
"id": "wms.work.execute",
"module": "WMS",
"resource": "work",
"action": "execute",
"risk": "high",
"description": "WMS 작업 시작",
"presentation": "disable",
"backendRequired": true
},
{
"id": "wms.work.read",
"module": "WMS",
"resource": "work",
"action": "read",
"risk": "low",
"description": "WMS 작업 Queue 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.telemetry.write",
"module": "COMMON",
"resource": "telemetry",
"action": "write",
"risk": "low",
"description": "민감정보를 포함하지 않는 KBX 의미 단위 UX Telemetry 전송",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.ux.read",
"module": "COMMON",
"resource": "ux-metrics",
"action": "read",
"risk": "medium",
"description": "KBX UX 품질·자동화 지표 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.experiment.evaluate",
"module": "COMMON",
"resource": "experiment",
"action": "evaluate",
"risk": "low",
"description": "현재 사용자에 대한 안전한 UX 실험 Variant 평가",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.experiment.read",
"module": "COMMON",
"resource": "experiment",
"action": "read",
"risk": "low",
"description": "KBX UX 실험·점진배포 상태 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.experiment.manage",
"module": "COMMON",
"resource": "experiment",
"action": "manage",
"risk": "high",
"description": "UX 실험 점진배포 비율 변경·일시중지·즉시 롤백",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.integration.read",
"module": "COMMON",
"resource": "integration",
"action": "read",
"risk": "low",
"description": "업무 외부연계 상태·시도 이력 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.integration.retry",
"module": "COMMON",
"resource": "integration",
"action": "retry",
"risk": "high",
"description": "최종 실패한 idempotent 외부연계의 수동 재처리 예약",
"presentation": "disable",
"backendRequired": true
},
{
"id": "common.external-data.read",
"module": "COMMON",
"resource": "external-data",
"action": "read",
"risk": "low",
"description": "외부 데이터셋의 출처·신선도·캐시 상태 조회",
"presentation": "hide",
"backendRequired": true
},
{
"id": "common.external-data.refresh",
"module": "COMMON",
"resource": "external-data",
"action": "refresh",
"risk": "medium",
"description": "승인된 외부 데이터셋의 명시적 재수집 요청",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.order.confirm",
"module": "OMS",
"resource": "order",
"action": "confirm",
"risk": "high",
"description": "작성 주문 확정",
"presentation": "disable",
"backendRequired": true
},
{
"id": "oms.order.write",
"module": "OMS",
"resource": "order",
"action": "write",
"risk": "medium",
"description": "작성 상태 주문 수정",
"presentation": "disable",
"backendRequired": true
}
] as const
export const kbxSensitiveDataPolicies = [
{
"id": "oms.order.recipient",
"fields": [
"receiverName",
"phone",
"postalCode",
"address1",
"address2"
],
"defaultExposure": "masked",
"viewPermission": "oms.order.read",
"revealPermission": "oms.order.recipient.unmask",
"unmaskedExportPermission": "oms.order.recipient.export",
"auditRequired": true,
"aiExposure": "masked-only",
"telemetryExposure": "never",
"reasonRequired": false
}
] as const
export const kbxAiAuthorizationPolicy = {
"usePermission": "common.ai.use",
"executePermission": "common.ai.execute",
"executeRequiresProposalPermission": true,
"defaultCapabilities": [
"explain",
"suggest",
"draft"
]
} as const
export type KbxPermissionId = typeof kbxPermissionCatalog[number]['id']
@@ -0,0 +1,215 @@
// generated from contracts/providers/kbx.providers.json; do not edit.
import type { KbxExternalProviderDefinition } from '../provider'
export const kbxProviderSourceSha256='5515e5e002767ed895ea0442003829b0bad8fa9c7a0693ce6cdcbd65969b830f' as const
export const kbxExternalProviderCatalog={
"provider.krx.openapi": {
"id": "provider.krx.openapi",
"title": "KRX Data Marketplace OPEN API",
"ownerModule": "COMMON",
"purpose": "market-data-readonly",
"mutationAllowed": false,
"officialSources": [
"https://openapi.krx.co.kr/contents/OPP/INFO/OPPINFO003.jsp",
"https://openapi.krx.co.kr/contents/OPP/INFO/service/OPPINFO004.cmd"
],
"official": {
"approvalRequired": true,
"authentication": {
"type": "header-api-key",
"headerName": "AUTH_KEY"
},
"serviceUrlPolicy": "service-specific-from-approved-KRX-definition",
"dataAvailabilityNote": "서비스 목록은 다수 데이터에 대해 2010년 이후 제공범위를 안내함",
"numericRateLimit": null
},
"kbxPolicy": {
"allowedHostSuffix": ".krx.co.kr",
"credentialKey": "ExternalProviders:Krx:AuthKey",
"maxConcurrency": 2,
"timeoutMs": 5000,
"retryHttpStatuses": [
408,
429,
500,
502,
503,
504
],
"maxRetryAttempts": 1,
"serviceDefinitionRequired": true,
"cacheable": true
},
"operations": [
{
"id": "krx.approved-service.invoke",
"method": "provider-defined-read",
"path": "provider-defined",
"response": "json-or-xml",
"notes": "실제 URL/메서드는 KRX에서 승인된 개별 서비스 명세를 구성값으로 등록. 저장소에서 추정하지 않음."
}
]
},
"provider.opendart": {
"id": "provider.opendart",
"title": "금융감독원 OPENDART OpenAPI",
"ownerModule": "COMMON",
"purpose": "disclosure-data-readonly",
"mutationAllowed": false,
"officialSources": [
"https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001",
"https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019002",
"https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019018"
],
"official": {
"baseUri": "https://opendart.fss.or.kr/api/",
"authentication": {
"type": "query-api-key",
"parameterName": "crtfc_key",
"length": 40
},
"statusField": "status",
"messageField": "message",
"statusCodes": {
"100": "invalid-field",
"101": "invalid-access",
"800": "maintenance",
"900": "undefined-error",
"901": "expired-personal-data-retention",
"000": "success",
"010": "unregistered-key",
"011": "disabled-key",
"012": "forbidden-ip",
"013": "no-data",
"014": "file-missing",
"020": "request-limit-exceeded",
"021": "company-count-exceeded"
},
"rateLimitNote": "020은 일반적으로 20,000건 이상의 요청에서 발생할 수 있으나 서비스별 제한이 다를 수 있음"
},
"kbxPolicy": {
"credentialKey": "ExternalProviders:OpenDart:ApiKey",
"timeoutMs": 5000,
"maxConcurrency": 2,
"retryStatusCodes": [
"020",
"800",
"900"
],
"maxRetryAttempts": 1,
"noDataStatusCodes": [
"013"
],
"cacheable": true
},
"operations": [
{
"id": "opendart.disclosures",
"method": "GET",
"path": "list.json",
"response": "json"
},
{
"id": "opendart.company",
"method": "GET",
"path": "company.json",
"response": "json"
},
{
"id": "opendart.corp-code",
"method": "GET",
"path": "corpCode.xml",
"response": "zip-binary"
}
]
},
"provider.kis.market-data": {
"id": "provider.kis.market-data",
"title": "한국투자증권 KIS Open API — 국내주식 시세",
"ownerModule": "COMMON",
"purpose": "market-data-readonly",
"mutationAllowed": false,
"officialSources": [
"https://apiportal.koreainvestment.com/apiservice-apiservice",
"https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000001/post/d0d1a83f-6f8d-4437-9700-6d26702fd989",
"https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/domestic_stock/inquire_price/inquire_price.py",
"https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/kis_auth.py"
],
"official": {
"productionBaseUri": "https://openapi.koreainvestment.com:9443",
"sandboxBaseUri": "https://openapivts.koreainvestment.com:29443",
"authentication": {
"type": "oauth2-client-credentials",
"tokenPath": "/oauth2/tokenP",
"bodyFields": [
"grant_type",
"appkey",
"appsecret"
],
"grantType": "client_credentials"
},
"tokenValidityHours": 24,
"tokenRenewalCycleHours": 6,
"headers": [
"authorization",
"appkey",
"appsecret",
"tr_id",
"custtype",
"tr_cont"
],
"responseSuccess": {
"field": "rt_cd",
"value": "0"
},
"rateLimitsAsOf": "2026-04-20",
"productionRequestsPerSecond": 18,
"sandboxRequestsPerSecond": 1,
"tokenRequestsPerSecond": 1,
"concurrentSpacingRecommendationMs": [
100,
150
]
},
"kbxPolicy": {
"appKeyCredential": "ExternalProviders:Kis:AppKey",
"appSecretCredential": "ExternalProviders:Kis:AppSecret",
"environmentKey": "ExternalProviders:Kis:Environment",
"readOnlyOnly": true,
"tokenRefreshSkewMinutes": 5,
"productionMinimumSpacingMs": 100,
"sandboxMinimumSpacingMs": 1000,
"timeoutMs": 5000,
"maxRetryAttempts": 1,
"retryHttpStatuses": [
408,
429,
500,
502,
503,
504
],
"cacheable": true
},
"operations": [
{
"id": "kis.oauth.token",
"method": "POST",
"path": "/oauth2/tokenP",
"response": "json",
"authOperation": true
},
{
"id": "kis.domestic-stock.current-price",
"method": "GET",
"path": "/uapi/domestic-stock/v1/quotations/inquire-price",
"trId": "FHKST01010100",
"response": "json",
"query": [
"FID_COND_MRKT_DIV_CODE",
"FID_INPUT_ISCD"
]
}
]
}
} as const satisfies Record<string,KbxExternalProviderDefinition>
export type KbxExternalProviderId=keyof typeof kbxExternalProviderCatalog
@@ -0,0 +1,956 @@
// generated from contracts/screens/kbx.screen-recipes.json; do not edit.
// SHA256: 97ab56bc54149f32ea16577369c4ec29579b516ea464cb42010b31e4dc413e60
import type { KbxScreenRecipeDefinition } from '../screen'
export const kbxScreenRecipeContractVersion="1.1.0" as const
export const kbxScreenRecipeCatalog={
"T01": {
"code": "T01",
"type": "list",
"templateComponent": "KbxListPage",
"defaultCommands": [
{
"id": "search",
"label": "조회",
"group": "query",
"shortcut": "F3",
"permissionKind": "none"
},
{
"id": "excel",
"label": "엑셀",
"group": "output",
"permissionKind": "none"
}
],
"requiredPolicies": [
"server-read-model",
"tanstack-query",
"search-condition-preservation",
"server-side-bulk-selection"
],
"recoveryPolicies": [
"idle-before-first-search",
"retain-grid-during-refresh",
"retry-with-search-context",
"partial-bulk-result"
],
"securityPolicies": [
"screen-permission",
"command-permission",
"safe-drilldown-route",
"masked-sensitive-cells"
],
"canonicalScenarioIds": [
"scenario.oms.order-list.bulk-ship",
"scenario.oms.order-list.all-filtered-ship"
],
"scaffoldSurfaces": [
"search",
"content",
"summary"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e"
],
"requiredTags": [
"bulk",
"idempotency"
],
"requiredEvidence": [
"playwright-trace"
],
"requiredChecks": [
"idle-search",
"refresh-retains-context",
"bulk-partial-result",
"safe-drilldown"
]
}
},
"T02": {
"code": "T02",
"type": "master",
"templateComponent": "KbxMasterPage",
"defaultCommands": [
{
"id": "search",
"label": "조회",
"group": "query",
"shortcut": "F3",
"permissionKind": "none"
},
{
"id": "new",
"label": "신규",
"group": "edit",
"permissionKind": "write"
},
{
"id": "save",
"label": "저장",
"group": "edit",
"shortcut": "F8",
"variant": "primary",
"permissionKind": "write"
},
{
"id": "excel",
"label": "엑셀",
"group": "output",
"permissionKind": "none"
}
],
"requiredPolicies": [
"normalized-write-model",
"lookup-provider",
"server-validation",
"optimistic-concurrency",
"audit"
],
"recoveryPolicies": [
"dirty-navigation-guard",
"conflict-reload",
"field-validation-focus",
"readonly-after-terminal-state"
],
"securityPolicies": [
"screen-permission",
"write-command-permission",
"server-enforcement",
"sensitive-value-masking"
],
"canonicalScenarioIds": [
"scenario.erp.item-master.lifecycle",
"scenario.erp.item-master.keyboard-recovery"
],
"scaffoldSurfaces": [
"list",
"detail"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e",
"integration"
],
"requiredTags": [
"master-crud",
"concurrency",
"audit"
],
"requiredEvidence": [
"playwright-trace",
"db-assertions"
],
"requiredChecks": [
"permission-aware-save",
"dirty-guard",
"conflict-reload",
"audit-visible"
]
}
},
"T03": {
"code": "T03",
"type": "transaction",
"templateComponent": "KbxTransactionPage",
"defaultCommands": [
{
"id": "new",
"label": "신규",
"group": "edit",
"permissionKind": "write"
},
{
"id": "save",
"label": "저장",
"group": "edit",
"shortcut": "F8",
"variant": "primary",
"permissionKind": "write"
},
{
"id": "excel",
"label": "엑셀",
"group": "output",
"permissionKind": "none"
}
],
"requiredPolicies": [
"normalized-write-model",
"header-detail-transaction",
"server-validation",
"optimistic-concurrency",
"audit",
"outbox"
],
"recoveryPolicies": [
"dirty-navigation-guard",
"conflict-reload",
"row-key-validation",
"double-submit-guard"
],
"securityPolicies": [
"screen-permission",
"write-command-permission",
"workflow-permission",
"server-enforcement"
],
"canonicalScenarioIds": [
"scenario.oms.order-register.keyboard",
"scenario.oms.order-register.conflict",
"scenario.oms.order-register.lifecycle"
],
"scaffoldSurfaces": [
"header",
"detail",
"summary"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e",
"integration"
],
"requiredTags": [
"keyboard",
"lookup",
"conflict",
"workflow"
],
"requiredEvidence": [
"playwright-trace",
"problem-json"
],
"requiredChecks": [
"f2-lookup",
"f8-save",
"row-key-validation",
"conflict-recovery"
]
}
},
"T04": {
"code": "T04",
"type": "fast-entry",
"templateComponent": "KbxFastEntryPage",
"defaultCommands": [
{
"id": "new",
"label": "신규",
"group": "edit",
"permissionKind": "write"
},
{
"id": "save",
"label": "저장",
"group": "edit",
"shortcut": "F8",
"variant": "primary",
"permissionKind": "write"
},
{
"id": "excel",
"label": "엑셀",
"group": "output",
"permissionKind": "none"
}
],
"requiredPolicies": [
"typed-clipboard-normalization",
"stable-client-row-key",
"bulk-server-validation",
"idempotent-bulk-save"
],
"recoveryPolicies": [
"error-cell-navigation",
"paste-error-summary",
"dirty-navigation-guard",
"partial-save-result"
],
"securityPolicies": [
"screen-permission",
"write-command-permission",
"lookup-entity-resolution",
"server-enforcement"
],
"canonicalScenarioIds": [
"scenario.erp.item-price.fast-entry",
"scenario.oms.order-register.fast-entry"
],
"scaffoldSurfaces": [
"guide",
"content",
"validation",
"summary"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e"
],
"requiredTags": [
"fast-entry",
"paste",
"fill-down",
"idempotency"
],
"requiredEvidence": [
"playwright-trace",
"problem-json"
],
"requiredChecks": [
"multi-cell-paste",
"fill-down",
"error-navigation",
"partial-save-result"
]
}
},
"T05": {
"code": "T05",
"type": "master-detail",
"templateComponent": "KbxMasterDetailPage",
"defaultCommands": [
{
"id": "search",
"label": "조회",
"group": "query",
"shortcut": "F3",
"permissionKind": "none"
},
{
"id": "excel",
"label": "엑셀",
"group": "output",
"permissionKind": "none"
}
],
"requiredPolicies": [
"server-read-model",
"master-selection-context",
"detail-projection",
"n-plus-one-prohibition"
],
"recoveryPolicies": [
"selection-restore-after-refresh",
"retain-master-on-detail-error",
"safe-drilldown",
"empty-master-detail-reset"
],
"securityPolicies": [
"screen-permission",
"safe-drilldown-route",
"masked-sensitive-detail"
],
"canonicalScenarioIds": [
"scenario.erp.inventory.master-detail-context"
],
"scaffoldSurfaces": [
"search",
"master",
"detail"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e"
],
"requiredTags": [
"master-detail",
"context-retention",
"drill-down"
],
"requiredEvidence": [
"playwright-trace",
"api-transcript"
],
"requiredChecks": [
"master-selection-context",
"detail-error-retains-master",
"history-context",
"safe-drilldown"
]
}
},
"T06": {
"code": "T06",
"type": "queue",
"templateComponent": "KbxQueuePage",
"defaultCommands": [
{
"id": "search",
"label": "조회",
"group": "query",
"shortcut": "F3",
"permissionKind": "none"
}
],
"requiredPolicies": [
"exception-first-projection",
"sla-state",
"server-side-bulk-selection",
"audit"
],
"recoveryPolicies": [
"partial-action-result",
"retryable-vs-terminal-error",
"stale-event-suppression",
"detail-context-retention"
],
"securityPolicies": [
"screen-permission",
"exception-action-permission",
"server-enforcement"
],
"canonicalScenarioIds": [
"scenario.common.operations.stale-event",
"scenario.integration.manual-retry.idempotent",
"scenario.common.operations.queue-recovery"
],
"scaffoldSurfaces": [
"summary",
"exceptions",
"content"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e",
"integration"
],
"requiredTags": [
"exception",
"recovery",
"context-retention"
],
"requiredEvidence": [
"playwright-trace",
"db-assertions"
],
"requiredChecks": [
"exception-first",
"detail-context-retention",
"retryable-vs-terminal",
"partial-action-result"
]
}
},
"T07": {
"code": "T07",
"type": "reconcile",
"templateComponent": "KbxReconcilePage",
"defaultCommands": [
{
"id": "search",
"label": "조회",
"group": "query",
"shortcut": "F3",
"permissionKind": "none"
}
],
"requiredPolicies": [
"expected-actual-difference-projection",
"reason-code",
"resolution-state",
"audit"
],
"recoveryPolicies": [
"grace-period-before-mismatch",
"mismatch-only-filter",
"resolution-retry",
"retain-comparison-context"
],
"securityPolicies": [
"screen-permission",
"resolution-permission",
"server-enforcement"
],
"canonicalScenarioIds": [
"scenario.common.reconcile.grace-period",
"scenario.common.reconcile.mismatch-recovery"
],
"scaffoldSurfaces": [
"search",
"content",
"resolution"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e",
"integration"
],
"requiredTags": [
"reconcile",
"mismatch-filter",
"eventual-consistency"
],
"requiredEvidence": [
"playwright-trace",
"db-assertions"
],
"requiredChecks": [
"mismatch-only-filter",
"grace-period",
"resolution-retry",
"comparison-context"
]
}
},
"T08": {
"code": "T08",
"type": "import",
"templateComponent": "KbxImportPage",
"defaultCommands": [],
"requiredPolicies": [
"staging-before-domain-write",
"mapping-source-traceability",
"background-job-threshold",
"idempotent-commit",
"audit"
],
"recoveryPolicies": [
"session-mapping-isolation",
"validation-error-download",
"partial-commit-result",
"job-refresh-recovery"
],
"securityPolicies": [
"screen-permission",
"file-preflight",
"commit-permission",
"server-validation",
"sensitive-column-policy"
],
"canonicalScenarioIds": [
"scenario.oms.order-import.partial"
],
"scaffoldSurfaces": [
"steps",
"content",
"result"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e"
],
"requiredTags": [
"excel",
"mapping",
"staging",
"partial-success",
"job"
],
"requiredEvidence": [
"playwright-trace",
"problem-json"
],
"requiredChecks": [
"mapping-isolation",
"validation-download",
"commit-confirm",
"job-refresh-recovery"
]
}
},
"T09": {
"code": "T09",
"type": "wms-mobile",
"templateComponent": "KbxWmsMobilePage",
"defaultCommands": [],
"requiredPolicies": [
"barcode-normalization",
"server-validation-before-success",
"idempotent-scan-command",
"network-state",
"offline-command-policy"
],
"recoveryPolicies": [
"duplicate-scan-debounce",
"response-loss-replay",
"wrong-item-no-retry",
"pending-command-visibility"
],
"securityPolicies": [
"screen-permission",
"execute-permission",
"server-enforcement",
"offline-command-allowlist"
],
"canonicalScenarioIds": [
"scenario.wms.picking.idempotent-replay",
"scenario.wms.picking.wrong-item"
],
"scaffoldSurfaces": [
"context",
"content",
"actions"
],
"testProfile": {
"requiredScenarioKinds": [
"e2e"
],
"requiredTags": [
"scanner",
"idempotency",
"network",
"retry-policy"
],
"requiredEvidence": [
"playwright-trace",
"api-transcript"
],
"requiredChecks": [
"scan-normalization",
"server-before-success",
"response-loss-replay",
"wrong-item-no-retry"
]
}
}
} as const satisfies Record<string,KbxScreenRecipeDefinition>
export const kbxScreenRecipeVerificationCatalog={
"T01": {
"code": "T01",
"type": "list",
"canonicalScenarioIds": [
"scenario.oms.order-list.bulk-ship",
"scenario.oms.order-list.all-filtered-ship"
],
"scenarioKinds": [
"contract",
"e2e"
],
"tags": [
"all-filtered",
"audit",
"bulk",
"filter-snapshot",
"golden-screen",
"idempotency",
"outbox"
],
"evidence": [
"api-transcript",
"correlation-ids",
"db-assertions",
"playwright-trace",
"screenshot",
"vitest-contract"
],
"requiredChecks": [
"idle-search",
"refresh-retains-context",
"bulk-partial-result",
"safe-drilldown"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T02": {
"code": "T02",
"type": "master",
"canonicalScenarioIds": [
"scenario.erp.item-master.lifecycle",
"scenario.erp.item-master.keyboard-recovery"
],
"scenarioKinds": [
"e2e",
"integration"
],
"tags": [
"audit",
"concurrency",
"golden-screen",
"keyboard",
"master-crud",
"permission",
"recovery",
"state-policy"
],
"evidence": [
"api-transcript",
"db-assertions",
"playwright-trace",
"problem-json",
"screenshot"
],
"requiredChecks": [
"permission-aware-save",
"dirty-guard",
"conflict-reload",
"audit-visible"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T03": {
"code": "T03",
"type": "transaction",
"canonicalScenarioIds": [
"scenario.oms.order-register.keyboard",
"scenario.oms.order-register.conflict",
"scenario.oms.order-register.lifecycle"
],
"scenarioKinds": [
"e2e",
"integration"
],
"tags": [
"audit",
"concurrency",
"conflict",
"golden-screen",
"keyboard",
"lookup",
"recovery",
"state-policy",
"transaction",
"validation",
"workflow"
],
"evidence": [
"api-transcript",
"db-assertions",
"playwright-trace",
"problem-json",
"screenshot"
],
"requiredChecks": [
"f2-lookup",
"f8-save",
"row-key-validation",
"conflict-recovery"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T04": {
"code": "T04",
"type": "fast-entry",
"canonicalScenarioIds": [
"scenario.erp.item-price.fast-entry",
"scenario.oms.order-register.fast-entry"
],
"scenarioKinds": [
"contract",
"e2e"
],
"tags": [
"audit",
"fast-entry",
"fill-down",
"golden-screen",
"idempotency",
"inline-validation",
"lookup",
"outbox",
"paste"
],
"evidence": [
"api-transcript",
"component-catalog",
"db-assertions",
"playwright-trace",
"problem-json",
"vitest-contract"
],
"requiredChecks": [
"multi-cell-paste",
"fill-down",
"error-navigation",
"partial-save-result"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T05": {
"code": "T05",
"type": "master-detail",
"canonicalScenarioIds": [
"scenario.erp.inventory.master-detail-context"
],
"scenarioKinds": [
"e2e"
],
"tags": [
"context-retention",
"drill-down",
"golden-screen",
"history",
"master-detail"
],
"evidence": [
"api-transcript",
"playwright-trace",
"screenshot"
],
"requiredChecks": [
"master-selection-context",
"detail-error-retains-master",
"history-context",
"safe-drilldown"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T06": {
"code": "T06",
"type": "queue",
"canonicalScenarioIds": [
"scenario.common.operations.stale-event",
"scenario.integration.manual-retry.idempotent",
"scenario.common.operations.queue-recovery"
],
"scenarioKinds": [
"e2e",
"integration"
],
"tags": [
"context-retention",
"exception",
"golden-screen",
"idempotency",
"integration",
"manual-retry",
"ordering",
"outbox",
"permission",
"projection",
"queue",
"recovery"
],
"evidence": [
"api-transcript",
"correlation-ids",
"db-assertions",
"playwright-trace",
"projection-event-log",
"screenshot"
],
"requiredChecks": [
"exception-first",
"detail-context-retention",
"retryable-vs-terminal",
"partial-action-result"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T07": {
"code": "T07",
"type": "reconcile",
"canonicalScenarioIds": [
"scenario.common.reconcile.grace-period",
"scenario.common.reconcile.mismatch-recovery"
],
"scenarioKinds": [
"e2e",
"integration"
],
"tags": [
"context-retention",
"eventual-consistency",
"golden-screen",
"mismatch-filter",
"reconcile",
"recovery"
],
"evidence": [
"api-transcript",
"clock-snapshot",
"db-assertions",
"playwright-trace",
"screenshot"
],
"requiredChecks": [
"mismatch-only-filter",
"grace-period",
"resolution-retry",
"comparison-context"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T08": {
"code": "T08",
"type": "import",
"canonicalScenarioIds": [
"scenario.oms.order-import.partial"
],
"scenarioKinds": [
"e2e"
],
"tags": [
"excel",
"job",
"mapping",
"partial-success",
"staging"
],
"evidence": [
"api-transcript",
"db-assertions",
"error-workbook-metadata",
"import-session-snapshot",
"playwright-trace",
"problem-json"
],
"requiredChecks": [
"mapping-isolation",
"validation-download",
"commit-confirm",
"job-refresh-recovery"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
},
"T09": {
"code": "T09",
"type": "wms-mobile",
"canonicalScenarioIds": [
"scenario.wms.picking.idempotent-replay",
"scenario.wms.picking.wrong-item"
],
"scenarioKinds": [
"e2e"
],
"tags": [
"business-error",
"golden-screen",
"idempotency",
"network",
"retry-policy",
"scanner"
],
"evidence": [
"api-transcript",
"db-assertions",
"playwright-trace",
"scanner-event-log"
],
"requiredChecks": [
"scan-normalization",
"server-before-success",
"response-loss-replay",
"wrong-item-no-retry"
],
"missingScenarioKinds": [],
"missingTags": [],
"missingEvidence": [],
"complete": true
}
} as const
@@ -0,0 +1,284 @@
// generated from contracts/telemetry/kbx.telemetry.json; do not edit.
export const kbxTelemetryCatalog = {
"screen.open": {
"name": "screen.open",
"category": "navigation",
"description": "화면 진입",
"allowedAttributes": [
"module",
"launchMode"
]
},
"screen.close": {
"name": "screen.close",
"category": "navigation",
"description": "화면 종료",
"allowedAttributes": [
"module",
"closeReason"
]
},
"task.start": {
"name": "task.start",
"category": "task",
"description": "측정 가능한 업무 Task 시작",
"allowedAttributes": [
"taskType"
]
},
"task.complete": {
"name": "task.complete",
"category": "task",
"description": "업무 Task 정상 완료",
"allowedAttributes": [
"taskType",
"result"
],
"requiresDuration": true
},
"task.abandon": {
"name": "task.abandon",
"category": "task",
"description": "업무 Task 중단",
"allowedAttributes": [
"taskType",
"reasonCode"
],
"requiresDuration": true
},
"interaction.execute": {
"name": "interaction.execute",
"category": "interaction",
"description": "KBX 의미 단위 업무 조작 실행",
"allowedAttributes": [
"interactionType",
"commandId"
]
},
"search.execute": {
"name": "search.execute",
"category": "interaction",
"description": "조회 실행",
"allowedAttributes": [
"resultBucket"
]
},
"command.execute": {
"name": "command.execute",
"category": "command",
"description": "업무 Command 실행 시도",
"allowedAttributes": [
"commandId",
"operationKind"
]
},
"command.succeeded": {
"name": "command.succeeded",
"category": "command",
"description": "업무 Command 성공",
"allowedAttributes": [
"commandId",
"operationKind"
],
"requiresDuration": true
},
"command.failed": {
"name": "command.failed",
"category": "command",
"description": "업무 Command 실패",
"allowedAttributes": [
"commandId",
"problemType",
"reasonCode"
],
"requiresDuration": true
},
"lookup.open": {
"name": "lookup.open",
"category": "lookup",
"description": "Lookup 열기",
"allowedAttributes": [
"entityType"
]
},
"lookup.select": {
"name": "lookup.select",
"category": "lookup",
"description": "Lookup 선택 완료",
"allowedAttributes": [
"entityType",
"selectionSource"
]
},
"grid.bulk_action": {
"name": "grid.bulk_action",
"category": "interaction",
"description": "Grid 일괄처리 실행",
"allowedAttributes": [
"commandId",
"countBucket"
]
},
"validation.failed": {
"name": "validation.failed",
"category": "quality",
"description": "저장/업무 실행 전후 검증 실패",
"allowedAttributes": [
"stage",
"errorCountBucket",
"reasonCode"
]
},
"excel.import.start": {
"name": "excel.import.start",
"category": "excel",
"description": "Excel Import 시작",
"allowedAttributes": [
"importType",
"rowCountBucket"
]
},
"excel.import.completed": {
"name": "excel.import.completed",
"category": "excel",
"description": "Excel Import 완료",
"allowedAttributes": [
"importType",
"result",
"rowCountBucket"
],
"requiresDuration": true
},
"excel.import.failed": {
"name": "excel.import.failed",
"category": "excel",
"description": "Excel Import 실패",
"allowedAttributes": [
"importType",
"reasonCode",
"rowCountBucket"
],
"requiresDuration": true
},
"exception.open": {
"name": "exception.open",
"category": "exception",
"description": "업무 예외 생성/노출",
"allowedAttributes": [
"exceptionType",
"severity"
]
},
"exception.resolved": {
"name": "exception.resolved",
"category": "exception",
"description": "업무 예외 해결",
"allowedAttributes": [
"exceptionType",
"resolutionType"
],
"requiresDuration": true
},
"ai.proposal.open": {
"name": "ai.proposal.open",
"category": "ai",
"description": "AI 제안 확인",
"allowedAttributes": [
"proposalType"
]
},
"ai.proposal.accept": {
"name": "ai.proposal.accept",
"category": "ai",
"description": "AI 제안 승인",
"allowedAttributes": [
"proposalType"
]
},
"ai.proposal.reject": {
"name": "ai.proposal.reject",
"category": "ai",
"description": "AI 제안 거절",
"allowedAttributes": [
"proposalType",
"reasonCode"
]
},
"manual.intervention": {
"name": "manual.intervention",
"category": "outcome",
"description": "정상 자동처리 대신 사람의 확인/수정/판단이 필요했던 업무",
"allowedAttributes": [
"workType",
"reasonCode",
"exceptionType"
]
},
"experiment.exposed": {
"name": "experiment.exposed",
"category": "experiment",
"description": "실험 Variant가 실제 화면에 렌더링되어 사용자에게 노출됨",
"allowedAttributes": [
"surface"
]
}
} as const
export type KbxTelemetryEventName = keyof typeof kbxTelemetryCatalog
export const kbxUxMetricCatalog = {
"task_completion_time_ms": {
"key": "task_completion_time_ms",
"label": "Task Completion Time",
"numerator": "task.complete.duration_ms",
"aggregation": "p50,p95",
"unit": "ms"
},
"semantic_interactions_per_task": {
"key": "semantic_interactions_per_task",
"label": "Interactions / Task",
"numerator": "interaction.execute",
"denominator": "task.complete",
"aggregation": "ratio",
"unit": "count"
},
"manual_intervention_rate": {
"key": "manual_intervention_rate",
"label": "Manual Intervention Rate",
"numerator": "ux_business_outcomes.manual_intervention_count",
"denominator": "ux_business_outcomes.observed_count",
"aggregation": "ratio",
"unit": "percent"
},
"validation_failure_rate": {
"key": "validation_failure_rate",
"label": "Validation Failure Rate",
"numerator": "validation.failed",
"denominator": "command.execute",
"aggregation": "ratio",
"unit": "percent"
},
"import_failure_rate": {
"key": "import_failure_rate",
"label": "Import Failure Rate",
"numerator": "excel.import.failed",
"denominator": "excel.import.start",
"aggregation": "ratio",
"unit": "percent"
},
"exception_resolution_time_ms": {
"key": "exception_resolution_time_ms",
"label": "Exception Resolution Time",
"numerator": "exception.resolved.duration_ms",
"aggregation": "p50,p95",
"unit": "ms"
},
"ai_proposal_acceptance_rate": {
"key": "ai_proposal_acceptance_rate",
"label": "AI Proposal Acceptance Rate",
"numerator": "ai.proposal.accept",
"denominator": "ai.proposal.open",
"aggregation": "ratio",
"unit": "percent"
}
} as const
export type KbxUxMetricKey = keyof typeof kbxUxMetricCatalog
export const kbxTelemetrySourceSha256 = '9f5de69498b657cb0d60df18c1e98c504a44eec555c4fc50d46f15f83a4ea1ce' as const
@@ -0,0 +1,91 @@
import type { KbxLookupDefinition } from './lookup'
export type KbxGridType =
| 'text' | 'code' | 'integer' | 'decimal' | 'quantity' | 'money'
| 'percent' | 'date' | 'datetime' | 'boolean' | 'status' | 'lookup' | 'link'
export type KbxGridDensity = 'compact' | 'comfortable'
export interface KbxGridColumn<T> {
field: keyof T & string
header: string
type?: KbxGridType
width?: number
minWidth?: number
maxWidth?: number
pinned?: 'left' | 'right'
editable?: boolean | ((row: T) => boolean)
sortable?: boolean
filterable?: boolean
permission?: string
lookup?: KbxLookupDefinition
drilldown?: boolean
}
export interface KbxGridColumnPreference {
field: string
order: number
width?: number
pinned?: 'left' | 'right' | null
hidden?: boolean
sort?: 'asc' | 'desc' | null
sortIndex?: number | null
}
export interface KbxGridCellRef<T = string> {
rowKey: T
field: string
}
export interface KbxGridSummary<T> {
key: string
label: string
field?: keyof T & string
kind: 'count' | 'sum' | 'custom'
value?: string | number
}
/** Browser에는 현재 선택 상태만 유지한다. all-filtered에서 전체 ID를 적재하지 않는다. */
export interface KbxSelectionState<TId = string> {
mode: 'explicit' | 'all-filtered'
selectedIds: TId[]
excludedIds?: TId[]
}
/** Server-side bulk command의 canonical selection envelope. */
export interface KbxBulkSelectionRequest<TFilter = Record<string, unknown>, TId = string> {
mode: 'ids' | 'filter'
ids?: TId[]
filter?: TFilter
excludedIds?: TId[]
}
export interface KbxGridEditingPolicy {
allowRowAdd?: boolean
allowRowDuplicate?: boolean
fillDown?: boolean
paste?: boolean
errorNavigation?: boolean
}
export interface KbxGridPasteIssue {
rowOffset: number
columnOffset: number
field?: string
code: 'INVALID_NUMBER' | 'INVALID_DATE' | 'INVALID_BOOLEAN'
value: unknown
}
export interface KbxGridPasteResult {
cellCount: number
normalizedCount: number
rejectedCount: number
issues: KbxGridPasteIssue[]
}
export interface KbxGridContextRequest<T> {
row: T
field: keyof T & string
clientX: number
clientY: number
}
@@ -0,0 +1,9 @@
export interface KbxHelpContent {
key: string
title: string
purpose: string
steps?: string[]
shortcuts?: { key: string; description: string }[]
cautions?: string[]
relatedScreens?: { id: string; title: string }[]
}
@@ -0,0 +1,59 @@
export * from './screen'
export * from './ui'
export * from './command'
export * from './grid'
export * from './lookup'
export * from './problem'
export * from './field'
export * from './transaction'
export * from './audit'
export * from './wms'
export * from './registry'
export * from './permission'
export * from './help'
export * from './preference'
export * from './status'
export * from './search'
export * from './excel'
export * from './operations'
export * from './suggestion'
export * from './ai'
export * from './workflow'
export * from './runtime'
export * from './performance'
export * from './navigation'
export * from './catalog'
export * from './generated/fieldCatalog'
export * from './api'
export * from './generated/apiCatalog'
export * from './authorization'
export * from './generated/permissionCatalog'
export * from './telemetry'
export * from './generated/telemetryCatalog'
export * from './experiment'
export * from './generated/experimentCatalog'
export * from './testing'
export * from './generated/testScenarioCatalog'
export * from './generated/screenRecipeCatalog'
export * from './integration'
export * from './generated/integrationCatalog'
export * from './provider'
export * from './generated/providerCatalog'
export * from './externalData'
export * from './generated/externalDataCatalog'
export * from './configuration'
export * from './generated/configurationCatalog'
@@ -0,0 +1,58 @@
export type KbxIntegrationState = 'queued' | 'delivering' | 'retrying' | 'delivered' | 'failed' | 'suspended'
export type KbxIntegrationDirection = 'inbound' | 'outbound'
export type KbxIntegrationTransport = 'outbox-http' | 'outbox-event' | 'inbox-event'
export type KbxIntegrationDelivery = 'at-most-once' | 'at-least-once'
export type KbxIntegrationCriticality = 'low' | 'medium' | 'high'
export interface KbxIntegrationShortRetryDefinition {
maxRetryAttempts: number
baseDelayMs: number
backoff: 'constant' | 'exponential'
}
export interface KbxIntegrationLongRetryDefinition {
scheduler: 'hangfire'
maxAttempts: number
scheduleSeconds: readonly number[]
}
export interface KbxIntegrationCircuitBreakerDefinition {
failureRatio: number
samplingSeconds: number
minimumThroughput: number
breakSeconds: number
}
export interface KbxIntegrationDefinition {
id: string
title: string
ownerModule: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
direction: KbxIntegrationDirection
transport: KbxIntegrationTransport
criticality: KbxIntegrationCriticality
sourceEvent: string
target: string
delivery: KbxIntegrationDelivery
ordering: 'none' | 'per-aggregate'
idempotency: 'none' | 'event-id' | 'business-key'
timeoutMs: number
shortRetry: KbxIntegrationShortRetryDefinition
longRetry: KbxIntegrationLongRetryDefinition
circuitBreaker: KbxIntegrationCircuitBreakerDefinition
retryable: readonly string[]
permanent: readonly string[]
terminalAction: 'operations-exception' | 'dead-letter'
userVisible: boolean
}
export interface KbxIntegrationStatusView {
integrationId: string
state: KbxIntegrationState
label: string
attemptCount?: number
lastAttemptAt?: string
nextRetryAt?: string
detail?: string
correlationId?: string
retryAllowed?: boolean
}
@@ -0,0 +1,47 @@
export type KbxLookupColumnSource =
| 'code'
| 'displayName'
| 'secondaryText'
| 'status'
| `metadata.${string}`
export interface KbxLookupColumnDefinition {
key: string
label: string
source: KbxLookupColumnSource
width?: number
align?: 'left' | 'center' | 'right'
}
export interface KbxLookupDefinition {
entity: string
columns?: KbxLookupColumnDefinition[]
pageSize?: number
}
export interface KbxLookupItem<TId = string> {
id: TId
code: string
displayName: string
secondaryText?: string
status?: string
metadata?: Record<string, unknown>
}
export interface KbxLookupSearchRequest {
query?: string
page: number
pageSize: number
filters?: Record<string, unknown>
}
export interface KbxLookupSearchResult<TId = string> {
items: KbxLookupItem<TId>[]
totalCount: number
}
export interface KbxLookupProvider<TId = string> {
search(request: KbxLookupSearchRequest): Promise<KbxLookupSearchResult<TId>>
resolveById(id: TId): Promise<KbxLookupItem<TId> | null>
resolveByCode(code: string): Promise<KbxLookupItem<TId> | null>
}
@@ -0,0 +1,117 @@
export type KbxWorkspaceLaunchMode = 'tab' | 'replace' | 'external'
export type KbxRecentPolicy = 'screen' | 'route' | 'none'
export interface KbxNavigationEntry {
screenId: string
path: string
section: string
group?: string
keywords?: string[]
order?: number
launchMode?: KbxWorkspaceLaunchMode
menuVisible?: boolean
favoriteAllowed?: boolean
/** Persist only a safe catalog path by default. Route mode is opt-in for non-sensitive parameterized routes. */
recentPolicy?: KbxRecentPolicy
/** Menu discovery can be narrower than direct screen access. */
permissions?: string[]
/** Lower values are surfaced first in Home quick-start. Omit when the screen should not be promoted. */
homePriority?: number
/** Stable business-oriented group used by Home without inventing dashboard categories. */
homeGroup?: string
}
export interface KbxNavigationResolvedEntry extends KbxNavigationEntry {
title: string
module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
permissions?: string[]
}
export interface KbxNavigationSection {
key: string
label: string
module?: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
entries: KbxNavigationResolvedEntry[]
}
export interface KbxRecentNavigation {
screenId: string
path: string
title: string
visitedAt: string
}
export interface KbxWorkspaceTab {
key: string
screenId: string
title: string
path: string
dirty?: boolean
pinned?: boolean
openedAt: string
lastActivatedAt: string
}
export interface KbxNavigationPreference {
favorites: string[]
recents: KbxRecentNavigation[]
sideNavCollapsed: boolean
workspaceMaxTabs: number
/** Versioned key used to prevent cross-user/tenant preference leakage. */
scopeKey?: string
}
export type KbxHomeLaunchSource = 'dirty' | 'pinned' | 'open' | 'favorite' | 'recent' | 'priority'
export interface KbxHomeLaunchItem {
/** Stable UI key. Open workspace instances use tabKey so multiple records from one screen can coexist. */
launchKey: string
screenId: string
title: string
module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
section: string
path: string
source: KbxHomeLaunchSource
sourceLabel: string
tabKey?: string
/** Optional human-readable route instance hint for multiple open records of the same screen. */
instanceLabel?: string
homeGroup?: string
}
export type KbxHomeAttentionSource = 'dirty' | 'operation-failed' | 'notification-urgent' | 'operation-running' | 'notification'
export interface KbxHomeAttentionItem {
key: string
source: KbxHomeAttentionSource
priority: number
/** Used for deterministic newest-first ordering inside the same business priority. */
occurredAt: string
title: string
detail?: string
actionLabel: string
screenId?: string
path?: string
tabKey?: string
operationId?: string
notificationId?: string
}
export interface KbxHomeAttentionQueue {
items: KbxHomeAttentionItem[]
totalCount: number
overflowCount: number
sourceCounts: Partial<Record<KbxHomeAttentionSource, number>>
}
export interface KbxMenuSearchResult {
screenId: string
title: string
module: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
section: string
path: string
keywords: string[]
score: number
}
@@ -0,0 +1,79 @@
export type KbxSeverity = 'info' | 'warning' | 'critical'
export type KbxWorkItemStatus = 'open' | 'claimed' | 'resolved' | 'ignored'
export interface KbxWorkQueueCounter {
key: string
label: string
count: number
severity?: KbxSeverity
}
export interface KbxWorkItemAction {
id: string
label: string
kind: 'navigate' | 'claim' | 'resolve' | 'retry' | 'domain'
permission?: string
permissionMode?: 'hide' | 'disable'
danger?: boolean
}
export interface KbxWorkItem {
id: string
sourceModule: 'OMS' | 'ERP' | 'WMS' | 'COMMON'
sourceType: string
sourceId: string
referenceNo: string
sourceScreenId?: string | null
code: string
title: string
detail?: string
severity: KbxSeverity
status: KbxWorkItemStatus
ownerId?: string | null
ownerName?: string | null
occurredAt: string
dueAt?: string | null
ageMinutes: number
version: number
context?: Record<string, unknown>
actions?: KbxWorkItemAction[]
}
export interface KbxWorkQueueResult {
items: KbxWorkItem[]
totalCount: number
counters: KbxWorkQueueCounter[]
}
export type KbxReconcileStatus = 'matched' | 'mismatch' | 'pending' | 'resolved'
export interface KbxReconcileItem {
id: string
reconcileType: string
referenceNo: string
sourceLabel: string
targetLabel: string
expectedValue: string
actualValue: string
differenceValue?: string | null
reasonCode?: string | null
reasonText?: string | null
status: KbxReconcileStatus
occurredAt: string
sourceId?: string | null
targetId?: string | null
version: number
}
export interface KbxReconcileSummary {
totalCount: number
matchedCount: number
mismatchCount: number
pendingCount: number
resolvedCount: number
}
export interface KbxReconcileResult {
items: KbxReconcileItem[]
summary: KbxReconcileSummary
}
@@ -0,0 +1,16 @@
export interface KbxPerformanceBudget {
name: string
targetMs: number
percentile?: 50 | 95 | 99
note: string
}
// Reference acceptance targets. Teams may tighten them after field telemetry,
// but should not silently loosen them per screen.
export const kbxReferencePerformanceBudgets: KbxPerformanceBudget[] = [
{ name: 'keyboard-feedback', targetMs: 100, note: '키 입력/Focus/로컬 선택 피드백' },
{ name: 'lookup-search', targetMs: 500, percentile: 95, note: '사내망 기준 Lookup 후보 응답' },
{ name: 'filtered-list-search', targetMs: 2000, percentile: 95, note: '일반 업무 조회 첫 결과' },
{ name: 'wms-authoritative-scan', targetMs: 500, percentile: 95, note: '현장망 기준 서버 확정 Scan 응답' },
{ name: 'foreground-operation', targetMs: 3000, note: '이보다 길면 Operation Center/Job 전환 검토' },
]
@@ -0,0 +1,5 @@
export interface KbxPermissionContext {
has(permission: string): boolean
hasAny(permissions: readonly string[]): boolean
hasAll(permissions: readonly string[]): boolean
}
@@ -0,0 +1,12 @@
export type KbxDensity = 'compact' | 'comfortable' | 'touch'
export interface KbxScreenPreference {
screenId: string
screenVersion: string
density?: KbxDensity
grid?: {
columnState?: unknown
pageSize?: number
}
searchDefaults?: Record<string, unknown>
}
@@ -0,0 +1,94 @@
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
const problemTypes = new Set<KbxProblem['type']>([
'validation', 'business-rule', 'conflict', 'permission', 'not-found', 'integration', 'system',
])
export function isKbxProblem(value: unknown): value is KbxProblem {
if (!value || typeof value !== 'object') return false
const problem = value as Record<string, unknown>
return typeof problem.type === 'string'
&& problemTypes.has(problem.type as KbxProblem['type'])
&& typeof problem.title === 'string'
}
export function kbxUnexpectedProblem(correlationId: string, detail?: string, retryable = false): KbxSystemProblem {
return {
type: 'system',
code: 'UNEXPECTED_ERROR',
title: '요청을 처리하지 못했습니다.',
detail,
correlationId,
retryable,
}
}
export function isRetryableKbxProblem(value: unknown): boolean {
return isKbxProblem(value)
&& ((value.type === 'system' && value.retryable === true)
|| (value.type === 'integration' && value.retryable === true))
}
@@ -0,0 +1,24 @@
export type KbxExternalProviderPurpose = 'market-data-readonly' | 'disclosure-data-readonly'
export interface KbxExternalProviderOperationDefinition {
id: string
method: string
path: string
response: string
trId?: string
query?: readonly string[]
authOperation?: boolean
notes?: string
}
export interface KbxExternalProviderDefinition {
id: string
title: string
ownerModule: 'COMMON' | 'OMS' | 'ERP' | 'WMS'
purpose: KbxExternalProviderPurpose
mutationAllowed: boolean
officialSources: readonly string[]
official: Record<string, unknown>
kbxPolicy: Record<string, unknown>
operations: readonly KbxExternalProviderOperationDefinition[]
}
@@ -0,0 +1,26 @@
import type { KbxScreenDefinition } from './screen'
export interface KbxScreenManifestEntry {
id: string
version: string
module: KbxScreenDefinition['module']
type: KbxScreenDefinition['type']
templateCode: KbxScreenDefinition['templateCode']
title: string
route?: string
helpKey?: string
requiredPermissions: string[]
}
export interface KbxComponentManifestEntry {
name: string
category: 'primitive' | 'business' | 'template' | 'wms' | 'shell'
purpose: string
allowedUse?: string[]
forbiddenUse?: string[]
keyboard?: string[]
accessibility?: string[]
examples?: string[]
owner: 'KBX'
version: string
}
@@ -0,0 +1,87 @@
export type KbxRuntimeMode = 'normal' | 'degraded' | 'read-only' | 'offline'
export interface KbxRuntimeNotice {
mode: KbxRuntimeMode
title: string
message?: string
since?: string
correlationId?: string
retryAllowed?: boolean
}
export interface KbxDataFreshness {
observedAt: string
staleAfterSeconds?: number
source?: string
}
export type KbxOperationStatus =
| 'queued'
| 'running'
| 'completed'
| 'partially-completed'
| 'failed'
| 'cancelled'
export interface KbxOperationRun {
id: string
type: string
title: string
sourceScreenId?: string
status: KbxOperationStatus
requestedAt: string
startedAt?: string
completedAt?: string
processed?: number
total?: number
succeeded?: number
failed?: number
correlationId?: string
resultMessage?: string
}
export type KbxNotificationSeverity = 'info' | 'success' | 'warning' | 'error'
export interface KbxUserNotification {
id: string
severity: KbxNotificationSeverity
title: string
message?: string
createdAt: string
readAt?: string
screenId?: string
entityType?: string
entityId?: string
action?: {
id: string
label: string
route?: string
}
}
export interface KbxConflictFieldChange {
field: string
label: string
mine?: unknown
latest?: unknown
}
export interface KbxConflictSnapshot {
code: string
title: string
detail?: string
entityId?: string
requestedVersion?: number
currentVersion?: number
changes?: KbxConflictFieldChange[]
correlationId?: string
}
export interface KbxDiagnosticReference {
correlationId: string
requestId?: string
occurredAt: string
screenId?: string
screenVersion?: string
appVersion?: string
}
@@ -0,0 +1,120 @@
import type { KbxCommandDefinition } from './command'
export type KbxScreenType =
| 'list'
| 'master'
| 'transaction'
| 'fast-entry'
| 'master-detail'
| 'queue'
| 'reconcile'
| 'import'
| 'wms-mobile'
export type KbxTemplateMetricTone = 'default' | 'info' | 'success' | 'warning' | 'danger'
export type KbxTemplateStateCapability =
| 'idle'
| 'loading'
| 'refreshing'
| 'empty'
| 'error'
| 'permission'
| 'dirty'
| 'conflict'
| 'validation'
| 'job'
| 'network'
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[]
}
export interface KbxTemplateManifestEntry {
code: 'T01'|'T02'|'T03'|'T04'|'T05'|'T06'|'T07'|'T08'|'T09'
type: KbxScreenType
component: string
purpose: string
requiredSurfaces: string[]
optionalSurfaces: string[]
keyboard: string[]
referenceScreens: string[]
defaultDensity: 'compact' | 'comfortable' | 'touch'
runtimeStates: ('idle'|'loading'|'empty'|'error'|'ready')[]
/** State concerns the template must expose or deliberately delegate to a shared KBX component. */
stateCapabilities: KbxTemplateStateCapability[]
utilitySurfaces: ('help'|'ai'|'suggestion'|'audit')[]
accessibility: string[]
/** Canonical KBX components expected for a production-grade implementation of this template. */
coreComponents: string[]
/** Review items that must be resolved before a screen of this type is considered complete. */
completionChecks: string[]
}
export type KbxScreenTemplateCode = 'T01'|'T02'|'T03'|'T04'|'T05'|'T06'|'T07'|'T08'|'T09'
export type KbxRecipePermissionKind = 'none'|'write'|'execute'
export interface KbxScreenRecipeCommand {
id: string
label: string
group: 'query'|'edit'|'workflow'|'output'|'more'
shortcut?: string
variant?: 'primary'|'secondary'|'danger'|'ghost'
permissionKind: KbxRecipePermissionKind
}
export interface KbxScreenRecipeTestProfile {
/** Scenario kinds that must exist in the canonical recipe coverage. */
requiredScenarioKinds: ('e2e'|'integration'|'contract')[]
/** Behavioral tags that must be covered by the recipe's canonical scenarios. */
requiredTags: string[]
/** Evidence classes that must be emitted by at least one canonical scenario. */
requiredEvidence: string[]
/** Stable review/test intents generated for every screen using the recipe. */
requiredChecks: string[]
}
export interface KbxScreenRecipeDefinition {
code: KbxScreenTemplateCode
type: KbxScreenType
templateComponent: string
defaultCommands: KbxScreenRecipeCommand[]
requiredPolicies: string[]
recoveryPolicies: string[]
securityPolicies: string[]
canonicalScenarioIds: string[]
scaffoldSurfaces: string[]
testProfile: KbxScreenRecipeTestProfile
}
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,23 @@
import type { KbxLookupDefinition } from './lookup'
export type KbxSearchFieldType = 'text' | 'date' | 'date-range' | 'select' | 'lookup' | 'checkbox'
export interface KbxSearchOption {
value: string
label: string
}
export interface KbxSearchField {
key: string
label: string
type: KbxSearchFieldType
primary?: boolean
width?: 'sm' | 'md' | 'lg'
options?: KbxSearchOption[]
lookup?: KbxLookupDefinition
range?: { from: string; to: string }
placeholder?: string
defaultValue?: unknown
disabled?: boolean
helpText?: string
}
@@ -0,0 +1,16 @@
export type KbxStatusSemantic =
| 'draft'
| 'pending'
| 'processing'
| 'completed'
| 'hold'
| 'warning'
| 'error'
| 'cancelled'
| 'disabled'
export interface KbxStatusDefinition {
value: string
label: string
semantic: KbxStatusSemantic
}
@@ -0,0 +1,23 @@
export type KbxSuggestionCategory = 'inconvenience' | 'bug' | 'improvement'
export interface KbxSuggestionContext {
screenId: string
screenVersion: string
route: string
appVersion: string
userRole: string
activeFilters?: string[]
gridLayoutVersion?: string
}
export interface KbxSuggestionRequest {
category: KbxSuggestionCategory
message: string
includeScreenContext: boolean
context: KbxSuggestionContext
}
export interface KbxSuggestionResult {
suggestionId: string
receivedAt: string
}
@@ -0,0 +1,47 @@
import type { KbxTelemetryEventName } from './generated/telemetryCatalog'
export type KbxTelemetryCategory = 'navigation' | 'task' | 'interaction' | 'command' | 'lookup' | 'quality' | 'excel' | 'exception' | 'ai' | 'outcome' | 'experiment'
export interface KbxUxEvent {
eventName: KbxTelemetryEventName
screenId?: string
screenVersion?: string
sessionId: string
taskSessionId?: string
appVersion?: string
experimentId?: string
experimentVariant?: string
occurredAt: string
durationMs?: number
count?: number
attributes?: Record<string, string>
}
export interface KbxUxEventBatch {
events: KbxUxEvent[]
}
export interface KbxUxMetricRow {
metricKey: string
label: string
value: number | null
unit: 'ms' | 'percent' | 'count'
sampleCount: number
p50?: number | null
p95?: number | null
}
export interface KbxUxMetricsResponse {
from: string
to: string
screenId?: string | null
metrics: KbxUxMetricRow[]
}
export interface KbxUxWorkloadOutcome {
workType: string
observedCount: number
autoProcessedCount: number
manualInterventionCount: number
reasonCode?: string
}
@@ -0,0 +1,32 @@
export type KbxTestScenarioKind = 'e2e' | 'integration' | 'contract'
export type KbxTestIsolation = 'database-reset' | 'tenant-reset' | 'ui-only'
export interface KbxTestScenarioDefinition {
id: string
title: string
screenId: string
kind: KbxTestScenarioKind
tags: ReadonlyArray<string>
fixtureSets: ReadonlyArray<string>
requiredPermissions: ReadonlyArray<string>
apiOperations: ReadonlyArray<string>
isolation: KbxTestIsolation
steps: ReadonlyArray<Record<string, unknown>>
assertions: ReadonlyArray<string>
evidence: ReadonlyArray<string>
}
export interface KbxTestFixtureSetDefinition {
id: string
description: string
refs: ReadonlyArray<string>
}
export interface KbxGeneratedScreenTestPlan {
screenId: string
templateCode: 'T01'|'T02'|'T03'|'T04'|'T05'|'T06'|'T07'|'T08'|'T09'
canonicalScenarioIds: ReadonlyArray<string>
requiredChecks: ReadonlyArray<string>
requiredEvidence: ReadonlyArray<string>
}
@@ -0,0 +1,30 @@
export type KbxTransactionMode = 'new' | 'edit' | 'view'
export interface KbxTransactionContext<THeader, TLine> {
header: THeader
lines: TLine[]
mode: KbxTransactionMode
status: string
dirty: boolean
version?: number
}
export type KbxRecordEditability = 'editable' | 'restricted' | 'readonly'
export interface KbxRecordStatePolicy {
status: string
editability: KbxRecordEditability
editableFields?: string[]
message?: string
}
export function resolveKbxRecordStatePolicy(policies: KbxRecordStatePolicy[], status: string): KbxRecordStatePolicy {
return policies.find(x => x.status === status) ?? { status, editability: 'readonly', message: '현재 상태에서는 수정할 수 없습니다.' }
}
export function kbxFieldReadonly(policy: KbxRecordStatePolicy, field: string): boolean {
if (policy.editability === 'readonly') return true
if (policy.editability === 'editable') return false
return !(policy.editableFields ?? []).includes(field)
}
@@ -0,0 +1,37 @@
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>
}
@@ -0,0 +1,92 @@
export type KbxBarcodeSource = 'keyboard-wedge' | 'camera' | 'manual'
export interface KbxBarcodeEvent {
rawValue: string
normalizedValue: string
source: KbxBarcodeSource
occurredAt: number
}
export type KbxWmsPickingStage =
| 'ready'
| 'await-location'
| 'await-item'
| 'processing'
| 'completed'
| 'blocked'
export interface KbxWmsCurrentLine {
lineId: string
lineNo: number
locationCode: string
itemId: string
itemCode: string
itemName: string
itemOption?: string | null
barcode: string
requiredQty: number
pickedQty: number
remainingQty: number
}
export interface KbxWmsPickingTask {
taskId: string
taskNo: string
stage: KbxWmsPickingStage
status: string
completedLines: number
totalLines: number
completedQty: number
totalQty: number
version: number
currentLine?: KbxWmsCurrentLine | null
message?: string | null
}
export interface KbxWmsScanCommand {
taskId: string
barcode: string
source: KbxBarcodeSource
idempotencyKey: string
expectedVersion: number
occurredAt: string
}
export interface KbxWmsScanResult {
accepted: boolean
duplicate: boolean
task: KbxWmsPickingTask
feedback: 'success' | 'warning' | 'error' | 'neutral'
message: string
}
export type KbxWmsExceptionType =
| 'no-stock'
| 'short-quantity'
| 'wrong-location'
| 'damaged-item'
| 'barcode-issue'
| 'other'
export interface KbxWmsExceptionCommand {
taskId: string
lineId?: string | null
type: KbxWmsExceptionType
memo?: string | null
idempotencyKey: string
expectedVersion: number
}
export interface KbxWmsNetworkState {
online: boolean
pendingCommands: number
syncing: boolean
}
export interface KbxWmsSetQuantityCommand {
taskId: string
lineId: string
pickedQty: number
idempotencyKey: string
expectedVersion: number
}
@@ -0,0 +1,30 @@
import type { KbxStatusSemantic } from './status'
export interface KbxWorkflowStateDefinition {
value: string
label: string
semantic: KbxStatusSemantic
terminal?: boolean
}
export interface KbxWorkflowTransitionDefinition {
id: string
from: string[]
to: string
label: string
permission?: string
confirm?: boolean
reasonRequired?: boolean
}
export interface KbxWorkflowDefinition {
id: string
version: string
states: KbxWorkflowStateDefinition[]
transitions: KbxWorkflowTransitionDefinition[]
}
export function allowedKbxTransitions(workflow: KbxWorkflowDefinition, current: string) {
return workflow.transitions.filter(x => x.from.includes(current))
}
@@ -0,0 +1,15 @@
{
"name": "@kbx/ui",
"version": "0.1.0",
"type": "module",
"peerDependencies": {
"vue": ">=3",
"primevue": ">=4",
"ag-grid-community": ">=33",
"ag-grid-vue3": ">=33"
},
"dependencies": {
"@kbx/contracts": "workspace:*"
},
"exports": { ".": "./src/index.ts", "./tokens.css": "./src/tokens/kbx.css" }
}
@@ -0,0 +1,82 @@
import type { KbxComponentCatalogEntry } from '@kbx/contracts'
const commonInputStates = [
{ id:'default', label:'기본', state:'default' as const },
{ id:'required', label:'필수', state:'required' as const },
{ id:'readonly', label:'읽기전용', state:'readonly' as const },
{ id:'disabled', label:'사용불가', state:'disabled' as const },
{ id:'changed', label:'변경됨', state:'changed' as const },
{ id:'warning', label:'주의', state:'warning' as const },
{ id:'ai-suggested', label:'AI 제안', state:'ai-suggested' as const },
{ id:'error', label:'오류', state:'error' as const },
{ id:'keyboard', label:'키보드', state:'keyboard' as const },
]
export const kbxComponentCatalog: KbxComponentCatalogEntry[] = [
{ component:'KbxInput', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxNumberField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxMoneyField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxQuantityField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxDateField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxDateRange', group:'input', scenarios:[...commonInputStates.slice(0,4),{id:'keyboard',label:'키보드',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxSelect', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxLookup', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxButton', group:'command', scenarios:[{id:'default',label:'기본',state:'default'},{id:'disabled',label:'사용불가',state:'disabled'},{id:'loading',label:'처리중',state:'loading'},{id:'keyboard',label:'단축키',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxCommandBar', group:'command', scenarios:[{id:'default',label:'기본',state:'default'},{id:'keyboard',label:'F3/F8',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxSearchPanel', group:'command', scenarios:[{id:'default',label:'기본 조회조건',state:'default'},{id:'keyboard',label:'F3/Enter',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxPageHeader', group:'template', scenarios:[{id:'default',label:'화면명/경로/Utility',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxFormSection', group:'template', scenarios:[{id:'default',label:'기본정보',state:'default'},{id:'description',label:'설명/보조 Action',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxFormGrid', group:'template', scenarios:[{id:'two-column',label:'Desktop 2-column',state:'default',density:'compact'},{id:'narrow',label:'좁은 화면 1-column',state:'default'}], accessibility:{keyboard:false,focusVisible:false} },
{ component:'KbxFormSpan', group:'template', scenarios:[{id:'full',label:'주소/비고 Full span',state:'default'}], accessibility:{keyboard:false,focusVisible:false} },
{ component:'KbxBulkActionBar', group:'command', scenarios:[{id:'default',label:'17건 선택',state:'default'},{id:'disabled',label:'조건 불충족',state:'disabled'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxIntegrationState', group:'feedback', scenarios:[{id:'queued',label:'전송 대기',state:'default'},{id:'retrying',label:'자동 재시도',state:'loading'},{id:'delivered',label:'전송 완료',state:'default'},{id:'failed',label:'연계 실패',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxDataProvenance', group:'feedback', scenarios:[{id:'fresh',label:'최신',state:'default'},{id:'stale',label:'Stale',state:'loading'},{id:'expired',label:'만료',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxStatus', group:'feedback', scenarios:[{id:'default',label:'상태 의미',state:'default'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxToast', group:'feedback', scenarios:[{id:'default',label:'성공',state:'default'},{id:'error',label:'주의',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxConfirm', group:'overlay', scenarios:[{id:'default',label:'위험 확인',state:'default'},{id:'keyboard',label:'Esc/Primary',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxDialog', group:'overlay', scenarios:[{id:'default',label:'기본',state:'default'},{id:'keyboard',label:'Esc/Focus Trap',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxDrawer', group:'overlay', scenarios:[{id:'default',label:'상세',state:'default'},{id:'keyboard',label:'Esc/Focus Restore',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxDataGrid', group:'grid', scenarios:[{id:'default',label:'Compact',state:'default',density:'compact'},{id:'loading',label:'조회 중',state:'loading'},{id:'empty',label:'결과 없음',state:'empty'},{id:'keyboard',label:'Keyboard/F2',state:'keyboard'},{id:'fast-entry',label:'Paste/Fill Down/행복제',state:'keyboard'},{id:'all-filtered',label:'검색결과 전체선택',state:'default'},{id:'changed',label:'Changed Cell',state:'changed'},{id:'error',label:'Cell/API Error 이동',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxExcelMenu', group:'command', scenarios:[{id:'default',label:'다운로드/업로드 메뉴',state:'default'},{id:'keyboard',label:'키보드 이동',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxExcelImport', group:'template', scenarios:[{id:'default',label:'파일',state:'default'},{id:'error',label:'검증 오류',state:'error'},{id:'loading',label:'반영중',state:'loading'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxJobProgress', group:'feedback', scenarios:[{id:'default',label:'진행중',state:'default'},{id:'loading',label:'장시간 처리',state:'loading'},{id:'error',label:'부분실패',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxAuditTrail', group:'feedback', scenarios:[{id:'default',label:'변경이력',state:'default'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxHelpPanel', group:'overlay', scenarios:[{id:'default',label:'화면 도움말',state:'default'},{id:'keyboard',label:'Esc/Focus',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxAiPanel', group:'overlay', scenarios:[{id:'default',label:'AI 보조',state:'default'},{id:'loading',label:'답변 준비',state:'loading'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxProposalPanel', group:'overlay', scenarios:[{id:'default',label:'AI 변경안 검토',state:'default'},{id:'error',label:'검증 실패',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxListPage', group:'template', scenarios:[{id:'default',label:'Search/List',state:'default',density:'compact'},{id:'keyboard',label:'F3',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxMasterPage', group:'template', scenarios:[{id:'default',label:'Master CRUD',state:'default',density:'compact'},{id:'keyboard',label:'F8',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxTransactionPage', group:'template', scenarios:[{id:'default',label:'Desktop Compact',state:'default',density:'compact'},{id:'keyboard',label:'F2/F8',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxMasterDetailPage', group:'template', scenarios:[{id:'default',label:'Master/Detail',state:'default',density:'compact'},{id:'context',label:'선택 Context + 이력',state:'default',density:'compact'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxQueuePage', group:'template', scenarios:[{id:'default',label:'Work Queue',state:'default',density:'compact'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxReconcilePage', group:'template', scenarios:[{id:'default',label:'Reconcile',state:'default',density:'compact'},{id:'error',label:'불일치',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxWmsMobilePage', group:'wms', scenarios:[{id:'default',label:'Touch',state:'default',density:'touch'},{id:'error',label:'현장 오류',state:'error'},{id:'keyboard',label:'Scanner',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxApplicationShell', group:'shell', scenarios:[{id:'default',label:'Desktop',state:'default',density:'compact'},{id:'module',label:'Header↔SideNav 모듈 동기화',state:'default'},{id:'keyboard',label:'메뉴검색',state:'keyboard'},{id:'overflow',label:'우선 항목 Overflow·전체 건수',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxCheckbox', group:'input', scenarios:[{id:'default',label:'기본',state:'default'},{id:'disabled',label:'사용불가',state:'disabled'},{id:'readonly',label:'읽기전용',state:'readonly'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxRadio', group:'input', scenarios:[{id:'default',label:'기본',state:'default'},{id:'disabled',label:'사용불가',state:'disabled'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxTextarea', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxTabs', group:'template', scenarios:[{id:'default',label:'상세 Tabs',state:'default'},{id:'keyboard',label:'Keyboard',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxBadge', group:'feedback', scenarios:[{id:'default',label:'상태 보조',state:'default'},{id:'error',label:'오류',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxTooltip', group:'feedback', scenarios:[{id:'default',label:'Hover',state:'default'},{id:'keyboard',label:'Focus',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxBarcodeField', group:'input', scenarios:commonInputStates, accessibility:{keyboard:true,focusVisible:true,labelRequired:true} },
{ component:'KbxSectionHeader', group:'template', scenarios:[{id:'default',label:'Detail Section',state:'default'}], accessibility:{keyboard:true,focusVisible:true} },
{ component:'KbxQuickFilterBar', group:'command', scenarios:[{id:'default',label:'KPI Quick Filter',state:'default'},{id:'error',label:'예외 Filter',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxDataState', group:'feedback', scenarios:[{id:'idle',label:'조회 전',state:'idle'},{id:'loading',label:'조회 중',state:'loading'},{id:'empty',label:'결과 없음',state:'empty'},{id:'error',label:'복구 가능 오류',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxTemplateStateBoundary', group:'feedback', scenarios:[{id:'idle',label:'조회 전',state:'idle'},{id:'ready',label:'정상',state:'ready'},{id:'refreshing',label:'재조회 중 Context 유지',state:'refreshing'},{id:'error-retry',label:'오류 복구',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxSummaryBar', group:'feedback', scenarios:[{id:'default',label:'조회/선택/합계',state:'default'}], accessibility:{keyboard:false,focusVisible:false} },
{ component:'KbxHomePage', group:'shell', scenarios:[{id:'default',label:'업무 Workbench Home',state:'default',density:'compact'},{id:'attention',label:'실패 작업·중요 알림 우선',state:'error'},{id:'dirty-first',label:'미저장 업무 우선',state:'warning'},{id:'keyboard',label:'메뉴검색',state:'keyboard'},{id:'overflow',label:'우선 항목 Overflow·전체 건수',state:'default'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxAccessDenied', group:'shell', scenarios:[{id:'error',label:'권한 없음',state:'error'},{id:'secure-default',label:'화면 정보 비노출',state:'default'},{id:'recovery',label:'홈/메뉴검색 복구',state:'default'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxRouteNotFound', group:'shell', scenarios:[{id:'recovery',label:'잘못된 딥링크 복구',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxTemplateContextBar', group:'template', scenarios:[{id:'default',label:'조회/선택/기준시각 문맥',state:'default'},{id:'error',label:'오류 문맥',state:'error'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxProgressSteps', group:'template', scenarios:[{id:'default',label:'단계 진행',state:'default'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxLookupDialog', group:'overlay', scenarios:[{id:'default',label:'검색/선택',state:'default'},{id:'loading',label:'조회 중',state:'loading'},{id:'empty',label:'결과 없음',state:'empty'},{id:'error',label:'조회 실패/재시도',state:'error'},{id:'keyboard',label:'↑↓/Enter/Esc',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,labelRequired:true,colorIndependentStatus:true} },
{ component:'KbxValidationSummary', group:'feedback', scenarios:[{id:'error',label:'입력 오류 요약',state:'error'},{id:'keyboard',label:'오류 위치 이동',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxExceptionSummary', group:'feedback', scenarios:[{id:'default',label:'예외 카운터',state:'default'},{id:'error',label:'중요 예외',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxExceptionDetailDrawer', group:'overlay', scenarios:[{id:'default',label:'원인/대상/후속작업',state:'default'},{id:'error',label:'Critical 예외',state:'error'},{id:'disabled',label:'권한 없는 Action',state:'disabled'},{id:'keyboard',label:'Drawer Focus/Esc',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxRecordLifecycle', group:'feedback', scenarios:[{id:'default',label:'Workflow/Version/Audit',state:'default'},{id:'changed',label:'Dirty',state:'changed'},{id:'error',label:'Conflict',state:'error'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxBarcodeCapture', group:'wms', scenarios:[{id:'default',label:'Keyboard Wedge',state:'default',density:'touch'},{id:'disabled',label:'Scan Paused',state:'disabled',density:'touch'},{id:'keyboard',label:'Enter 종결/중복 Debounce',state:'keyboard',density:'touch'},{id:'error',label:'Oversized/Invalid 입력 무시',state:'error',density:'touch'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxNetworkIndicator', group:'wms', scenarios:[{id:'default',label:'온라인',state:'default',density:'touch'},{id:'loading',label:'동기화/전송 대기',state:'loading',density:'touch'},{id:'error',label:'오프라인',state:'error',density:'touch'}], accessibility:{keyboard:false,focusVisible:false,colorIndependentStatus:true} },
{ component:'KbxWmsActionButton', group:'wms', scenarios:[{id:'default',label:'Touch CTA',state:'default',density:'touch'},{id:'disabled',label:'사용불가',state:'disabled',density:'touch'},{id:'loading',label:'처리중',state:'loading',density:'touch'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxAiAssistant', group:'overlay', scenarios:[{id:'default',label:'현재 업무 질문',state:'default'},{id:'loading',label:'답변 준비',state:'loading'},{id:'error',label:'조회 실패/재질문',state:'error'},{id:'disabled',label:'Capability/Permission Guard',state:'disabled'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
{ component:'KbxUtilityRail', group:'overlay', scenarios:[{id:'default',label:'Help/AI/제안',state:'default'},{id:'error',label:'AI 오류 복구',state:'error'},{id:'keyboard',label:'Panel Focus/Esc',state:'keyboard'}], accessibility:{keyboard:true,focusVisible:true,colorIndependentStatus:true} },
]
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { KbxAiAnswer, KbxAiAnswerAction, KbxAiScreenContext } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
const props = defineProps<{
context: KbxAiScreenContext
answer?: KbxAiAnswer | null
loading?: boolean
error?: string
currentScreenLabel?: string
quickQuestions?: string[]
can?: (permission:string)=>boolean
}>()
const emit = defineEmits<{
ask: [question: string]
action: [actionId: string]
openProposal: []
}>()
const permissionHost=inject(KbxPermissionHostKey,null)
const question = ref('')
const lastSubmitted = ref('')
const canAsk = computed(() => question.value.trim().length > 1 && !props.loading)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??false)}
function actionAllowed(action:KbxAiAnswerAction){
const capabilityAllowed=!action.requiredCapability||props.context.allowedCapabilities.includes(action.requiredCapability)
const permissionAllowed=!action.requiredPermission||canPermission(action.requiredPermission)
return capabilityAllowed&&permissionAllowed
}
const actions=computed(()=>props.answer?.actions?.filter(actionAllowed)??[])
const proposalAllowed=computed(()=>{
const proposal=props.answer?.proposal
if(!proposal)return false
if(!props.context.allowedCapabilities.includes(proposal.capability))return false
if(proposal.requiredPermission&&!canPermission(proposal.requiredPermission))return false
return true
})
const proposalBlocked=computed(()=>Boolean(props.answer?.proposal)&&!proposalAllowed.value)
function ask(value = question.value) {
const normalized = value.trim()
if (normalized.length < 2 || props.loading) return
lastSubmitted.value=normalized
emit('ask', normalized)
question.value = ''
}
function retry(){if(lastSubmitted.value&&!props.loading)emit('ask',lastSubmitted.value)}
</script>
<template>
<section class="kbx-ai" aria-labelledby="kbx-ai-title" data-kbx-component="ai-assistant" :aria-busy="loading || undefined">
<header>
<h2 id="kbx-ai-title">AI 도우미</h2>
<small>{{ currentScreenLabel || '현재 업무' }}</small>
</header>
<div v-if="quickQuestions?.length" class="kbx-ai__quick" aria-label="빠른 질문">
<KbxButton v-for="item in quickQuestions" :key="item" :label="item" variant="secondary" :disabled="loading" @click="ask(item)" />
</div>
<div v-if="error" class="kbx-ai__error" role="alert">
<strong>AI 답변을 가져오지 못했습니다.</strong><span>{{error}}</span><KbxButton v-if="lastSubmitted" label="다시 질문" variant="secondary" :loading="loading" @click="retry" />
</div>
<article v-if="answer" class="kbx-ai__answer" aria-live="polite">
<p>{{ answer.answer }}</p>
<div v-if="answer.evidence?.length" class="kbx-ai__evidence">
<strong>근거</strong>
<ul>
<li v-for="evidence in answer.evidence" :key="`${evidence.sourceType}:${evidence.reference ?? evidence.label}`">
{{ evidence.label }} <small>({{ evidence.sourceType }})</small>
</li>
</ul>
</div>
<div v-if="actions.length" class="kbx-ai__actions">
<KbxButton v-for="action in actions" :key="action.id" :label="action.label" variant="secondary" @click="emit('action', action.id)" />
</div>
<KbxButton v-if="answer.proposal && proposalAllowed" label="변경 제안 확인" variant="secondary" @click="emit('openProposal')" />
<p v-else-if="proposalBlocked" class="kbx-ai__guard" role="status">현재 사용자 권한 또는 AI 허용 범위를 벗어난 변경 제안은 실행할 없습니다.</p>
</article>
<form class="kbx-ai__ask" @submit.prevent="ask()">
<label for="kbx-ai-question">질문</label>
<textarea id="kbx-ai-question" v-model="question" rows="3" maxlength="1000" placeholder="현재 업무에 대해 질문하세요." />
<KbxButton label="질문" variant="primary" :disabled="!canAsk" :loading="loading" @click="ask()" />
</form>
<p class="kbx-ai__guard">AI 답변은 업무 근거를 설명하거나 제안합니다. 실제 변경은 권한·대상 식별·서버 업무규칙 검증을 거쳐 별도 실행합니다.</p>
</section>
</template>
<style scoped>
.kbx-ai{display:grid;gap:var(--kbx-space-3);padding:var(--kbx-space-4);font-size:var(--kbx-font-md)}header{display:flex;justify-content:space-between;align-items:baseline;gap:var(--kbx-space-2)}h2{margin:0;font-size:var(--kbx-font-xl)}header small{color:var(--kbx-color-text-muted)}.kbx-ai__quick,.kbx-ai__actions{display:flex;gap:var(--kbx-space-2);flex-wrap:wrap}.kbx-ai__answer{display:grid;gap:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);padding:var(--kbx-space-3);background:var(--kbx-color-surface-muted)}.kbx-ai__answer p{margin:0;white-space:pre-wrap;line-height:1.55}.kbx-ai__evidence{display:grid;gap:var(--kbx-space-1)}.kbx-ai__evidence ul{margin:0;padding-left:var(--kbx-space-5);color:var(--kbx-color-text-muted)}.kbx-ai__error{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.kbx-ai__error span{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-ai__ask{display:grid;gap:var(--kbx-space-2)}textarea{resize:vertical;padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);font:inherit}.kbx-ai__ask :deep(.p-button){justify-self:end}.kbx-ai__guard{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);line-height:1.45}
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxAiAnswer, KbxAiScreenContext } from '@kbx/contracts'
import KbxAiAssistant from './KbxAiAssistant.vue'
defineProps<{ context:KbxAiScreenContext; answer?:KbxAiAnswer|null; loading?:boolean; error?:string; currentScreenLabel?:string; quickQuestions?:string[] }>()
const emit=defineEmits<{ ask:[string]; action:[string]; openProposal:[] }>()
</script>
<template><KbxAiAssistant :context="context" :answer="answer" :loading="loading" :error="error" :current-screen-label="currentScreenLabel" :quick-questions="quickQuestions" @ask="emit('ask',$event)" @action="emit('action',$event)" @open-proposal="emit('openProposal')"/></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import type { KbxAuditEntry } from '@kbx/contracts'; defineProps<{entries:KbxAuditEntry[]}>()
</script>
<template><section class="kbx-audit" aria-label="변경이력"><div v-if="!entries.length" class="kbx-audit__empty">변경이력이 없습니다.</div><article v-for="entry in entries" :key="entry.id" class="kbx-audit__entry"><header><strong>{{entry.actor.displayName}}</strong><time>{{entry.occurredAt}}</time><span>{{entry.action}}</span></header><dl v-if="entry.changes?.length"><template v-for="c in entry.changes" :key="c.field"><dt>{{c.label}}</dt><dd><span>{{c.before ?? '-'}}</span><b aria-hidden="true">→</b><span>{{c.after ?? '-'}}</span></dd></template></dl><p v-if="entry.reason">사유: {{entry.reason}}</p></article></section></template>
<style scoped>.kbx-audit{display:grid;gap:var(--kbx-space-3)}.kbx-audit__entry{padding-bottom:var(--kbx-space-3);border-bottom:1px solid var(--kbx-color-border)}header{display:flex;gap:var(--kbx-space-2);align-items:center;font-size:var(--kbx-font-sm)}time{color:var(--kbx-color-text-muted)}dl{display:grid;grid-template-columns:120px 1fr;gap:4px 8px;margin:8px 0}dt{font-weight:500}dd{margin:0;display:flex;gap:8px}.kbx-audit p,.kbx-audit__empty{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
withDefaults(defineProps<{label:string;tone?:'neutral'|'info'|'success'|'warning'|'danger'}>(),{tone:'neutral'})
</script>
<template><span class="kbx-badge" :data-tone="tone">{{label}}</span></template>
<style scoped>.kbx-badge{display:inline-flex;align-items:center;min-height:calc(var(--kbx-space-5) + var(--kbx-space-1));padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-pill);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-xs);white-space:nowrap}.kbx-badge[data-tone="info"]{color:var(--kbx-color-primary);border-color:var(--kbx-color-info-border);background:var(--kbx-color-info-surface)}.kbx-badge[data-tone="success"]{color:var(--kbx-color-success);border-color:var(--kbx-color-success-border);background:var(--kbx-color-success-surface)}.kbx-badge[data-tone="warning"]{color:var(--kbx-color-warning-text);border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-badge[data-tone="danger"]{color:var(--kbx-color-danger);border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
import KbxInput from './KbxInput.vue'
withDefaults(defineProps<{modelValue?:string|null;label?:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;placeholder?:string}>(),{label:'바코드',modelValue:'',state:'default'})
const emit=defineEmits<{ 'update:modelValue':[string]; enter:[] }>()
</script>
<template><KbxInput :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="error" :warning="warning" :help-text="helpText" :state="state" :placeholder="placeholder" @update:model-value="emit('update:modelValue',$event.trim())" @enter="emit('enter')"/></template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { inject } from 'vue'
import type { KbxCommandDefinition } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import { KbxPermissionHostKey } from '../permission/host'
const props=defineProps<{selectionCount:number;actions:KbxCommandDefinition[];can?:(permission:string)=>boolean}>()
const emit=defineEmits<{ command:[string] }>()
const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
function visible(a:KbxCommandDefinition){return !a.permission||canPermission(a.permission)}
function disabled(a:KbxCommandDefinition){return (a.minSelection!=null&&props.selectionCount<a.minSelection)||(a.maxSelection!=null&&props.selectionCount>a.maxSelection)}
</script>
<template><div v-if="selectionCount>0" class="kbx-bulk" role="toolbar" :aria-label="`${selectionCount}건 선택 업무`"><strong>{{selectionCount.toLocaleString()}}건 선택</strong><KbxButton v-for="a in actions.filter(visible)" :key="a.id" :label="a.label" :variant="a.variant" :disabled="disabled(a)" @click="emit('command',a.id)"/></div></template>
<style scoped>.kbx-bulk{display:flex;align-items:center;gap:var(--kbx-space-2);min-height:44px;padding:6px var(--kbx-space-3);border:1px solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);border-radius:var(--kbx-radius-sm)}.kbx-bulk strong{margin-right:var(--kbx-space-2);font-size:var(--kbx-font-sm)}</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import Button from 'primevue/button'
const props = withDefaults(defineProps<{
label: string
shortcut?: string
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
disabled?: boolean
loading?: boolean
title?: string
}>(), { variant: 'secondary' })
const emit = defineEmits<{ click: [MouseEvent] }>()
</script>
<template>
<Button
:label="shortcut ? `${label} ${shortcut}` : label"
:disabled="disabled"
:loading="loading"
:title="title"
:severity="variant === 'danger' ? 'danger' : undefined"
:outlined="variant === 'secondary'"
:text="variant === 'ghost'"
@click="emit('click', $event)"
/>
</template>
@@ -0,0 +1,10 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{modelValue?:boolean;label:string;disabled?:boolean;readonly?:boolean;helpText?:string;state?:KbxFieldState}>(),{modelValue:false,disabled:false,readonly:false,helpText:'',state:'default'})
const emit=defineEmits<{ 'update:modelValue':[boolean] }>()
const uid=`kbx-checkbox-${Math.random().toString(36).slice(2)}`
function change(event:Event){if(props.readonly||props.disabled)return;emit('update:modelValue',(event.currentTarget as HTMLInputElement).checked)}
function preventReadonly(event:Event){if(props.readonly)event.preventDefault()}
</script>
<template><label class="kbx-checkbox" :data-state="state" :data-readonly="readonly||undefined"><input :id="uid" type="checkbox" :checked="modelValue" :disabled="disabled" :aria-readonly="readonly||undefined" @click="preventReadonly" @keydown.space="preventReadonly" @change="change"><span>{{label}}</span><small v-if="helpText">{{helpText}}</small></label></template>
<style scoped>.kbx-checkbox{min-height:var(--kbx-control-height);display:grid;grid-template-columns:var(--kbx-checkbox-track) auto minmax(0,1fr);align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-md)}.kbx-checkbox input{width:var(--kbx-checkbox-size);height:var(--kbx-checkbox-size);margin:0}.kbx-checkbox small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-checkbox[data-readonly="true"]{color:var(--kbx-color-text-muted)}.kbx-checkbox[data-state="changed"] span{color:var(--kbx-color-primary)}.kbx-checkbox[data-state="warning"] span{color:var(--kbx-color-warning-text)}.kbx-checkbox[data-state="ai-suggested"] span{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import type { KbxCommandDefinition, KbxCommandGroup } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxConfirm from './KbxConfirm.vue'
import { KbxPermissionHostKey } from '../permission/host'
const props = withDefaults(defineProps<{ commands:KbxCommandDefinition[]; selectionCount?:number; status?:string; dirty?:boolean; can?:(permission:string)=>boolean }>(), { selectionCount:0, status:'', dirty:false })
const emit=defineEmits<{ command:[string] }>()
const pending=ref<KbxCommandDefinition|null>(null)
const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
function invoke(command:KbxCommandDefinition){if(reason(command))return;if(command.confirm){pending.value=command;return}emit('command',command.id)}
function confirmPending(){const command=pending.value;if(!command)return;pending.value=null;emit('command',command.id)}
const groupOrder:KbxCommandGroup[]=['query','edit','workflow','output','more']
function effectivePermission(c:KbxCommandDefinition){return (props.status&&c.permissionByStatus?.[props.status])||c.permission}
function visible(c:KbxCommandDefinition){const permission=effectivePermission(c);return !permission||canPermission(permission)}
function reason(c:KbxCommandDefinition){
const n=props.selectionCount
if(c.requiresSelection&&n===0)return '처리할 항목을 선택하세요.'
if(c.minSelection!=null&&n<c.minSelection)return `${c.minSelection} 이상 선택하세요.`
if(c.maxSelection!=null&&n>c.maxSelection)return `${c.maxSelection}건 이하로 선택하세요.`
if(c.allowedStatuses?.length&&props.status&&!c.allowedStatuses.includes(props.status))return c.disabledReason??`현재 상태(${props.status})에서는 실행할 수 없습니다.`
if(c.requiresDirty&&!props.dirty)return c.disabledReason??'변경된 내용이 없습니다.'
if(c.requiresClean&&props.dirty)return c.disabledReason??'변경사항을 먼저 저장하세요.'
return ''
}
const groups=computed(()=>groupOrder.map(group=>({group,commands:props.commands.filter(c=>c.group===group&&visible(c))})).filter(x=>x.commands.length))
</script>
<template><div class="kbx-command-bar" role="toolbar" aria-label="화면 명령"><template v-for="(item,index) in groups" :key="item.group"><div class="kbx-command-group" :data-group="item.group"><KbxButton v-for="command in item.commands" :key="command.id" :label="command.label" :shortcut="command.shortcut" :variant="command.variant" :disabled="Boolean(reason(command))" :title="reason(command)||undefined" @click="invoke(command)"/></div><span v-if="index<groups.length-1" class="kbx-command-separator" aria-hidden="true"/></template></div><KbxConfirm v-if="pending?.confirm" :open="Boolean(pending)" :title="pending.confirm.title" :detail="pending.confirm.detail" :level="pending.confirm.level" :confirm-label="pending.confirm.confirmLabel??pending.label" @update:open="value=>{if(!value)pending=null}" @confirm="confirmPending"/></template>
<style scoped>.kbx-command-bar{display:flex;align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-command-bar-height);overflow-x:auto}.kbx-command-group{display:flex;align-items:center;gap:var(--kbx-space-2);flex-shrink:0}.kbx-command-separator{width:var(--kbx-border-width);height:var(--kbx-command-separator-height);background:var(--kbx-color-border);flex-shrink:0}</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import KbxDialog from './KbxDialog.vue'
import KbxButton from './KbxButton.vue'
withDefaults(defineProps<{open:boolean;title:string;detail:string;level?:'low'|'medium'|'high';confirmLabel:string}>(),{level:'medium'})
const emit=defineEmits<{ 'update:open':[boolean]; confirm:[] }>()
</script>
<template><KbxDialog :open="open" :title="title" size="sm" @update:open="emit('update:open',$event)"><p class="kbx-confirm__detail">{{detail}}</p><template #footer><KbxButton label="취소" @click="emit('update:open',false)"/><KbxButton :label="confirmLabel" :variant="level==='high'?'danger':'primary'" @click="emit('confirm')"/></template></KbxDialog></template>
<style scoped>.kbx-confirm__detail{white-space:pre-line;line-height:1.5;color:var(--kbx-color-text)}</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { KbxConflictSnapshot } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
defineProps<{ conflict: KbxConflictSnapshot }>()
const emit = defineEmits<{ reload: []; cancel: [] }>()
</script>
<template>
<section class="kbx-conflict" role="alert">
<header>
<strong>{{ conflict.title }}</strong>
<span v-if="conflict.detail">{{ conflict.detail }}</span>
</header>
<div v-if="conflict.changes?.length" class="changes">
<div class="head"><span>항목</span><span> 화면</span><span>최신 </span></div>
<div v-for="change in conflict.changes" :key="change.field" class="row">
<strong>{{ change.label }}</strong>
<span>{{ change.mine ?? '-' }}</span>
<span>{{ change.latest ?? '-' }}</span>
</div>
</div>
<p v-if="conflict.correlationId" class="reference">참조번호 {{ conflict.correlationId }}</p>
<footer>
<KbxButton label="계속 편집" variant="secondary" @click="emit('cancel')" />
<KbxButton label="최신 내용 보기" variant="primary" @click="emit('reload')" />
</footer>
</section>
</template>
<style scoped>
.kbx-conflict { display:grid; gap:12px; padding:16px; border:1px solid var(--kbx-color-warning); border-radius:var(--kbx-radius-md); background:var(--kbx-color-surface); }
header { display:grid; gap:4px; } header span,.reference { color:var(--kbx-color-text-muted); }
.changes { border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); overflow:hidden; }
.head,.row { display:grid; grid-template-columns:minmax(120px,1fr) minmax(120px,1fr) minmax(120px,1fr); gap:8px; padding:8px 10px; }
.head { background:var(--kbx-color-surface-muted); font-size:var(--kbx-font-sm); color:var(--kbx-color-text-muted); }
.row + .row { border-top:1px solid var(--kbx-color-border); }
footer { display:flex; justify-content:flex-end; gap:8px; }
.reference { margin:0; font-size:var(--kbx-font-sm); }
</style>
@@ -0,0 +1,270 @@
<script setup lang="ts" generic="T extends object">
import { computed, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import { kbxGridTheme } from '../theme/kbxGridTheme'
import type {
KbxGridCellRef, KbxGridColumn, KbxGridColumnPreference, KbxGridContextRequest,
KbxGridDensity, KbxGridEditingPolicy, KbxGridPasteResult, KbxGridSummary,
KbxSelectionState, KbxValidationError,
} from '@kbx/contracts'
import { normalizeKbxGridClipboardData } from '../grid/editing'
import KbxButton from './KbxButton.vue'
import KbxDataState from './KbxDataState.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
const props = withDefaults(defineProps<{
rows: T[]
columns: KbxGridColumn<T>[]
rowKey: keyof T & string
loading?: boolean
errorText?: string
emptyText?: string
selection?: 'none' | 'single' | 'multiple'
selectionState?: KbxSelectionState<string>
totalCount?: number
allowAllFilteredSelection?: boolean
editable?: boolean
editingPolicy?: KbxGridEditingPolicy
clipboard?: boolean
personalization?: boolean
savedPreference?: KbxGridColumnPreference[]
exportable?: boolean
exportFileName?: string
density?: KbxGridDensity
errors?: KbxValidationError[]
changedCells?: KbxGridCellRef<string>[]
summary?: KbxGridSummary<T>[]
activeRowKey?: string | number | null
}>(), {
loading:false,
errorText:'',
emptyText:'조회된 데이터가 없습니다.',
selection:'none',
totalCount:0,
allowAllFilteredSelection:false,
editable:false,
clipboard:true,
personalization:false,
exportable:false,
exportFileName:'kbx-grid.csv',
density:'compact',
errors:()=>[],
changedCells:()=>[],
summary:()=>[],
activeRowKey:null,
})
const emit = defineEmits<{
selectionChanged:[T[]]
selectionStateChanged:[KbxSelectionState<string>]
rowDoubleClicked:[T]
drillDownRequested:[T]
cellChanged:[{row:T;field:keyof T & string;oldValue:unknown;newValue:unknown}]
lookupRequested:[{row:T;field:keyof T & string;entity:string}]
rowAddRequested:[]
rowDuplicateRequested:[T[]]
fillDownApplied:[{field:keyof T & string;count:number}]
pasteProcessed:[KbxGridPasteResult]
errorFocusChanged:[KbxGridCellRef<string>]
contextRequested:[KbxGridContextRequest<T>]
retry:[]
preferenceChanged:[KbxGridColumnPreference[]]
preferenceReset:[]
}>()
const gridApi=ref<any>(null)
const selectedRows=ref<T[]>([])
const allFilteredActive=ref(props.selectionState?.mode==='all-filtered')
const excludedIds=ref<string[]>(props.selectionState?.excludedIds??[])
const errorCursor=ref(-1)
watch(()=>props.selectionState, value=>{
allFilteredActive.value=value?.mode==='all-filtered'
excludedIds.value=value?.excludedIds??[]
},{deep:true})
const firstLoading=computed(()=>props.loading&&props.rows.length===0)
const empty=computed(()=>!props.loading&&!props.errorText&&props.rows.length===0)
const blockingError=computed(()=>Boolean(props.errorText)&&props.rows.length===0)
const showToolbar=computed(()=>props.exportable||props.personalization||props.errors.length>0||selectedCount.value>0||Boolean(props.editingPolicy)||canSelectAllFiltered.value)
const selectedCount=computed(()=>allFilteredActive.value?Math.max((props.totalCount||props.rows.length)-excludedIds.value.length,0):selectedRows.value.length)
const canSelectAllFiltered=computed(()=>props.allowAllFilteredSelection&&props.selection==='multiple'&&(props.totalCount||0)>props.rows.length)
const canDuplicate=computed(()=>Boolean(props.editable&&props.editingPolicy?.allowRowDuplicate&&selectedRows.value.length>0&&!allFilteredActive.value))
const canFillDown=computed(()=>Boolean(props.editable&&props.editingPolicy?.fillDown&&selectedRows.value.length>1&&!allFilteredActive.value))
function formatNumber(value:unknown){if(value==null||value==='')return'';return new Intl.NumberFormat('ko-KR').format(Number(value))}
function errorFor(row:T,field:string){const id=String(row[props.rowKey]);return props.errors.find(e=>e.rowKey===id&&e.field===field)}
function changedFor(row:T,field:string){const id=String(row[props.rowKey]);return props.changedCells.some(x=>String(x.rowKey)===id&&x.field===field)}
function columnFor(field:string){return props.columns.find(c=>c.field===field)}
function isEditable(column:KbxGridColumn<T>|undefined,row:T){if(!column||!props.editable)return false;return typeof column.editable==='function'?column.editable(row):(column.editable??false)}
const columnDefs=computed<ColDef<T>[]>(()=>props.columns.map(c=>({
field:c.field,headerName:c.header,width:c.width,minWidth:c.minWidth,maxWidth:c.maxWidth,pinned:c.pinned,
editable:params=>Boolean(params.data&&isEditable(c,params.data)),
sortable:c.sortable??true,filter:c.filterable??true,resizable:true,
valueFormatter:c.type==='money'||c.type==='quantity'?p=>formatNumber(p.value):undefined,
valueParser:c.type==='money'||c.type==='quantity'||c.type==='integer'||c.type==='decimal'||c.type==='percent'?p=>Number(String(p.newValue??'').replace(/,/g,'')):undefined,
cellClass:params=>{
const classes:string[]=[]
if(c.type==='money'||c.type==='quantity'||c.type==='integer'||c.type==='decimal'||c.type==='percent')classes.push('kbx-cell--numeric')
if(c.type==='date'||c.type==='datetime'||c.type==='status'||c.type==='boolean')classes.push('kbx-cell--center')
if(c.type==='link'||c.drilldown)classes.push('kbx-cell--link')
if(params.data&&changedFor(params.data,c.field))classes.push('kbx-cell--changed')
if(params.data&&errorFor(params.data,c.field))classes.push('kbx-cell--invalid')
return classes
},
tooltipValueGetter:params=>params.data?errorFor(params.data,c.field)?.message:undefined,
suppressKeyboardEvent:params=>Boolean(c.lookup&&params.event.key==='F2'),
})))
const summaryItems=computed(()=>props.summary.map(item=>{
let value:string|number=item.value??''
if(item.kind==='count')value=props.totalCount||props.rows.length
else if(item.kind==='sum'&&item.field)value=props.rows.reduce((sum,row)=>sum+(Number(row[item.field!])||0),0)
return {key:item.key,label:item.label,value}
}))
function currentPreference():KbxGridColumnPreference[]{
const state=(gridApi.value?.getColumnState?.()??[]) as any[]
return state.map((x,index)=>({field:String(x.colId),order:index,width:x.width,pinned:x.pinned??null,hidden:Boolean(x.hide),sort:x.sort??null,sortIndex:x.sortIndex??null}))
}
function savePreference(){if(props.personalization&&gridApi.value)emit('preferenceChanged',currentPreference())}
function restorePreference(){
if(!props.personalization||!props.savedPreference?.length||!gridApi.value)return
const ordered=[...props.savedPreference].sort((a,b)=>a.order-b.order)
gridApi.value.applyColumnState({state:ordered.map(x=>({colId:x.field,width:x.width,pinned:x.pinned,hide:x.hidden,sort:x.sort,sortIndex:x.sortIndex})),applyOrder:true})
}
function resetColumns(){gridApi.value?.resetColumnState();emit('preferenceReset')}
function exportCsv(){gridApi.value?.exportDataAsCsv({fileName:props.exportFileName})}
function syncActiveRow(){
if(!gridApi.value||props.activeRowKey==null)return
const node=gridApi.value.getRowNode?.(String(props.activeRowKey));if(!node)return
if(props.selection==='single'){gridApi.value.deselectAll?.();node.setSelected?.(true)}
if(node.rowIndex!=null)gridApi.value.ensureNodeVisible?.(node,'middle')
}
function onGridReady(event:any){gridApi.value=event.api;restorePreference();syncActiveRow()}
watch([()=>props.activeRowKey,()=>props.rows],()=>syncActiveRow(),{deep:true,flush:'post'})
function onSelectionChanged(event:any){
const rows=event.api.getSelectedRows() as T[]
selectedRows.value=rows
emit('selectionChanged',rows)
const ids=rows.map(row=>String(row[props.rowKey]))
if(allFilteredActive.value){
const pageIds=props.rows.map(row=>String(row[props.rowKey]))
const selected=new Set(ids)
const excluded=new Set(excludedIds.value)
for(const id of pageIds) selected.has(id)?excluded.delete(id):excluded.add(id)
excludedIds.value=[...excluded]
emit('selectionStateChanged',{mode:'all-filtered',selectedIds:[],excludedIds:excludedIds.value})
}else emit('selectionStateChanged',{mode:'explicit',selectedIds:ids})
}
function toggleAllFiltered(){
if(allFilteredActive.value){
allFilteredActive.value=false;excludedIds.value=[];gridApi.value?.deselectAll();emit('selectionStateChanged',{mode:'explicit',selectedIds:[]});return
}
allFilteredActive.value=true;excludedIds.value=[];gridApi.value?.selectAll();emit('selectionStateChanged',{mode:'all-filtered',selectedIds:[],excludedIds:[]})
}
function onCellKeyDown(event:any){
if(event.event?.key!=='F2'||!event.data||!event.colDef?.field)return
const column=columnFor(event.colDef.field);if(!column?.lookup)return
event.event.preventDefault();emit('lookupRequested',{row:event.data,field:column.field,entity:column.lookup.entity})
}
function onCellValueChanged(event:any){emit('cellChanged',{row:event.data,field:event.colDef.field,oldValue:event.oldValue,newValue:event.newValue})}
function onCellClicked(event:any){const column=columnFor(event.colDef?.field);if(event.data&&(column?.type==='link'||column?.drilldown))emit('drillDownRequested',event.data)}
function onCellContextMenu(event:any){
if(!event.data||!event.colDef?.field)return
event.event?.preventDefault?.()
emit('contextRequested',{row:event.data,field:event.colDef.field,clientX:event.event?.clientX??0,clientY:event.event?.clientY??0})
}
function duplicateRows(){if(canDuplicate.value)emit('rowDuplicateRequested',[...selectedRows.value])}
function fillDown(){
if(!canFillDown.value||!gridApi.value)return
gridApi.value.stopEditing?.()
const focused=gridApi.value.getFocusedCell?.();if(!focused)return
const field=String(focused.column?.getColId?.()??'');const column=columnFor(field);if(!column)return
const sourceNode=gridApi.value.getDisplayedRowAtIndex?.(focused.rowIndex);const source=sourceNode?.data as T|undefined
if(!source||!isEditable(column,source))return
const value=(source as any)[field]
let count=0
for(const node of gridApi.value.getSelectedNodes?.()??[]){
if(!node?.data||node===sourceNode||!isEditable(column,node.data))continue
node.setDataValue?.(field,value);count++
}
if(count)emit('fillDownApplied',{field:column.field,count})
}
function focusError(direction:1|-1=1){
if(!props.errors.length||!gridApi.value)return
errorCursor.value=(errorCursor.value+direction+props.errors.length)%props.errors.length
const error=props.errors[errorCursor.value];if(!error?.rowKey||!error.field)return
const node=gridApi.value.getRowNode?.(String(error.rowKey));if(node?.rowIndex==null)return
gridApi.value.ensureNodeVisible?.(node,'middle');gridApi.value.setFocusedCell?.(node.rowIndex,error.field)
emit('errorFocusChanged',{rowKey:String(error.rowKey),field:error.field})
}
function processClipboardData(params:any){
if(!props.clipboard||props.editingPolicy?.paste===false)return null
const displayed=(params.api?.getAllDisplayedColumns?.()??[]).map((c:any)=>columnFor(String(c.getColId?.()))).filter(Boolean) as KbxGridColumn<T>[]
const focused=params.api?.getFocusedCell?.();const start=displayed.findIndex(c=>c.field===String(focused?.column?.getColId?.()??''))
const normalized=normalizeKbxGridClipboardData(params.data??[],displayed,Math.max(start,0));emit('pasteProcessed',normalized.result);return normalized.data
}
</script>
<template>
<section class="kbx-grid" :data-density="density" :aria-busy="loading">
<KbxDataState v-if="firstLoading" state="loading" />
<KbxDataState v-else-if="blockingError" state="error" :detail="errorText" action-label="다시 조회" @action="emit('retry')" />
<KbxDataState v-else-if="empty" state="empty" :title="emptyText" action-label="다시 조회" @action="emit('retry')" />
<template v-else>
<div v-if="showToolbar" class="kbx-grid__toolbar" role="toolbar" aria-label="그리드 도구">
<strong v-if="selectedCount" class="kbx-grid__selection">{{selectedCount.toLocaleString('ko-KR')}}건 선택<span v-if="allFilteredActive"> · 검색결과 전체</span></strong>
<span v-if="errors.length" class="kbx-grid__error-count">{{errors.length.toLocaleString('ko-KR')}} 오류</span>
<span v-if="loading" class="kbx-grid__busy" role="status">재조회 중...</span>
<KbxButton v-if="editable&&editingPolicy?.allowRowAdd" label="행 추가" variant="ghost" @click="emit('rowAddRequested')" />
<KbxButton v-if="editable&&editingPolicy?.allowRowDuplicate" label="행 복제" variant="ghost" :disabled="!canDuplicate" @click="duplicateRows" />
<KbxButton v-if="editable&&editingPolicy?.fillDown" label="아래 채우기" variant="ghost" :disabled="!canFillDown" @click="fillDown" />
<KbxButton v-if="errors.length&&editingPolicy?.errorNavigation" label="첫/다음 오류" variant="ghost" @click="focusError(1)" />
<KbxButton v-if="canSelectAllFiltered" :label="allFilteredActive?'전체선택 해제':`검색결과 ${(totalCount||0).toLocaleString('ko-KR')}건 전체선택`" variant="ghost" @click="toggleAllFiltered" />
<span class="kbx-grid__spacer" />
<KbxButton v-if="personalization" label="열 배치 초기화" variant="ghost" @click="resetColumns" />
<KbxButton v-if="exportable" label="CSV 내보내기" variant="ghost" @click="exportCsv" />
</div>
<KbxDataState v-if="errorText" state="error" compact :detail="errorText" action-label="다시 조회" @action="emit('retry')" />
<div class="kbx-grid__body">
<AgGridVue
class="kbx-grid__ag"
:theme="kbxGridTheme"
:row-data="rows"
:column-defs="columnDefs"
:get-row-id="(p:any)=>String(p.data[rowKey])"
:row-selection="selection==='multiple'?'multiple':selection==='single'?'single':undefined"
:loading="loading"
:enter-navigates-vertically="editable"
:enter-navigates-vertically-after-edit="editable"
:enable-cell-text-selection="clipboard"
:suppress-clipboard-paste="!clipboard"
:process-data-from-clipboard="processClipboardData"
@grid-ready="onGridReady"
@column-moved="savePreference"
@column-resized="savePreference"
@column-pinned="savePreference"
@sort-changed="savePreference"
@selection-changed="onSelectionChanged"
@row-double-clicked="(e:any)=>emit('rowDoubleClicked',e.data)"
@cell-clicked="onCellClicked"
@cell-context-menu="onCellContextMenu"
@cell-value-changed="onCellValueChanged"
@cell-key-down="onCellKeyDown"
/>
</div>
<KbxSummaryBar v-if="summaryItems.length" :items="summaryItems" />
</template>
</section>
</template>
<style scoped>
.kbx-grid{min-height:var(--kbx-grid-min-height);height:100%;display:flex;flex-direction:column;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-grid__toolbar{min-height:var(--kbx-grid-toolbar-height);display:flex;align-items:center;gap:var(--kbx-space-2);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);flex-wrap:wrap}.kbx-grid__selection{color:var(--kbx-color-text);font-weight:600}.kbx-grid__spacer{flex:1}.kbx-grid__error-count{color:var(--kbx-color-danger);font-weight:600}.kbx-grid__busy{color:var(--kbx-color-primary)}.kbx-grid__body{min-height:0;flex:1}.kbx-grid__ag{width:100%;height:100%;min-height:var(--kbx-grid-min-height)}.kbx-grid[data-density="compact"] :deep(.ag-row){font-size:var(--kbx-font-sm)}.kbx-grid[data-density="comfortable"] :deep(.ag-row){font-size:var(--kbx-font-md)}:deep(.kbx-cell--numeric){text-align:right}:deep(.kbx-cell--center){text-align:center}:deep(.kbx-cell--link){text-decoration:underline;text-underline-offset:var(--kbx-space-1);cursor:pointer}:deep(.kbx-cell--changed){box-shadow:inset 0 0 0 var(--kbx-border-width) var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}:deep(.kbx-cell--invalid){box-shadow:inset 0 0 0 var(--kbx-border-width) var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}
</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxExternalDataProvenance, KbxDataFreshness } from '@kbx/contracts'
import KbxFreshnessIndicator from './KbxFreshnessIndicator.vue'
const props=defineProps<{ provenance:KbxExternalDataProvenance; compact?:boolean }>()
const emit=defineEmits<{ refresh:[] }>()
const freshness=computed<KbxDataFreshness>(()=>({ observedAt:props.provenance.providerObservedAt ?? props.provenance.receivedAt, staleAfterSeconds:props.provenance.freshUntil?Math.max(0,Math.floor((new Date(props.provenance.freshUntil).getTime()-new Date(props.provenance.receivedAt).getTime())/1000)):undefined, source:props.provenance.sourceLabel }))
const stateLabel=computed(()=>({fresh:'최신',stale:'최신 데이터 확인 중',expired:'만료',unavailable:'사용 불가'}[props.provenance.state]))
</script>
<template>
<section class="kbx-provenance" :class="{compact}" aria-label="데이터 출처와 신선도">
<div class="summary">
<strong>{{ provenance.sourceLabel }}</strong>
<span>{{ stateLabel }}</span>
<KbxFreshnessIndicator :freshness="freshness" @refresh="emit('refresh')" />
</div>
<dl v-if="!compact">
<div><dt>데이터셋</dt><dd>{{ provenance.datasetId }}</dd></div>
<div v-if="provenance.providerObservedAt"><dt>공급자 기준</dt><dd>{{ provenance.providerObservedAt }}</dd></div>
<div><dt>수신</dt><dd>{{ provenance.receivedAt }}</dd></div>
<div><dt>정규화</dt><dd>{{ provenance.ingestedAt }}</dd></div>
<div><dt>정규화 버전</dt><dd>{{ provenance.normalizerVersion }}</dd></div>
<div><dt>증거 Hash</dt><dd class="hash">{{ provenance.payloadSha256 }}</dd></div>
</dl>
<p v-if="provenance.warning" class="warning">{{ provenance.warning }}</p>
</section>
</template>
<style scoped>
.kbx-provenance{display:grid;gap:var(--kbx-space-2);border:thin solid var(--kbx-color-border);padding:var(--kbx-space-2);background:var(--kbx-color-surface)}
.summary{display:flex;gap:var(--kbx-space-2);align-items:center;flex-wrap:wrap}.summary>span{color:var(--kbx-color-text-muted)}
dl{display:grid;gap:var(--kbx-space-1);margin:0}dl>div{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:var(--kbx-space-2)}dt{color:var(--kbx-color-text-muted)}dd{margin:0}.hash{overflow-wrap:anywhere;font-family:monospace}.warning{margin:0;color:var(--kbx-color-warning)}
.compact{border:0;padding:0;background:transparent}.compact dl{display:none}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { KbxAsyncState } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
const props = withDefaults(defineProps<{
state: Exclude<KbxAsyncState, 'ready'>
title?: string
detail?: string
actionLabel?: string
compact?: boolean
}>(), { title:'', detail:'', actionLabel:'', compact:false })
const emit = defineEmits<{ action:[] }>()
const defaults = {
idle: { title:'조회 전입니다.', detail:'조회조건을 확인한 후 조회하세요.' },
loading: { title:'조회 중...', detail:'잠시 후 결과를 표시합니다.' },
empty: { title:'조회된 데이터가 없습니다.', detail:'조회조건을 변경하거나 다시 조회하세요.' },
error: { title:'데이터를 불러오지 못했습니다.', detail:'네트워크 연결과 조회조건을 확인한 후 다시 시도하세요.' },
} as const
</script>
<template>
<section class="kbx-data-state" :class="{compact}" :data-state="state" :aria-busy="state==='loading'" :role="state==='error'?'alert':'status'">
<div class="kbx-data-state__mark" aria-hidden="true">{{ state==='loading' ? '…' : state==='empty' ? '—' : state==='idle' ? '○' : '!' }}</div>
<div class="kbx-data-state__body">
<strong>{{ title || defaults[state].title }}</strong>
<span v-if="detail || defaults[state].detail">{{ detail || defaults[state].detail }}</span>
</div>
<KbxButton v-if="actionLabel" :label="actionLabel" variant="secondary" @click="emit('action')" />
</section>
</template>
<style scoped>
.kbx-data-state{min-height:var(--kbx-data-state-min-height);display:flex;align-items:center;justify-content:center;gap:var(--kbx-space-3);padding:var(--kbx-space-5);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);text-align:left}.kbx-data-state.compact{min-height:var(--kbx-control-lg);justify-content:flex-start;padding:var(--kbx-space-2) var(--kbx-space-3)}.kbx-data-state__mark{width:var(--kbx-control-sm);height:var(--kbx-control-sm);display:grid;place-items:center;border-radius:var(--kbx-radius-pill);background:var(--kbx-color-surface-muted);font-weight:700;color:var(--kbx-color-text-muted)}.kbx-data-state[data-state="error"] .kbx-data-state__mark{background:var(--kbx-color-danger-surface);color:var(--kbx-color-danger)}.kbx-data-state__body{display:flex;flex-direction:column;gap:var(--kbx-space-1);min-width:0}.kbx-data-state__body strong{font-size:var(--kbx-font-md)}.kbx-data-state__body span{font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}.kbx-data-state.compact .kbx-data-state__body span{display:none}
</style>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{modelValue?:string|null;label:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState}>(),{state:'default'})
const emit=defineEmits<{ 'update:modelValue':[string] }>()
const uid=`kbx-date-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function normalize(value:string){const digits=value.replace(/\D/g,'');return digits.length===8?`${digits.slice(0,4)}-${digits.slice(4,6)}-${digits.slice(6,8)}`:value}
</script>
<template><div class="kbx-field" :data-state="effectiveState"><label :for="uid" class="kbx-field__label">{{label}}<span v-if="required" aria-hidden="true"> *</span></label><input :id="uid" class="kbx-input" inputmode="numeric" :value="modelValue??''" :readonly="readonly" :disabled="disabled" :required="required" placeholder="YYYY-MM-DD" :aria-invalid="Boolean(error)" :aria-describedby="message?messageId:undefined" @change="emit('update:modelValue',normalize(($event.target as HTMLInputElement).value))"><span v-if="message" :id="messageId" class="kbx-field__message" :role="error?'alert':undefined">{{message}}</span></div></template>
<style scoped>.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;min-height:var(--kbx-control-height);font-size:var(--kbx-font-md)}.kbx-field__label{padding-top:var(--kbx-space-2);font-weight:500;white-space:nowrap}.kbx-input{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] .kbx-input{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] .kbx-input{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] .kbx-input{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] .kbx-input{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-input[readonly]{background:var(--kbx-color-surface-muted)}.kbx-input:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);margin-top:calc(var(--kbx-space-1) * -1)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props=withDefaults(defineProps<{from:string|null;to:string|null;label:string;disabled?:boolean;readonly?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;presets?:boolean}>(),{state:'default',presets:true})
const emit=defineEmits<{ 'update:from':[string|null]; 'update:to':[string|null] }>()
const uid=`kbx-range-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`;const preset=ref('custom')
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function fmt(d:Date){const y=d.getFullYear();const m=String(d.getMonth()+1).padStart(2,'0');const day=String(d.getDate()).padStart(2,'0');return`${y}-${m}-${day}`}
function setRange(from:Date,to:Date){emit('update:from',fmt(from));emit('update:to',fmt(to))}
function applyPreset(value:string){
preset.value=value;if(value==='custom')return
const today=new Date();today.setHours(0,0,0,0);const from=new Date(today);const to=new Date(today)
if(value==='yesterday'){from.setDate(from.getDate()-1);to.setDate(to.getDate()-1)}
if(value==='last7'){from.setDate(from.getDate()-6)}
if(value==='thisMonth'){from.setDate(1)}
if(value==='lastMonth'){from.setDate(1);from.setMonth(from.getMonth()-1);to.setDate(0)}
setRange(from,to)
}
</script>
<template>
<div class="kbx-range-field" :data-state="effectiveState">
<span :id="`${uid}-label`" class="kbx-range__label">{{label}}</span>
<div class="kbx-range" role="group" :aria-labelledby="`${uid}-label`" :aria-describedby="message?messageId:undefined">
<input type="date" :value="from??''" :disabled="disabled" :readonly="readonly" aria-label="시작일" @input="emit('update:from',($event.target as HTMLInputElement).value||null);preset='custom'">
<span aria-hidden="true">~</span>
<input type="date" :value="to??''" :disabled="disabled" :readonly="readonly" aria-label="종료일" @input="emit('update:to',($event.target as HTMLInputElement).value||null);preset='custom'">
<select v-if="presets" :value="preset" :disabled="disabled||readonly" aria-label="빠른 기간 선택" @change="applyPreset(($event.target as HTMLSelectElement).value)">
<option value="today">오늘</option><option value="yesterday">어제</option><option value="last7">최근 7</option><option value="thisMonth">이번달</option><option value="lastMonth">지난달</option><option value="custom">직접선택</option>
</select>
</div>
<span v-if="message" :id="messageId" class="kbx-range__message" :role="error?'alert':undefined">{{message}}</span>
</div>
</template>
<style scoped>.kbx-range-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-range__label{padding-top:var(--kbx-space-2);font-weight:500}.kbx-range{display:flex;align-items:center;gap:var(--kbx-space-1)}.kbx-range input,.kbx-range select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-range-field[data-state="changed"] input{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-range-field[data-state="warning"] input{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-range-field[data-state="ai-suggested"] input{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-range-field[data-state="error"] input{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-range__message{grid-column:2;font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-range-field[data-state="warning"] .kbx-range__message{color:var(--kbx-color-warning-text)}.kbx-range-field[data-state="error"] .kbx-range__message{color:var(--kbx-color-danger)}.kbx-range-field[data-state="ai-suggested"] .kbx-range__message{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
const props=withDefaults(defineProps<{open:boolean;title:string;size?:'sm'|'md'|'lg';closeable?:boolean}>(),{size:'md',closeable:true})
const emit=defineEmits<{ 'update:open':[boolean] }>()
const widths={sm:'420px',md:'600px',lg:'840px'} as const
</script>
<template><Dialog :visible="open" modal :header="title" :closable="closeable" :style="{width:widths[size]}" @update:visible="emit('update:open',$event)"><slot/><template #footer><slot name="footer"/></template></Dialog></template>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import Drawer from 'primevue/drawer'
withDefaults(defineProps<{open:boolean;title:string;position?:'left'|'right';width?:string}>(),{position:'right',width:'560px'})
const emit=defineEmits<{ 'update:open':[boolean] }>()
</script>
<template><Drawer :visible="open" :header="title" :position="position" :style="{width}" @update:visible="emit('update:open',$event)"><slot/><template #footer><slot name="footer"/></template></Drawer></template>
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
KbxImportDefinition,
KbxImportMapping,
KbxImportSession,
KbxImportProgressEvent,
} from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
import KbxConfirm from './KbxConfirm.vue'
import KbxJobProgress from './KbxJobProgress.vue'
import KbxProgressSteps from './KbxProgressSteps.vue'
import { validateKbxImportFileCandidate, validateKbxImportMappings } from '../excel/importGuard'
const props = defineProps<{
definition: KbxImportDefinition
session: KbxImportSession | null
busy?: boolean
progress?: KbxImportProgressEvent | null
}>()
const emit = defineEmits<{
upload: [File]
saveMapping: [KbxImportMapping[]]
saveNamedMapping: [string, KbxImportMapping[]]
validate: []
commit: []
downloadTemplate: []
downloadErrors: []
cancel: []
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const localMapping = ref<KbxImportMapping[] | null>(null)
const dragActive = ref(false)
const mappingName = ref('')
const clientIssue = ref('')
const commitConfirmOpen = ref(false)
const importSteps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]
const currentStepKey=computed(()=>step.value===1?'file':step.value===2?'mapping':step.value===3?'validation':'commit')
const step = computed(() => {
const status = props.session?.status
if (!status || status === 'created') return 1
if (status === 'uploaded' || status === 'mapping-required') return 2
if (status === 'validating' || status === 'validated') return 3
return 4
})
watch(() => props.session?.id, () => {
localMapping.value = null
mappingName.value = ''
clientIssue.value = ''
commitConfirmOpen.value = false
})
const mappings = computed({
get: () => localMapping.value ?? (props.session?.mapping ?? []),
set: value => { localMapping.value = value },
})
const importableFields = computed(() => props.definition.fields.filter(field => field.importable !== false))
const mappingIssues = computed(() => validateKbxImportMappings(props.definition,mappings.value))
const canValidate = computed(() => mappings.value.length > 0 && mappingIssues.value.length === 0 && !props.busy)
const canCommit = computed(() => (props.session?.validRows ?? 0) > 0 && props.session?.status === 'validated' && !props.busy)
const isFailed = computed(() => props.session?.status === 'failed')
const isCancelled = computed(() => props.session?.status === 'cancelled')
const isPartial = computed(() => props.session?.status === 'partially-completed')
function chooseFile() { clientIssue.value=''; fileInput.value?.click() }
function onFiles(files: FileList | null) {
const file = files?.item(0)
if (!file) return
clientIssue.value=''
const issue=validateKbxImportFileCandidate(props.definition,file)
if(issue){clientIssue.value=issue;return}
emit('upload', file)
}
function onDrop(event: DragEvent) {
dragActive.value = false
onFiles(event.dataTransfer?.files ?? null)
}
function setTarget(index: number, targetField: string) {
const next = mappings.value.map((item, itemIndex) => itemIndex === index
? { ...item, targetField: targetField || null, source: 'manual' as const, confidence: undefined, reason:undefined }
: item)
mappings.value = next
emit('saveMapping', next)
}
function requestCommit(){if(canCommit.value)commitConfirmOpen.value=true}
function confirmCommit(){commitConfirmOpen.value=false;emit('commit')}
</script>
<template>
<section class="kbx-import" data-kbx-component="excel-import" :aria-busy="busy || undefined">
<header class="kbx-import__header">
<div>
<h2>{{ definition.title }}</h2>
<p>파일 매핑 검증 반영 순서로 처리합니다. 원본 번호와 오류 이력은 유지됩니다.</p>
</div>
<KbxButton label="업로드 양식 다운로드" variant="secondary" @click="emit('downloadTemplate')" />
</header>
<KbxProgressSteps data-kbx-surface="progress-steps" :steps="importSteps" :current="currentStepKey" />
<div v-if="step === 1" class="kbx-import__drop" data-kbx-surface="file"
:class="{ 'is-dragging': dragActive }"
@dragover.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="onDrop">
<input ref="fileInput" hidden type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="onFiles(($event.target as HTMLInputElement).files)">
<strong>Excel 파일을 선택하세요.</strong>
<span>.xlsx · 최대 {{ Math.round((definition.maxFileSizeBytes ?? 10485760) / 1048576) }}MB</span>
<KbxButton label="파일 선택" variant="primary" :loading="busy" @click="chooseFile" />
<small>Drag & Drop은 보조 기능이며 파일 선택 버튼은 항상 제공됩니다.</small>
<p v-if="clientIssue" class="kbx-import__client-error" role="alert">{{clientIssue}}</p>
</div>
<section v-else-if="step === 2" class="kbx-import__mapping" data-kbx-surface="mapping">
<div class="mapping-head"><span>Excel </span><span>시스템 필드</span><span>매핑 근거</span></div>
<div v-for="(mapping, index) in mappings" :key="mapping.sourceColumn" class="mapping-row">
<strong>{{ mapping.sourceColumn }}</strong>
<select :value="mapping.targetField ?? ''" :aria-invalid="mapping.targetField ? mappings.filter(item=>item.targetField===mapping.targetField).length>1 : undefined" @change="setTarget(index, ($event.target as HTMLSelectElement).value)">
<option value="">매핑하지 않음</option>
<option v-for="field in importableFields" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</select>
<span class="mapping-reason">
{{ mapping.source === 'ai' ? `AI 추천 ${Math.round((mapping.confidence ?? 0) * 100)}%` : mapping.reason ?? mapping.source }}
</span>
</div>
<div v-if="mappingIssues.length" class="mapping-issues" role="alert">
<strong>매핑을 확인하세요.</strong>
<ul><li v-for="issue in mappingIssues" :key="`${issue.code}:${issue.fieldKey}`">{{issue.message}}</li></ul>
</div>
<div class="mapping-save">
<label>다음에도 사용할 매핑 이름 <input v-model="mappingName" maxlength="80" placeholder=": 쿠팡 주문양식"></label>
<KbxButton label="매핑 저장" variant="secondary" :disabled="!mappingName.trim() || Boolean(mappingIssues.length)" @click="emit('saveNamedMapping', mappingName.trim(), mappings)" />
</div>
<div class="kbx-import__actions">
<KbxButton label="취소" variant="secondary" @click="emit('cancel')" />
<KbxButton label="검증 시작" variant="primary" :disabled="!canValidate" :loading="busy" @click="emit('validate')" />
</div>
</section>
<section v-else-if="step === 3" class="kbx-import__validation" data-kbx-surface="validation">
<KbxJobProgress v-if="session?.status === 'validating'" :progress="progress ?? null" />
<template v-else>
<div class="summary" aria-label="검증 결과">
<div><span>전체</span><strong>{{ session?.totalRows.toLocaleString() }}</strong></div>
<div><span>정상</span><strong>{{ session?.validRows.toLocaleString() }}</strong></div>
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
<div><span>경고</span><strong>{{ session?.warningRows.toLocaleString() }}</strong></div>
</div>
<div v-if="session?.errors?.length" class="errors">
<div class="errors__head"><span>행</span><span>필드</span><span>사유</span></div>
<div v-for="error in session.errors.slice(0, 100)" :key="`${error.rowNumber}-${error.field}-${error.code}`" class="errors__row" :data-severity="error.severity">
<span>{{ error.rowNumber }}</span><span>{{ error.sourceColumn ?? error.field ?? '-' }}</span><span>{{ error.message }}</span>
</div>
<small v-if="session.errors.length > 100">화면에는 처음 100건만 표시합니다. 전체 오류는 오류파일로 확인하세요.</small>
</div>
<div class="kbx-import__actions">
<KbxButton v-if="session?.invalidRows" label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" />
<KbxButton label="정상 데이터 반영" variant="primary" :disabled="!canCommit" @click="requestCommit" />
</div>
</template>
</section>
<section v-else class="kbx-import__commit" data-kbx-surface="result" :data-result="session?.status">
<KbxJobProgress v-if="session?.status === 'committing'" :progress="progress ?? null" />
<div v-else-if="isFailed" class="kbx-import__terminal is-error" role="alert">
<strong>{{session?.failure?.title ?? '반영 작업을 완료하지 못했습니다.'}}</strong>
<p>{{session?.failure?.detail ?? '원본 업로드와 작업 이력은 유지됩니다. 원인을 확인한 뒤 다시 진행하세요.'}}</p>
<small v-if="session?.failure?.code">오류코드 {{session.failure.code}}</small>
<div class="kbx-import__actions"><KbxButton label=" 파일로 다시 시작" variant="secondary" @click="emit('cancel')" /></div>
</div>
<div v-else-if="isCancelled" class="kbx-import__terminal">
<strong>반영 작업이 취소되었습니다.</strong><p>새 파일을 선택해 다시 시작할 수 있습니다.</p>
<div class="kbx-import__actions"><KbxButton label=" 파일 선택" variant="secondary" @click="emit('cancel')" /></div>
</div>
<template v-else>
<div class="summary summary--result" :class="{'is-partial':isPartial}">
<div><span>신규</span><strong>{{ session?.createdRows.toLocaleString() }}</strong></div>
<div><span>수정</span><strong>{{ session?.updatedRows.toLocaleString() }}</strong></div>
<div><span>오류</span><strong>{{ session?.invalidRows.toLocaleString() }}</strong></div>
</div>
<p v-if="isPartial" class="kbx-import__partial">정상 데이터는 반영되었고 오류 데이터는 제외되었습니다. 오류파일로 실패 건만 다시 처리할 수 있습니다.</p>
<div v-if="isPartial && session?.invalidRows" class="kbx-import__actions"><KbxButton label="오류파일 다운로드" variant="secondary" @click="emit('downloadErrors')" /></div>
</template>
</section>
<KbxConfirm
:open="commitConfirmOpen"
title="정상 데이터를 반영하시겠습니까?"
:detail="`${(session?.validRows ?? 0).toLocaleString()}건을 반영합니다. 오류 ${(session?.invalidRows ?? 0).toLocaleString()}건은 제외됩니다.`"
level="high"
:confirm-label="`${(session?.validRows ?? 0).toLocaleString()}건 반영`"
@update:open="commitConfirmOpen=$event"
@confirm="confirmCommit"
/>
</section>
</template>
<style scoped>
.kbx-import{display:grid;gap:var(--kbx-space-4);min-width:0}.kbx-import__header{display:flex;justify-content:space-between;gap:var(--kbx-space-4);align-items:flex-start}.kbx-import__header h2{margin:0 0 var(--kbx-space-1);font-size:var(--kbx-font-xl)}.kbx-import__header p{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__drop{min-height:var(--kbx-import-drop-min-height);border:var(--kbx-border-width) dashed var(--kbx-color-border-strong);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);background:var(--kbx-color-surface-muted)}.kbx-import__drop.is-dragging{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2)}.kbx-import__drop>span,.kbx-import__drop small{color:var(--kbx-color-text-muted)}.kbx-import__client-error{margin:0;color:var(--kbx-color-danger);font-size:var(--kbx-font-sm)}.kbx-import__mapping,.kbx-import__validation,.kbx-import__commit{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);padding:var(--kbx-space-3)}.mapping-head,.mapping-row{display:grid;grid-template-columns:minmax(var(--kbx-import-source-column-min-width),1fr) minmax(var(--kbx-import-target-column-min-width),1.3fr) minmax(var(--kbx-import-reason-column-min-width),.8fr);gap:var(--kbx-space-3);min-height:var(--kbx-control-md);align-items:center;border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.mapping-head{font-size:var(--kbx-font-xs);font-weight:600;color:var(--kbx-color-text-muted)}.mapping-row select{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}.mapping-row select[aria-invalid="true"]{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.mapping-reason{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.mapping-issues{margin-top:var(--kbx-space-3);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface);font-size:var(--kbx-font-sm)}.mapping-issues ul{margin:var(--kbx-space-2) 0 0;padding-left:var(--kbx-space-5)}.mapping-save{display:flex;align-items:center;justify-content:flex-end;gap:var(--kbx-space-2);padding-top:var(--kbx-space-3)}.mapping-save label{display:flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-sm)}.mapping-save input{width:var(--kbx-import-mapping-name-width);height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2)}.summary{display:grid;grid-template-columns:repeat(4,minmax(var(--kbx-summary-item-min-width),1fr));border:var(--kbx-border-width) solid var(--kbx-color-border);margin-bottom:var(--kbx-space-3)}.summary>div{padding:var(--kbx-space-3);display:flex;justify-content:space-between;border-right:var(--kbx-border-width) solid var(--kbx-color-border)}.summary>div:last-child{border-right:0}.summary strong{font-size:var(--kbx-font-xl)}.summary--result{grid-template-columns:repeat(3,minmax(var(--kbx-summary-item-min-width),1fr))}.summary--result.is-partial{border-color:var(--kbx-color-warning-border)}.errors{border:var(--kbx-border-width) solid var(--kbx-color-border)}.errors__head,.errors__row{display:grid;grid-template-columns:var(--kbx-import-error-row-width) var(--kbx-import-error-field-width) 1fr;gap:var(--kbx-space-2);min-height:var(--kbx-control-height);align-items:center;padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm)}.errors__head{font-weight:600;background:var(--kbx-color-surface-muted)}.errors__row[data-severity="warning"]{background:var(--kbx-color-warning-surface)}.errors__row[data-severity="error"]{background:var(--kbx-color-danger-surface)}.errors__row:last-of-type{border-bottom:0}.errors small{display:block;padding:var(--kbx-space-2);color:var(--kbx-color-text-muted)}.kbx-import__terminal{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-4);background:var(--kbx-color-surface-muted);border:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-import__terminal.is-error{background:var(--kbx-color-danger-surface);border-color:var(--kbx-color-danger-border)}.kbx-import__terminal p,.kbx-import__partial{margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-import__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2);margin-top:var(--kbx-space-3)}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue'
import KbxButton from './KbxButton.vue'
const props = withDefaults(defineProps<{
canImport?: boolean
canExport?: boolean
}>(), { canImport: true, canExport: true })
const emit = defineEmits<{
export: []
template: []
import: []
paste: []
history: []
}>()
const open = ref(false)
function choose(action: 'export' | 'template' | 'import' | 'paste' | 'history') {
open.value = false
emit(action)
}
</script>
<template>
<div class="kbx-excel-menu">
<KbxButton label="엑셀" variant="secondary" @click="open = !open" />
<div v-if="open" class="kbx-excel-menu__popup" role="menu">
<button v-if="props.canExport" type="button" @click="choose('export')">현재 조회결과 다운로드</button>
<button v-if="props.canImport" type="button" @click="choose('template')">업로드 양식 다운로드</button>
<button v-if="props.canImport" type="button" @click="choose('import')">엑셀 업로드</button>
<button v-if="props.canImport" type="button" @click="choose('paste')">Excel에서 붙여넣기</button>
<button v-if="props.canImport" type="button" @click="choose('history')">최근 업로드 결과</button>
</div>
</div>
</template>
<style scoped>
.kbx-excel-menu { position:relative; display:inline-block; }
.kbx-excel-menu__popup { position:absolute; right:0; top:calc(100% + 4px); min-width:220px; padding:4px; background:var(--kbx-color-surface); border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); box-shadow:0 6px 18px rgb(0 0 0 / 12%); z-index:50; }
.kbx-excel-menu__popup button { display:block; width:100%; height:34px; padding:0 10px; text-align:left; border:0; background:transparent; color:var(--kbx-color-text); border-radius:4px; }
.kbx-excel-menu__popup button:hover { background:var(--kbx-color-surface-muted); }
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { KbxWorkItem, KbxWorkItemAction, KbxWorkQueueCounter } from '@kbx/contracts'
import KbxExceptionSummary from './KbxExceptionSummary.vue'
import KbxExceptionDetailDrawer from './KbxExceptionDetailDrawer.vue'
defineProps<{
counters: KbxWorkQueueCounter[]
activeKey?: string | null
selectedItem?: KbxWorkItem | null
can?: (permission: string) => boolean
}>()
const emit = defineEmits<{
filter: [string | null]
closeDetail: []
action: [KbxWorkItemAction]
}>()
</script>
<template>
<section class="kbx-exception-center">
<KbxExceptionSummary :counters="counters" :active-key="activeKey" @select="emit('filter', $event)" />
<div class="kbx-exception-center__queue"><slot /></div>
<KbxExceptionDetailDrawer :item="selectedItem ?? null" :can="can" @close="emit('closeDetail')" @action="emit('action', $event)" />
</section>
</template>
<style scoped>
.kbx-exception-center { display:flex; flex-direction:column; gap:8px; min-height:0; height:100%; }
.kbx-exception-center__queue { min-height:0; flex:1; }
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { inject } from 'vue'
import type { KbxWorkItem, KbxWorkItemAction } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
import KbxDrawer from './KbxDrawer.vue'
const props = defineProps<{ item: KbxWorkItem | null; can?: (permission: string) => boolean }>()
const emit = defineEmits<{ close: []; action: [KbxWorkItemAction] }>()
const permissionHost=inject(KbxPermissionHostKey,null)
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??false)}
function allowed(action:KbxWorkItemAction){return !action.permission||canPermission(action.permission)}
function visible(action:KbxWorkItemAction){return allowed(action)||action.permissionMode==='disable'}
function invoke(action:KbxWorkItemAction){if(allowed(action))emit('action',action)}
</script>
<template>
<KbxDrawer :open="Boolean(item)" :title="item?.title ?? '예외 상세'" @update:open="value=>{if(!value)emit('close')}">
<article v-if="item" class="kbx-exception-detail" data-kbx-component="exception-detail-drawer" :data-severity="item.severity">
<header class="kbx-exception-detail__status">
<span>{{item.code}}</span><strong>{{item.status==='resolved'?'해결':item.status==='claimed'?'처리중':item.status==='ignored'?'제외':'확인 필요'}}</strong>
</header>
<section class="meta" aria-label="예외 정보">
<dl>
<div><dt>모듈</dt><dd>{{ item.sourceModule }}</dd></div>
<div><dt>대상</dt><dd>{{ item.sourceType }} · {{ item.referenceNo }}</dd></div>
<div><dt>발생</dt><dd>{{ item.occurredAt }}</dd></div>
<div><dt>담당</dt><dd>{{ item.ownerName || '미지정' }}</dd></div>
<div v-if="item.dueAt"><dt>기한</dt><dd>{{item.dueAt}}</dd></div>
</dl>
</section>
<section class="detail">
<h2>원인/안내</h2>
<p>{{ item.detail || '추가 설명이 없습니다.' }}</p>
<slot name="context" :item="item" />
</section>
</article>
<template #footer>
<div v-if="item" class="kbx-exception-detail__actions" role="toolbar" aria-label="예외 후속 작업">
<KbxButton
v-for="action in (item.actions ?? []).filter(visible)"
:key="action.id"
:label="action.label"
:variant="action.danger?'danger':'secondary'"
:disabled="!allowed(action)"
:title="!allowed(action)?'이 작업을 실행할 권한이 없습니다.':undefined"
@click="invoke(action)"
/>
</div>
</template>
</KbxDrawer>
</template>
<style scoped>
.kbx-exception-detail{display:grid;gap:var(--kbx-space-4)}.kbx-exception-detail__status{display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-2);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.kbx-exception-detail[data-severity="warning"] .kbx-exception-detail__status{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-exception-detail[data-severity="critical"] .kbx-exception-detail__status{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}.kbx-exception-detail__status span{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.meta,.detail{padding-bottom:var(--kbx-space-4);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}dl{margin:0;display:grid;gap:var(--kbx-space-2)}dl div{display:grid;grid-template-columns:var(--kbx-label-width) 1fr;gap:var(--kbx-space-2)}dt{color:var(--kbx-color-text-muted)}dd{margin:0}h2{margin:0 0 var(--kbx-space-2);font-size:var(--kbx-font-md)}p{margin:0;line-height:1.6}.kbx-exception-detail__actions{display:flex;justify-content:flex-end;gap:var(--kbx-space-2);flex-wrap:wrap}
</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { KbxWorkQueueCounter } from '@kbx/contracts'
defineProps<{ counters: KbxWorkQueueCounter[]; activeKey?: string | null }>()
const emit = defineEmits<{ select: [string | null] }>()
</script>
<template>
<div class="kbx-exception-summary" aria-label="업무 예외 요약">
<button
v-for="counter in counters"
:key="counter.key"
type="button"
class="kbx-exception-summary__item"
:class="{ active: activeKey === counter.key }"
:data-severity="counter.severity ?? 'info'"
@click="emit('select', activeKey === counter.key ? null : counter.key)"
>
<span class="label">{{ counter.label }}</span>
<strong>{{ counter.count.toLocaleString('ko-KR') }}</strong>
</button>
</div>
</template>
<style scoped>
.kbx-exception-summary{display:flex;flex-wrap:wrap;gap:var(--kbx-space-2)}
.kbx-exception-summary__item{display:grid;grid-template-columns:auto minmax(var(--kbx-control-md),auto);align-items:center;gap:var(--kbx-space-2);min-height:var(--kbx-control-lg);padding:var(--kbx-space-1) var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);color:var(--kbx-color-text);cursor:pointer}
.kbx-exception-summary__item:hover, .kbx-exception-summary__item.active { border-color:var(--kbx-color-primary); background:var(--kbx-color-surface-hover); }
.kbx-exception-summary__item[data-severity="critical"] strong { color:var(--kbx-color-danger); }
.kbx-exception-summary__item[data-severity="warning"] strong { color:var(--kbx-color-warning); }
.label{font-size:var(--kbx-font-sm)}
strong{font-size:var(--kbx-font-lg);text-align:right}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxValidationError } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
const props=withDefaults(defineProps<{
screen:KbxScreenDefinition
selectionCount?:number
can?:(permission:string)=>boolean
breadcrumb?:string
dirty?:boolean
showKeyboardGuide?:boolean
context?:KbxTemplateContext|null
contentState?:KbxAsyncState
refreshing?:boolean
errors?:KbxValidationError[]
summaryItems?:KbxSummaryItem[]
}>(), { showKeyboardGuide:true, context:null, contentState:'ready', refreshing:false, errors:()=>[], summaryItems:()=>[] })
const emit=defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="fast-entry" template-code="T04" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :dirty="dirty" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<section v-if="showKeyboardGuide" class="kbx-fast-entry__guide" aria-label="빠른 입력 키보드 안내" data-kbx-surface="keyboard-guide"><slot name="guide"><span><kbd>Enter</kbd> 입력 확정/다음</span><span><kbd>F2</kbd> 코드 조회</span><span><kbd>Ctrl+V</kbd> Excel 붙여넣기</span><span><kbd>Ctrl+D</kbd> Fill Down</span><span>오류는 Grid에서 바로 이동</span></slot></section>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<div v-if="$slots.contextual" class="kbx-fast-entry__contextual" data-kbx-surface="bulk-action"><slot name="contextual" /></div>
<main class="kbx-fast-entry__content" data-kbx-surface="editable-grid"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" @retry="emit('command','reload')"><slot name="content" /></KbxTemplateStateBoundary></main>
<section v-if="$slots.validation || errors.length" class="kbx-fast-entry__validation" data-kbx-surface="validation"><slot name="validation"><KbxValidationSummary :errors="errors" /></slot></section>
<footer v-if="$slots.summary || summaryItems.length" class="kbx-fast-entry__summary" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" align="end" /></slot></footer>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-fast-entry__guide{min-height:var(--kbx-control-sm);display:flex;align-items:center;gap:var(--kbx-space-3);padding:0 var(--kbx-space-2);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted);flex-wrap:wrap}.kbx-fast-entry__guide kbd{font:inherit;font-weight:600;color:var(--kbx-color-text)}.kbx-fast-entry__contextual{position:sticky;top:var(--kbx-command-bar-height);z-index:6}.kbx-fast-entry__content{min-height:var(--kbx-content-min-height);flex:1}.kbx-fast-entry__validation{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-fast-entry__summary{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border);display:flex;align-items:center}.kbx-fast-entry__summary :deep(.kbx-summary-bar){width:100%;border-top:0}
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(defineProps<{
columns?: 1 | 2
ariaLabel?: string
}>(), { columns:2, ariaLabel:'입력 항목' })
</script>
<template>
<div class="kbx-form-grid" :data-columns="columns" role="group" :aria-label="ariaLabel">
<slot />
</div>
</template>
<style scoped>
.kbx-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--kbx-form-grid-column-gap);row-gap:var(--kbx-form-grid-row-gap);align-items:start}.kbx-form-grid[data-columns="1"]{grid-template-columns:minmax(0,1fr)}@media(max-width:56rem){.kbx-form-grid{grid-template-columns:minmax(0,1fr)}}
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(defineProps<{ title:string; description?:string; labelledBy?:string }>(), { description:'' })
</script>
<template>
<section class="kbx-section" :aria-labelledby="labelledBy">
<header class="kbx-section__header">
<h2 :id="labelledBy">{{ title }}</h2>
<p v-if="description">{{description}}</p>
<div v-if="$slots.actions" class="kbx-section__actions"><slot name="actions" /></div>
</header>
<div class="kbx-section__content"><slot /></div>
</section>
</template>
<style scoped>
.kbx-section{display:flex;flex-direction:column;gap:var(--kbx-form-section-heading-gap);margin-bottom:var(--kbx-form-section-gap)}.kbx-section__header{min-height:var(--kbx-control-xs);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:var(--kbx-space-2);padding-bottom:var(--kbx-form-section-heading-padding);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-section h2{margin:0;font-size:var(--kbx-font-lg);font-weight:600}.kbx-section p{grid-column:1;margin:0;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-section__actions{grid-column:2;grid-row:1/-1;display:flex;align-items:center;gap:var(--kbx-space-1)}.kbx-section__content{min-width:0}
</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
withDefaults(defineProps<{ span?: 'cell' | 'full' }>(), { span:'cell' })
</script>
<template><div class="kbx-form-span" :data-span="span"><slot /></div></template>
<style scoped>.kbx-form-span{min-width:0}.kbx-form-span[data-span="full"]{grid-column:1/-1}</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxDataFreshness } from '@kbx/contracts'
const props = withDefaults(defineProps<{
freshness: KbxDataFreshness
now?: number
}>(), { now: () => Date.now() })
const emit = defineEmits<{ refresh: [] }>()
const ageSeconds = computed(() => Math.max(0, Math.floor((props.now - new Date(props.freshness.observedAt).getTime()) / 1000)))
const stale = computed(() => props.freshness.staleAfterSeconds != null && ageSeconds.value > props.freshness.staleAfterSeconds)
const label = computed(() => {
if (ageSeconds.value < 10) return '방금 갱신'
if (ageSeconds.value < 60) return `${ageSeconds.value}초 전`
const minutes = Math.floor(ageSeconds.value / 60)
return `${minutes}분 전`
})
</script>
<template>
<button class="kbx-freshness" :class="{ stale }" type="button" @click="emit('refresh')" :title="freshness.source ? `출처: ${freshness.source}` : undefined">
<span aria-hidden="true"></span>
<span>{{ stale ? '최신 데이터 확인 필요' : label }}</span>
</button>
</template>
<style scoped>
.kbx-freshness { display:inline-flex; align-items:center; gap:4px; border:0; background:transparent; color:var(--kbx-color-text-muted); font-size:var(--kbx-font-sm); cursor:pointer; }
.kbx-freshness.stale { color:var(--kbx-color-warning); font-weight:600; }
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { KbxHelpContent } from '@kbx/contracts'
defineProps<{ content: KbxHelpContent }>()
</script>
<template>
<aside class="kbx-help-panel">
<header><h2>{{ content.title }}</h2></header>
<p>{{ content.purpose }}</p>
<section v-if="content.steps?.length">
<h3>사용순서</h3>
<ol><li v-for="step in content.steps" :key="step">{{ step }}</li></ol>
</section>
<section v-if="content.shortcuts?.length">
<h3>단축키</h3>
<dl><template v-for="item in content.shortcuts" :key="item.key"><dt>{{ item.key }}</dt><dd>{{ item.description }}</dd></template></dl>
</section>
<section v-if="content.cautions?.length">
<h3>주의</h3>
<ul><li v-for="item in content.cautions" :key="item">{{ item }}</li></ul>
</section>
</aside>
</template>
<style scoped>
.kbx-help-panel { width:min(390px, 92vw); padding:16px; font-size:14px; line-height:1.55; }
h2 { margin:0 0 8px; font-size:18px; } h3 { margin:18px 0 6px; font-size:14px; } ol, ul { padding-left:20px; } dl { display:grid; grid-template-columns:72px 1fr; gap:6px 10px; } dt { font-weight:700; } dd { margin:0; }
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxTemplateContext } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean }>(), { contentState:'ready', refreshing:false })
const emit=defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="import" template-code="T08" :screen="screen" :can="can" :breadcrumb="breadcrumb" :command-bar="Boolean(screen.commands?.length || $slots.commands)" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<nav v-if="$slots.steps" class="kbx-import-page__steps" aria-label="가져오기 단계" data-kbx-surface="progress-steps"><slot name="steps" /></nav>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<main class="kbx-import-page__content" data-kbx-surface="import-content"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" @retry="emit('command','retry')"><slot /></KbxTemplateStateBoundary></main>
<section v-if="$slots.result" class="kbx-import-page__result" data-kbx-surface="result"><slot name="result" /></section>
<footer v-if="$slots.footer" class="kbx-import-page__footer" data-kbx-surface="footer"><slot name="footer" /></footer>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-import-page__steps{min-height:var(--kbx-control-lg);display:flex;align-items:center;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:0 var(--kbx-space-3)}.kbx-import-page__content{min-height:0;flex:1}.kbx-import-page__result{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-import-page__footer{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props = withDefaults(defineProps<{
modelValue?: string | null
label: string
required?: boolean
readonly?: boolean
disabled?: boolean
error?: string
warning?: string
helpText?: string
state?: KbxFieldState
placeholder?: string
maxlength?: number
}>(), { modelValue:'', state:'default' })
const emit = defineEmits<{ 'update:modelValue':[string]; enter:[]; blur:[FocusEvent] }>()
const uid = `kbx-input-${Math.random().toString(36).slice(2)}`
const messageId = `${uid}-message`
const effectiveState = computed(() => props.error ? 'error' : props.warning ? 'warning' : props.state)
const message = computed(() => props.error || props.warning || props.helpText || '')
</script>
<template>
<div class="kbx-field" :data-state="effectiveState" :data-readonly="readonly || undefined" :data-disabled="disabled || undefined">
<label class="kbx-field__label" :for="uid">{{ label }}<span v-if="required" aria-hidden="true"> *</span></label>
<input
:id="uid"
class="kbx-input"
:value="modelValue ?? ''"
:readonly="readonly"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:maxlength="maxlength"
:aria-invalid="Boolean(error)"
:aria-describedby="message ? messageId : undefined"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="emit('enter')"
@blur="emit('blur',$event)"
>
<span v-if="message" :id="messageId" class="kbx-field__message" :role="error ? 'alert' : undefined">{{ message }}</span>
</div>
</template>
<style scoped>
.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;min-height:var(--kbx-control-height);font-size:var(--kbx-font-md)}.kbx-field__label{padding-top:var(--kbx-space-2);font-weight:500;white-space:nowrap}.kbx-input{height:var(--kbx-control-height);min-width:0;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] .kbx-input{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] .kbx-input{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] .kbx-input{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] .kbx-input{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-input[readonly]{background:var(--kbx-color-surface-muted)}.kbx-input:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);margin-top:calc(var(--kbx-space-1) * -1)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { KbxIntegrationStatusView } from '@kbx/contracts'
import KbxButton from './KbxButton.vue'
const props = defineProps<{ value: KbxIntegrationStatusView }>()
const emit = defineEmits<{ details: []; retry: [] }>()
const stateLabel: Record<KbxIntegrationStatusView['state'], string> = {
queued: '전송 대기',
delivering: '전송 중',
retrying: '자동 재시도',
delivered: '전송 완료',
failed: '연계 실패',
suspended: '연계 중지',
}
</script>
<template>
<section class="kbx-integration-state" :data-state="value.state" role="status" aria-live="polite">
<div class="kbx-integration-state__body">
<strong>{{ value.label || stateLabel[value.state] }}</strong>
<span>{{ stateLabel[value.state] }}</span>
<small v-if="value.state === 'retrying' && value.nextRetryAt">다음 자동 재시도 {{ value.nextRetryAt }}</small>
<small v-if="value.detail">{{ value.detail }}</small>
<small v-if="value.correlationId">참조번호 {{ value.correlationId }}</small>
</div>
<div class="kbx-integration-state__actions">
<KbxButton label="상세보기" variant="tertiary" @click="emit('details')" />
<KbxButton v-if="value.retryAllowed && value.state === 'failed'" label="재처리" variant="secondary" @click="emit('retry')" />
</div>
</section>
</template>
<style scoped>
.kbx-integration-state { display:flex; align-items:center; justify-content:space-between; gap:var(--kbx-space-3); min-height:var(--kbx-control-lg); padding:var(--kbx-space-2) var(--kbx-space-3); border:thin solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface); }
.kbx-integration-state__body { display:flex; align-items:baseline; flex-wrap:wrap; gap:var(--kbx-space-2); }
.kbx-integration-state__body span,.kbx-integration-state__body small { color:var(--kbx-color-text-muted); }
.kbx-integration-state__actions { display:flex; gap:var(--kbx-space-2); }
.kbx-integration-state[data-state="retrying"] { border-color:var(--kbx-color-warning); }
.kbx-integration-state[data-state="failed"],.kbx-integration-state[data-state="suspended"] { border-color:var(--kbx-color-danger); }
.kbx-integration-state[data-state="delivered"] { border-color:var(--kbx-color-success); }
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxImportProgressEvent } from '@kbx/contracts'
const props = defineProps<{ progress: KbxImportProgressEvent | null }>()
const width = computed(() => `${Math.min(100, Math.max(0, props.progress?.progressPercent ?? 0))}%`)
</script>
<template>
<section v-if="progress" class="kbx-job-progress" aria-live="polite">
<header>
<strong>{{ progress.message ?? '처리 중' }}</strong>
<span>{{ progress.progressPercent }}%</span>
</header>
<div class="track"><div class="bar" :style="{ width }" /></div>
<div class="counts">
<span>처리 {{ progress.processedRows.toLocaleString() }} / {{ progress.totalRows.toLocaleString() }}</span>
<span>정상 {{ progress.validRows.toLocaleString() }}</span>
<span v-if="progress.invalidRows">오류 {{ progress.invalidRows.toLocaleString() }}</span>
<span v-if="progress.warningRows">경고 {{ progress.warningRows.toLocaleString() }}</span>
</div>
</section>
</template>
<style scoped>
.kbx-job-progress { border:1px solid var(--kbx-color-border); background:var(--kbx-color-surface); padding:12px; }
.kbx-job-progress header, .counts { display:flex; justify-content:space-between; gap:12px; font-size:13px; }
.track { margin:10px 0; height:8px; background:var(--kbx-color-surface-muted); border-radius:4px; overflow:hidden; }
.bar { height:100%; background:var(--kbx-color-primary); transition:width .18s ease; }
.counts { justify-content:flex-start; color:var(--kbx-color-text-muted); flex-wrap:wrap; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxQuickFilterItem, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext } from '@kbx/contracts'
import KbxQuickFilterBar from './KbxQuickFilterBar.vue'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
const props = withDefaults(defineProps<{
screen: KbxScreenDefinition
selectionCount?: number
can?: (permission: string) => boolean
breadcrumb?: string
context?: KbxTemplateContext | null
contentState?: KbxAsyncState
refreshing?: boolean
quickFilters?: KbxQuickFilterItem[]
summaryItems?: KbxSummaryItem[]
}>(), {
context: null,
contentState: 'ready',
refreshing: false,
quickFilters: () => [],
summaryItems: () => [],
})
const emit = defineEmits<{ command: [string]; quickFilter: [string] }>()
</script>
<template>
<KbxScreenFrame expected-type="list" template-code="T01" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command', $event)">
<template #utility><slot name="utility" /></template>
<template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-list-page__search" data-kbx-surface="search"><slot name="search" /></div>
<div v-if="$slots['quick-filter'] || quickFilters.length" class="kbx-list-page__quick-filter" data-kbx-surface="quick-filter">
<slot name="quick-filter"><KbxQuickFilterBar :items="quickFilters" @select="emit('quickFilter',$event)" /></slot>
</div>
<div v-if="$slots.context || props.context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="props.context" /></slot></div>
<div v-if="$slots.contextual" class="kbx-list-page__contextual" data-kbx-surface="bulk-action"><slot name="contextual" /></div>
<main class="kbx-list-page__content" data-kbx-surface="content"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')"><slot name="content" /></KbxTemplateStateBoundary></main>
<footer v-if="$slots.summary || summaryItems.length" class="kbx-list-page__summary" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" /></slot></footer>
<div v-if="$slots.detail" data-kbx-surface="detail-drawer"><slot name="detail" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-list-page__content{min-height:var(--kbx-content-min-height);flex:1}.kbx-list-page__summary{min-height:var(--kbx-control-sm);display:flex;align-items:center;border-top:var(--kbx-border-width) solid var(--kbx-color-border);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-sm)}.kbx-list-page__summary :deep(.kbx-summary-bar){width:100%;border-top:0}.kbx-list-page__contextual{position:sticky;top:var(--kbx-command-bar-height);z-index:6}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import type { KbxFieldState, KbxLookupColumnDefinition, KbxLookupItem } from '@kbx/contracts'
import { kbxLookupRegistryKey } from '../lookup/registry'
import KbxLookupDialog from './KbxLookupDialog.vue'
const props = withDefaults(defineProps<{
modelValue: string | null
entity: string
label: string
required?: boolean
readonly?: boolean
disabled?: boolean
error?: string
warning?: string
helpText?: string
state?: KbxFieldState
columns?: KbxLookupColumnDefinition[]
pageSize?: number
}>(), { state:'default', pageSize:30 })
const emit = defineEmits<{
'update:modelValue': [string | null]
selected: [KbxLookupItem<string>]
}>()
const registry = inject(kbxLookupRegistryKey, {})
const provider = computed(() => registry[props.entity])
const code = ref('')
const displayName = ref('')
const open = ref(false)
const localError = ref('')
const resolving = ref(false)
let editingCode = false
let triggerElement: HTMLElement | null = null
let resolveSequence = 0
const uid=`kbx-lookup-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error||localError.value?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||localError.value||props.warning||props.helpText||'')
watch(() => [props.modelValue, props.entity] as const, async ([id]) => {
const sequence=++resolveSequence
localError.value=''
if (!id) {
displayName.value = ''
if (!editingCode) code.value = ''
editingCode = false
resolving.value=false
return
}
if(!provider.value){code.value='';displayName.value='';localError.value='조회 공급자가 구성되지 않았습니다.';return}
resolving.value=true
try{
const item = await provider.value.resolveById(id)
if(sequence!==resolveSequence)return
if(item)applyDisplay(item)
else{code.value='';displayName.value='';localError.value='선택된 항목을 다시 확인하세요.'}
}catch{
if(sequence===resolveSequence)localError.value='선택 정보를 불러오지 못했습니다. 다시 시도하세요.'
}finally{if(sequence===resolveSequence)resolving.value=false}
}, { immediate: true })
function applyDisplay(item: KbxLookupItem<string>) { code.value=item.code; displayName.value=item.displayName }
async function resolveCode() {
const normalized=code.value.trim()
localError.value=''
if(!normalized){emit('update:modelValue',null);displayName.value='';return}
if (!provider.value){localError.value='조회 공급자가 구성되지 않았습니다.';return}
const sequence=++resolveSequence
resolving.value=true
try{
const item = await provider.value.resolveByCode(normalized)
if(sequence!==resolveSequence)return
if (item) select(item)
else localError.value = '일치하는 항목이 없습니다. F2로 조회하세요.'
}catch{
if(sequence===resolveSequence)localError.value='코드를 확인하지 못했습니다. 네트워크 상태를 확인하세요.'
}finally{if(sequence===resolveSequence)resolving.value=false}
}
function onCodeInput(value: string) {
code.value=value; displayName.value=''; localError.value=''; editingCode=true
emit('update:modelValue', null)
queueMicrotask(()=>{editingCode=false})
}
function show() {
if (props.readonly || props.disabled) return
if(!provider.value){localError.value='조회 공급자가 구성되지 않았습니다.';return}
triggerElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
open.value = true
}
function select(item: KbxLookupItem<string>) {
++resolveSequence
applyDisplay(item); localError.value=''; resolving.value=false; emit('update:modelValue',item.id); emit('selected',item); open.value=false
}
function restoreFocus(){queueMicrotask(()=>triggerElement?.focus())}
function onRootKeydown(event:KeyboardEvent){if(event.key==='F2'){event.preventDefault();show()}}
</script>
<template>
<div class="kbx-lookup" :data-state="effectiveState" :aria-busy="resolving || undefined" @keydown="onRootKeydown">
<label class="kbx-lookup__label" :for="`${uid}-code`">{{ label }}<span v-if="required" aria-hidden="true"> *</span></label>
<div class="kbx-lookup__control">
<input :id="`${uid}-code`" :value="code" class="kbx-lookup__code" :readonly="readonly" :disabled="disabled" aria-label="코드" autocomplete="off" :aria-invalid="Boolean(error||localError)" :aria-describedby="message?messageId:undefined" @input="onCodeInput(($event.target as HTMLInputElement).value)" @keydown.enter.prevent="resolveCode">
<input :value="displayName" class="kbx-lookup__name" readonly :disabled="disabled" aria-label="선택된 이름">
<button type="button" class="kbx-lookup__search" :disabled="readonly || disabled || resolving" title="조회 F2" @click="show">{{ resolving ? '확인중' : '검색' }}</button>
</div>
<span v-if="message" :id="messageId" class="kbx-lookup__message" :role="error||localError?'alert':undefined">{{message}}</span>
<KbxLookupDialog v-model:visible="open" :entity="entity" :title="label" :initial-query="displayName || code" :columns="columns" :page-size="pageSize" @select="select" @update:visible="value => { open=value; if(!value)restoreFocus() }" />
</div>
</template>
<style scoped>
.kbx-lookup{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-lookup__label{padding-top:var(--kbx-space-2);font-weight:500;white-space:nowrap}.kbx-lookup__control{display:grid;grid-template-columns:minmax(var(--kbx-lookup-code-width),auto) minmax(var(--kbx-lookup-name-min-width),1fr) var(--kbx-lookup-button-width);min-width:0}.kbx-lookup input,.kbx-lookup button{height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);font:inherit;color:var(--kbx-color-text)}.kbx-lookup input{padding:0 var(--kbx-space-2);min-width:0;background:var(--kbx-color-surface)}.kbx-lookup__code{border-radius:var(--kbx-radius-sm) 0 0 var(--kbx-radius-sm)}.kbx-lookup__name{background:var(--kbx-color-surface-muted)!important;border-left:0!important}.kbx-lookup__search{border-radius:0 var(--kbx-radius-sm) var(--kbx-radius-sm) 0;background:var(--kbx-color-surface);border-left:0!important}.kbx-lookup[data-state="changed"] .kbx-lookup__code{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-lookup[data-state="warning"] .kbx-lookup__code{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-lookup[data-state="ai-suggested"] .kbx-lookup__code{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-lookup[data-state="error"] .kbx-lookup__code{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-lookup__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}.kbx-lookup[data-state="warning"] .kbx-lookup__message{color:var(--kbx-color-warning-text)}.kbx-lookup[data-state="error"] .kbx-lookup__message{color:var(--kbx-color-danger)}.kbx-lookup[data-state="ai-suggested"] .kbx-lookup__message{color:var(--kbx-color-ai-text)}
</style>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, inject, nextTick, ref, watch } from 'vue'
import type { KbxLookupColumnDefinition, KbxLookupItem } from '@kbx/contracts'
import { kbxLookupRegistryKey } from '../lookup/registry'
import KbxButton from './KbxButton.vue'
import KbxDialog from './KbxDialog.vue'
const props = withDefaults(defineProps<{
visible: boolean
entity: string
title: string
initialQuery?: string
columns?: KbxLookupColumnDefinition[]
pageSize?: number
}>(), { initialQuery: '', pageSize: 30 })
const emit = defineEmits<{
'update:visible': [boolean]
select: [KbxLookupItem<string>]
}>()
const registry = inject(kbxLookupRegistryKey, {})
const query = ref('')
const items = ref<KbxLookupItem<string>[]>([])
const selectedIndex = ref(0)
const loading = ref(false)
const error = ref('')
const lastSearchedQuery = ref('')
const page = ref(1)
const totalCount = ref(0)
const root = ref<HTMLElement | null>(null)
let requestSequence = 0
const defaultColumns: KbxLookupColumnDefinition[] = [
{ key:'code', label:'코드', source:'code' },
{ key:'displayName', label:'명칭', source:'displayName' },
{ key:'status', label:'상태', source:'status', align:'center' },
]
const columns = computed(() => props.columns?.length ? props.columns : defaultColumns)
const safePageSize = computed(() => Math.max(10, Math.min(100, props.pageSize)))
const pageCount = computed(() => Math.max(1, Math.ceil(totalCount.value / safePageSize.value)))
const hasPrevious = computed(() => page.value > 1 && !loading.value)
const hasNext = computed(() => page.value < pageCount.value && !loading.value)
watch(() => [props.visible, props.entity] as const, async ([visible]) => {
requestSequence += 1
if (!visible) return
query.value = props.initialQuery.slice(0, 120)
page.value = 1
items.value = []
totalCount.value = 0
selectedIndex.value = 0
error.value = ''
await search(1)
})
function valueOf(item: KbxLookupItem<string>, column: KbxLookupColumnDefinition) {
switch (column.source) {
case 'code': return item.code
case 'displayName': return item.displayName
case 'secondaryText': return item.secondaryText ?? ''
case 'status': return item.status ?? ''
default: return item.metadata?.[column.source.slice('metadata.'.length)] ?? ''
}
}
async function search(nextPage = 1) {
const provider = registry[props.entity]
if (!provider) {
items.value = []
totalCount.value = 0
error.value = '조회 공급자가 구성되지 않았습니다. 관리자에게 문의하세요.'
return
}
const sequence = ++requestSequence
loading.value = true
error.value = ''
try {
const result = await provider.search({
query: query.value.trim().slice(0, 120),
page: nextPage,
pageSize: safePageSize.value,
})
if (sequence !== requestSequence) return
items.value = result.items
totalCount.value = Math.max(0, result.totalCount)
page.value = nextPage
selectedIndex.value = 0
lastSearchedQuery.value = query.value
await nextTick()
scrollSelectedIntoView()
} catch {
if (sequence !== requestSequence) return
items.value = []
totalCount.value = 0
error.value = '조회하지 못했습니다. 네트워크 상태를 확인한 후 다시 조회하세요.'
} finally {
if (sequence === requestSequence) loading.value = false
}
}
async function onQueryEnter() {
if (!loading.value && !error.value && lastSearchedQuery.value === query.value && items.value[selectedIndex.value]) {
select(items.value[selectedIndex.value])
return
}
await search(1)
}
function move(delta: number) {
if (!items.value.length || loading.value) return
selectedIndex.value = Math.max(0, Math.min(items.value.length - 1, selectedIndex.value + delta))
nextTick(scrollSelectedIntoView)
}
function scrollSelectedIntoView() {
root.value?.querySelector<HTMLElement>('tr[aria-selected="true"]')?.scrollIntoView({ block:'nearest' })
}
function select(item: KbxLookupItem<string>) {
emit('select', item)
emit('update:visible', false)
}
</script>
<template>
<KbxDialog :open="visible" :title="`${title} 검색`" size="lg" @update:open="emit('update:visible', $event)">
<div
ref="root"
class="kbx-lookup-dialog"
data-kbx-component="lookup-dialog"
:aria-busy="loading || undefined"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.esc.stop="emit('update:visible', false)"
>
<div class="kbx-lookup-dialog__search">
<label class="sr-only" for="kbx-lookup-dialog-query">검색어</label>
<input
id="kbx-lookup-dialog-query"
v-model="query"
class="kbx-lookup-dialog__input"
maxlength="120"
autocomplete="off"
autofocus
placeholder="코드 또는 명칭"
@input="error=''"
@keydown.enter.stop.prevent="onQueryEnter"
>
<KbxButton label="조회" variant="secondary" :loading="loading" @click="search(1)" />
</div>
<div v-if="loading" class="kbx-lookup-dialog__state" role="status">조회 중...</div>
<div v-else-if="error" class="kbx-lookup-dialog__state is-error" role="alert">
<strong>조회할 없습니다.</strong><span>{{ error }}</span><KbxButton label="다시 조회" variant="secondary" @click="search(page)" />
</div>
<div v-else-if="!items.length" class="kbx-lookup-dialog__state">
<strong>조회된 항목이 없습니다.</strong><span>검색어를 변경해 다시 조회하세요.</span>
</div>
<div v-else class="kbx-lookup-dialog__results">
<table class="kbx-lookup-table">
<thead><tr><th v-for="column in columns" :key="column.key" :style="{ width: column.width ? `${column.width}px` : undefined, textAlign: column.align }">{{ column.label }}</th></tr></thead>
<tbody>
<tr
v-for="(item, index) in items"
:key="String(item.id)"
:aria-selected="index === selectedIndex"
:class="{ selected: index === selectedIndex }"
@click="selectedIndex = index"
@dblclick="select(item)"
>
<td v-for="column in columns" :key="column.key" :style="{ textAlign: column.align }">{{ valueOf(item, column) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="kbx-lookup-dialog__footer">
<span aria-live="polite">{{ totalCount.toLocaleString() }} · {{ page }} / {{ pageCount }} 페이지 · 이동 · Enter 선택 · Esc 닫기</span>
<div class="kbx-lookup-dialog__footer-actions">
<KbxButton label="이전" variant="secondary" :disabled="!hasPrevious" @click="search(page - 1)" />
<KbxButton label="다음" variant="secondary" :disabled="!hasNext" @click="search(page + 1)" />
<KbxButton label="선택" variant="primary" :disabled="!items[selectedIndex]" @click="items[selectedIndex] && select(items[selectedIndex])" />
</div>
</div>
</div>
</KbxDialog>
</template>
<style scoped>
.kbx-lookup-dialog{display:grid;gap:var(--kbx-space-3)}
.kbx-lookup-dialog__search{display:flex;gap:var(--kbx-space-2)}
.kbx-lookup-dialog__input{flex:1;height:var(--kbx-control-height);border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-2);font:inherit;color:var(--kbx-color-text);background:var(--kbx-color-surface)}
.kbx-lookup-dialog__results{max-height:60vh;overflow:auto;border:var(--kbx-border-width) solid var(--kbx-color-border)}
.kbx-lookup-dialog__state{min-height:var(--kbx-data-state-min-height);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kbx-space-2);padding:var(--kbx-space-4);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);text-align:center;color:var(--kbx-color-text-muted)}
.kbx-lookup-dialog__state strong{color:var(--kbx-color-text)}
.kbx-lookup-dialog__state.is-error{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}
.kbx-lookup-table{width:100%;border-collapse:collapse;font-size:var(--kbx-font-sm)}
.kbx-lookup-table th,.kbx-lookup-table td{border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);padding:var(--kbx-space-2);text-align:left;white-space:nowrap}
.kbx-lookup-table th{position:sticky;top:0;background:var(--kbx-color-surface-muted);font-weight:600;z-index:1}
.kbx-lookup-table tr.selected{outline:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-focus);outline-offset:calc(var(--kbx-border-width) * -2);background:var(--kbx-color-info-surface)}
.kbx-lookup-dialog__footer{display:flex;justify-content:space-between;align-items:center;gap:var(--kbx-space-3);color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}
.kbx-lookup-dialog__footer-actions{display:flex;gap:var(--kbx-space-2)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props=withDefaults(defineProps<{label?:string;maskedValue:string;value?:string;revealed?:boolean;canReveal?:boolean;reason?:string}>(),{revealed:false,canReveal:false})
const emit=defineEmits<{reveal:[];hide:[]}>()
</script>
<template>
<span class="kbx-masked-value">
<span v-if="label" class="kbx-masked-value__label">{{ label }}</span>
<span class="kbx-masked-value__text">{{ revealed ? (value ?? maskedValue) : maskedValue }}</span>
<button v-if="canReveal && !revealed" type="button" class="kbx-masked-value__action" @click="emit('reveal')">전체보기</button>
<button v-else-if="revealed" type="button" class="kbx-masked-value__action" @click="emit('hide')">가리기</button>
<span v-if="!canReveal && reason" class="kbx-masked-value__reason">{{ reason }}</span>
</span>
</template>
<style scoped>
.kbx-masked-value{display:inline-flex;align-items:center;gap:var(--kbx-space-2)}
.kbx-masked-value__label{font-weight:500}.kbx-masked-value__action{border:0;background:none;text-decoration:underline;cursor:pointer}.kbx-masked-value__reason{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSectionHeader from './KbxSectionHeader.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; can?:(permission:string)=>boolean; selectionCount?:number; breadcrumb?:string; masterSize?:'sm'|'md'|'lg'; masterTitle?:string; detailTitle?:string; bottomTitle?:string; contextText?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean; summaryItems?:KbxSummaryItem[] }>(), { masterSize:'md', masterTitle:'목록', detailTitle:'상세', bottomTitle:'이력', contextText:'', context:null, contentState:'ready', refreshing:false, summaryItems:()=>[] })
const emit = defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="master-detail" template-code="T05" :screen="screen" :can="can" :selection-count="selectionCount" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-master-detail__search" data-kbx-surface="search"><slot name="search" /></div>
<div v-if="$slots.context || context || contextText" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context ?? (contextText ? {label:contextText} : null)" /></slot></div>
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')">
<div class="kbx-master-detail__workspace" :data-master-size="masterSize">
<section class="kbx-master-detail__pane kbx-master-detail__master" :aria-label="masterTitle" data-kbx-surface="master"><slot name="master-header"><KbxSectionHeader :title="masterTitle" /></slot><div class="kbx-master-detail__pane-body"><slot name="master" /></div></section>
<section class="kbx-master-detail__pane kbx-master-detail__detail" :aria-label="detailTitle" data-kbx-surface="detail"><slot name="detail-header"><KbxSectionHeader :title="detailTitle" /></slot><div class="kbx-master-detail__pane-body"><slot name="detail" /></div></section>
</div>
</KbxTemplateStateBoundary>
<section v-if="$slots.bottom" class="kbx-master-detail__pane kbx-master-detail__bottom" :aria-label="bottomTitle" data-kbx-surface="bottom/history"><slot name="bottom-header"><KbxSectionHeader :title="bottomTitle" /></slot><div class="kbx-master-detail__pane-body"><slot name="bottom" /></div></section>
<footer v-if="$slots.summary || summaryItems.length" class="kbx-master-detail__footer" data-kbx-surface="summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" /></slot></footer>
<div v-if="$slots.drawer" data-kbx-surface="drawer"><slot name="drawer" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-master-detail__workspace{min-height:var(--kbx-master-detail-min-height);flex:1;display:grid;gap:var(--kbx-space-2)}.kbx-master-detail__workspace[data-master-size="sm"]{grid-template-columns:minmax(var(--kbx-master-detail-master-min-sm),34%) minmax(0,1fr)}.kbx-master-detail__workspace[data-master-size="md"]{grid-template-columns:minmax(var(--kbx-master-detail-master-min-md),42%) minmax(0,1fr)}.kbx-master-detail__workspace[data-master-size="lg"]{grid-template-columns:minmax(var(--kbx-master-detail-master-min-lg),50%) minmax(0,1fr)}.kbx-master-detail__pane{min-height:0;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface);overflow:hidden;display:flex;flex-direction:column;padding:0 var(--kbx-space-2)}.kbx-master-detail__pane-body{min-height:0;flex:1;padding:var(--kbx-space-2) 0}.kbx-master-detail__bottom{min-height:var(--kbx-master-detail-bottom-min-height)}.kbx-master-detail__footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}.kbx-master-detail__footer :deep(.kbx-summary-bar){border-top:0}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxAuditEntry, KbxConflictSnapshot, KbxScreenDefinition, KbxTemplateContext, KbxValidationError, KbxWorkflowDefinition } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxValidationSummary from './KbxValidationSummary.vue'
import KbxRecordLifecycle from './KbxRecordLifecycle.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; status?:string; dirty?:boolean; version?:number; errors?:KbxValidationError[]; workflow?:KbxWorkflowDefinition; conflict?:KbxConflictSnapshot|null; auditEntries?:KbxAuditEntry[]; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean }>(), { status:'', dirty:false, errors:()=>[], conflict:null, auditEntries:()=>[], context:null, contentState:'ready', refreshing:false })
const emit = defineEmits<{ command:[string]; transition:[string]; reloadConflict:[]; dismissConflict:[] }>()
</script>
<template>
<KbxScreenFrame expected-type="master" template-code="T02" :screen="screen" :status="status" :dirty="dirty" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template>
<template #notice><slot name="notice" /></template>
<div v-if="errors.length" data-kbx-surface="validation"><KbxValidationSummary :errors="errors" /></div>
<KbxRecordLifecycle v-if="workflow || conflict || auditEntries.length || version!=null" data-kbx-surface="record-lifecycle" :status="status" :version="version" :workflow="workflow" :conflict="conflict" :audit-entries="auditEntries" :can="can" @transition="emit('transition',$event)" @reload-conflict="emit('reloadConflict')" @dismiss-conflict="emit('dismissConflict')" />
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" :idle-action-label="$slots.list?'조회 F3':''" @idle-action="emit('command','search')" @retry="emit('command','search')">
<div class="kbx-master-page__body" :class="{ 'without-list': !$slots.list }">
<aside v-if="$slots.list" class="kbx-master-page__list" data-kbx-surface="master-list"><slot name="list" /></aside>
<main class="kbx-master-page__detail" data-kbx-surface="detail">
<slot name="detail" />
<section v-if="$slots.tabs" class="kbx-master-page__tabs" data-kbx-surface="tabs"><slot name="tabs" /></section>
</main>
</div>
</KbxTemplateStateBoundary>
<footer v-if="$slots.footer" class="kbx-master-page__footer" data-kbx-surface="footer"><slot name="footer" /></footer>
<div v-if="$slots.drawer" data-kbx-surface="drawer"><slot name="drawer" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-master-page__body{display:grid;grid-template-columns:minmax(var(--kbx-master-list-min-width),32%) minmax(0,1fr);gap:var(--kbx-space-2);min-height:0;flex:1}.kbx-master-page__body.without-list{grid-template-columns:minmax(0,1fr)}.kbx-master-page__list,.kbx-master-page__detail{min-height:0;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-master-page__list{overflow:hidden}.kbx-master-page__detail{padding:var(--kbx-space-4);overflow:auto;display:flex;flex-direction:column;gap:var(--kbx-space-3)}.kbx-master-page__tabs{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-master-page__footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}@media(max-width:68.75rem){.kbx-master-page__body{grid-template-columns:var(--kbx-master-list-compact-width) minmax(0,1fr)}}
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
import KbxNumberField from './KbxNumberField.vue'
withDefaults(defineProps<{modelValue?:number|null;label:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;precision?:number;currency?:string;allowNegative?:boolean;zeroAllowed?:boolean}>(),{precision:0,currency:'원',allowNegative:false,zeroAllowed:true,state:'default'})
const emit=defineEmits<{ 'update:modelValue':[number|null] }>()
</script>
<template><KbxNumberField :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="error" :warning="warning" :help-text="helpText" :state="state" :precision="precision" :min="allowNegative?undefined:(zeroAllowed?0:Number.MIN_VALUE)" :suffix="currency" @update:model-value="emit('update:modelValue',$event)" /></template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { KbxUserNotification } from '@kbx/contracts'
defineProps<{ notifications: KbxUserNotification[] }>()
const emit = defineEmits<{ open: [KbxUserNotification]; read: [string] }>()
</script>
<template>
<section class="kbx-notification-center" aria-label="알림 센터">
<button
v-for="item in notifications"
:key="item.id"
type="button"
class="notice"
:class="[`is-${item.severity}`, { unread: !item.readAt }]"
@click="emit('open', item); emit('read', item.id)"
>
<span class="marker" aria-hidden="true" />
<span class="copy"><strong>{{ item.title }}</strong><small v-if="item.message">{{ item.message }}</small></span>
<time>{{ new Date(item.createdAt).toLocaleString('ko-KR') }}</time>
</button>
<p v-if="notifications.length === 0" class="empty"> 알림이 없습니다.</p>
</section>
</template>
<style scoped>
.kbx-notification-center { display:grid; gap:4px; }
.notice { display:grid; grid-template-columns:8px 1fr auto; align-items:start; gap:8px; padding:10px; border:1px solid transparent; background:transparent; text-align:left; cursor:pointer; }
.notice:hover { background:var(--kbx-color-surface-hover); }
.marker { width:6px; height:6px; margin-top:6px; border-radius:50%; background:var(--kbx-color-text-muted); }
.unread .marker { background:var(--kbx-color-primary); }
.copy { display:grid; gap:2px; } small,time,.empty { color:var(--kbx-color-text-muted); font-size:var(--kbx-font-xs); }
time { white-space:nowrap; }
.empty { margin:0; padding:12px; }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
const props = withDefaults(defineProps<{
modelValue?: number | null
label: string
required?: boolean
readonly?: boolean
disabled?: boolean
error?: string
warning?: string
helpText?: string
state?: KbxFieldState
min?: number
max?: number
step?: number
precision?: number
suffix?: string
}>(), { step:1, precision:0, state:'default' })
const emit=defineEmits<{ 'update:modelValue':[number|null]; enter:[] }>()
const uid=`kbx-number-${Math.random().toString(36).slice(2)}`
const messageId=`${uid}-message`
const effectiveState=computed(()=>props.error?'error':props.warning?'warning':props.state)
const message=computed(()=>props.error||props.warning||props.helpText||'')
function parse(raw:string){
if(!raw.trim()){emit('update:modelValue',null);return}
const n=Number(raw.replaceAll(',',''))
if(!Number.isFinite(n))return
const bounded=Math.max(props.min??-Infinity,Math.min(props.max??Infinity,n))
emit('update:modelValue',bounded)
}
function display(v:number|null|undefined){if(v==null)return'';return new Intl.NumberFormat('ko-KR',{minimumFractionDigits:props.precision,maximumFractionDigits:props.precision}).format(v)}
</script>
<template>
<div class="kbx-field" :data-state="effectiveState">
<label :for="uid" class="kbx-field__label">{{label}}<span v-if="required" aria-hidden="true"> *</span></label>
<div class="kbx-number-wrap">
<input :id="uid" class="kbx-number" inputmode="decimal" :value="display(modelValue)" :readonly="readonly" :disabled="disabled" :required="required" :aria-invalid="Boolean(error)" :aria-describedby="message?messageId:undefined" @change="parse(($event.target as HTMLInputElement).value)" @keydown.enter.prevent="emit('enter')">
<span v-if="suffix" class="kbx-number__suffix">{{suffix}}</span>
</div>
<span v-if="message" :id="messageId" class="kbx-field__message" :role="error?'alert':undefined">{{message}}</span>
</div>
</template>
<style scoped>
.kbx-field{display:grid;grid-template-columns:var(--kbx-label-width) minmax(0,1fr);gap:var(--kbx-space-2);align-items:start;font-size:var(--kbx-font-md)}.kbx-field__label{padding-top:var(--kbx-space-2);font-weight:500}.kbx-number-wrap{display:flex;align-items:center;gap:var(--kbx-space-2)}.kbx-number{height:var(--kbx-control-height);width:100%;min-width:0;text-align:right;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-sm);padding:0 var(--kbx-space-3);font:inherit;background:var(--kbx-color-surface);color:var(--kbx-color-text)}.kbx-field[data-state="changed"] .kbx-number{border-color:var(--kbx-color-changed-border);background:var(--kbx-color-changed-surface)}.kbx-field[data-state="warning"] .kbx-number{border-color:var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface)}.kbx-field[data-state="ai-suggested"] .kbx-number{border-color:var(--kbx-color-ai-border);background:var(--kbx-color-ai-surface)}.kbx-field[data-state="error"] .kbx-number{border-color:var(--kbx-color-danger);background:var(--kbx-color-danger-surface)}.kbx-number[readonly]{background:var(--kbx-color-surface-muted)}.kbx-number:disabled{background:var(--kbx-color-surface-subtle);color:var(--kbx-color-text-muted)}.kbx-number__suffix{white-space:nowrap;color:var(--kbx-color-text-muted)}.kbx-field__message{grid-column:2;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);margin-top:calc(var(--kbx-space-1) * -1)}.kbx-field[data-state="warning"] .kbx-field__message{color:var(--kbx-color-warning-text)}.kbx-field[data-state="error"] .kbx-field__message{color:var(--kbx-color-danger)}.kbx-field[data-state="ai-suggested"] .kbx-field__message{color:var(--kbx-color-ai-text)}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { KbxOperationRun } from '@kbx/contracts'
defineProps<{ operations: KbxOperationRun[] }>()
const emit = defineEmits<{ open: [KbxOperationRun] }>()
function progress(operation: KbxOperationRun) {
if (!operation.total) return null
return Math.min(100, Math.round(((operation.processed ?? 0) / operation.total) * 100))
}
</script>
<template>
<section class="kbx-operation-center" aria-label="작업 센터">
<button v-for="operation in operations" :key="operation.id" type="button" class="operation" @click="emit('open', operation)">
<div class="top"><strong>{{ operation.title }}</strong><span>{{ operation.status }}</span></div>
<div v-if="progress(operation) != null" class="progress"><i :style="{ width: `${progress(operation)}%` }" /></div>
<small v-if="operation.total">{{ operation.processed ?? 0 }} / {{ operation.total }}</small>
<small v-else-if="operation.resultMessage">{{ operation.resultMessage }}</small>
</button>
<p v-if="operations.length === 0" class="empty">진행 중이거나 최근 실행한 작업이 없습니다.</p>
</section>
</template>
<style scoped>
.kbx-operation-center { display:grid; gap:8px; }
.operation { display:grid; gap:6px; padding:10px 12px; text-align:left; border:1px solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); background:var(--kbx-color-surface); cursor:pointer; }
.top { display:flex; justify-content:space-between; gap:8px; } .top span,small,.empty { color:var(--kbx-color-text-muted); }
.progress { height:6px; background:var(--kbx-color-surface-subtle); border-radius:999px; overflow:hidden; }
.progress i { display:block; height:100%; background:var(--kbx-color-primary); }
.empty { margin:0; padding:12px; }
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
breadcrumb?: string
description?: string
status?: string
dirty?: boolean
}>(), { breadcrumb:'', description:'', status:'', dirty:false })
</script>
<template>
<header class="kbx-page-header">
<div class="kbx-page-header__identity">
<div v-if="breadcrumb" class="kbx-page-header__breadcrumb">{{ breadcrumb }}</div>
<div class="kbx-page-header__title-row">
<h1>{{ title }}</h1>
<span v-if="status" class="kbx-page-header__status">{{ status }}</span>
<span v-if="dirty" class="kbx-page-header__dirty">변경됨</span>
</div>
<p v-if="description">{{ description }}</p>
</div>
<div class="kbx-page-header__utility"><slot name="utility" /></div>
</header>
</template>
<style scoped>
.kbx-page-header {
min-height:var(--kbx-page-header-height);
display:flex;
align-items:center;
justify-content:space-between;
gap:var(--kbx-space-4);
padding:var(--kbx-space-1) var(--kbx-screen-inline-padding);
border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);
background:var(--kbx-color-surface);
}
.kbx-page-header__identity { min-width:0; }
.kbx-page-header__title-row { display:flex; align-items:center; gap:var(--kbx-space-2); min-width:0; }
h1 { font-size:var(--kbx-font-2xl); font-weight:600; margin:0; line-height:1.3; }
.kbx-page-header__breadcrumb, p { font-size:var(--kbx-font-xs); color:var(--kbx-color-text-muted); margin:0 0 var(--kbx-space-1); }
.kbx-page-header__status, .kbx-page-header__dirty { font-size:var(--kbx-font-xs); padding:var(--kbx-space-1) var(--kbx-space-2); border:var(--kbx-border-width) solid var(--kbx-color-border); border-radius:var(--kbx-radius-sm); white-space:nowrap; }
.kbx-page-header__status { background:var(--kbx-color-surface-muted); }
.kbx-page-header__dirty { color:var(--kbx-color-warning-text); border-color:var(--kbx-color-warning-border); background:var(--kbx-color-warning-surface); }
.kbx-page-header__utility { display:flex; align-items:center; gap:var(--kbx-space-1); flex-shrink:0; }
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
export interface KbxProgressStep { key:string; label:string; optional?:boolean }
const props=defineProps<{steps:KbxProgressStep[]; current:string}>()
const currentIndex=()=>Math.max(0,props.steps.findIndex(step=>step.key===props.current))
</script>
<template>
<ol class="kbx-progress-steps" aria-label="업무 진행 단계" :style="{'--kbx-progress-step-count': String(steps.length)}">
<li v-for="(step,index) in props.steps" :key="step.key" :class="{active:step.key===props.current,done:index<currentIndex()}" :aria-current="step.key===props.current?'step':undefined">
<span class="number" aria-hidden="true">{{ index+1 }}</span>
<span>{{ step.label }}</span>
<small v-if="step.optional">선택</small>
</li>
</ol>
</template>
<style scoped>
.kbx-progress-steps{display:grid;grid-template-columns:repeat(var(--kbx-progress-step-count,4),minmax(0,1fr));list-style:none;margin:0;padding:0;border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted)}.kbx-progress-steps li{min-height:var(--kbx-progress-step-height);display:flex;align-items:center;justify-content:center;gap:var(--kbx-space-2);border-right:var(--kbx-border-width) solid var(--kbx-color-border);font-size:var(--kbx-font-sm);color:var(--kbx-color-text-muted)}.kbx-progress-steps li:last-child{border-right:0}.kbx-progress-steps li.active{background:var(--kbx-color-surface);color:var(--kbx-color-primary);font-weight:600}.kbx-progress-steps li.done{color:var(--kbx-color-text);font-weight:500}.kbx-progress-steps .number{min-width:var(--kbx-progress-step-number-size);height:var(--kbx-progress-step-number-size);display:grid;place-items:center;border:var(--kbx-border-width) solid var(--kbx-color-border-strong);border-radius:50%;font-size:var(--kbx-font-xs)}.kbx-progress-steps li.active .number{border-color:var(--kbx-color-primary)}.kbx-progress-steps small{font-size:var(--kbx-font-xs);font-weight:400}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { KbxAiProposal } from '@kbx/contracts'
import { KbxPermissionHostKey } from '../permission/host'
import KbxButton from './KbxButton.vue'
const props=defineProps<{proposal:KbxAiProposal;canApply?:boolean;can?:(permission:string)=>boolean}>()
const emit=defineEmits<{cancel:[];detail:[];apply:[]}>()
const permissionHost=inject(KbxPermissionHostKey,null)
const permissionAllowed=computed(()=>!props.proposal.requiredPermission||(props.can?props.can(props.proposal.requiredPermission):(permissionHost?.has(props.proposal.requiredPermission)??false)))
const validationAllowed=computed(()=>!props.proposal.validation||props.proposal.validation.state==='validated')
const applyDisabled=computed(()=>props.canApply===false||!permissionAllowed.value||!validationAllowed.value)
const guardMessage=computed(()=>{
if(!permissionAllowed.value)return '이 제안을 적용할 권한이 없습니다.'
if(props.proposal.validation?.state==='pending')return props.proposal.validation.message??'서버에서 대상·권한·업무규칙을 확인하고 있습니다.'
if(props.proposal.validation?.state==='invalid')return props.proposal.validation.message??'현재 업무 상태에서는 이 제안을 적용할 수 없습니다.'
if(props.proposal.validation?.state==='stale')return props.proposal.validation.message??'대상 데이터가 변경되었습니다. 최신 기준으로 제안을 다시 확인하세요.'
if(props.canApply===false)return '현재 화면 상태에서는 이 제안을 적용할 수 없습니다.'
return ''
})
function apply(){if(!applyDisabled.value)emit('apply')}
</script>
<template>
<section class="kbx-proposal" aria-label="AI 제안" :data-validation="proposal.validation?.state ?? 'not-provided'">
<header><strong>AI 제안</strong><span v-if="proposal.confidence!=null">신뢰도 {{Math.round(proposal.confidence*100)}}%</span></header>
<div class="kbx-proposal__title"><h3>{{proposal.title}}</h3><small v-if="proposal.targets?.length">대상 {{proposal.targets.length.toLocaleString()}}</small></div>
<p>{{proposal.explanation}}</p>
<dl v-if="proposal.proposedChanges?.length"><template v-for="change in proposal.proposedChanges" :key="`${change.field}:${String(change.before)}:${String(change.after)}`"><dt>{{change.label}}</dt><dd>{{change.before ?? '-'}} {{change.after ?? '-'}}</dd></template></dl>
<div v-if="proposal.evidence?.length" class="kbx-proposal__evidence"><strong>근거</strong><span v-for="evidence in proposal.evidence" :key="`${evidence.sourceType}:${evidence.label}`">{{evidence.label}} · {{evidence.sourceType}}</span></div>
<p v-if="guardMessage" class="kbx-proposal__guard" role="status">{{guardMessage}}</p>
<footer><KbxButton label="취소" @click="emit('cancel')"/><KbxButton label="상세보기" @click="emit('detail')"/><KbxButton label="변경안 적용" variant="primary" :disabled="applyDisabled" :title="guardMessage||undefined" @click="apply"/></footer>
</section>
</template>
<style scoped>
.kbx-proposal{display:grid;gap:var(--kbx-space-2);padding:var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface)}header,footer,.kbx-proposal__title{display:flex;align-items:center;gap:var(--kbx-space-2)}header span,.kbx-proposal__title small{margin-left:auto;color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs)}h3,p{margin:0}dl{display:grid;grid-template-columns:calc(var(--kbx-label-width) + var(--kbx-space-6)) 1fr;gap:var(--kbx-space-1) var(--kbx-space-2);margin:var(--kbx-space-1) 0}dt{font-weight:500}dd{margin:0}.kbx-proposal__evidence{display:flex;gap:var(--kbx-space-2);flex-wrap:wrap;font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-proposal__guard{padding:var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-warning-border);background:var(--kbx-color-warning-surface);font-size:var(--kbx-font-sm)}.kbx-proposal[data-validation="invalid"] .kbx-proposal__guard,.kbx-proposal[data-validation="stale"] .kbx-proposal__guard{border-color:var(--kbx-color-danger-border);background:var(--kbx-color-danger-surface)}footer{justify-content:flex-end}
</style>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { KbxFieldState } from '@kbx/contracts'
import KbxNumberField from './KbxNumberField.vue'
const props=withDefaults(defineProps<{modelValue?:number|null;label:string;unit?:string;required?:boolean;readonly?:boolean;disabled?:boolean;error?:string;warning?:string;helpText?:string;state?:KbxFieldState;availableQuantity?:number|null;allowNegative?:boolean;precision?:number}>(),{unit:'EA',allowNegative:false,precision:0,state:'default'})
const emit=defineEmits<{ 'update:modelValue':[number|null] }>()
const effectiveError=computed(()=>props.error ?? (props.availableQuantity!=null && props.modelValue!=null && props.modelValue>props.availableQuantity ? `출고 가능 수량은 ${props.availableQuantity}${props.unit}입니다.` : undefined))
</script>
<template><KbxNumberField :model-value="modelValue" :label="label" :required="required" :readonly="readonly" :disabled="disabled" :error="effectiveError" :warning="warning" :help-text="helpText" :state="state" :precision="precision" :min="allowNegative?undefined:0" :suffix="unit" @update:model-value="emit('update:modelValue',$event)" /></template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxSummaryItem, KbxTemplateContext, KbxWorkQueueCounter } from '@kbx/contracts'
import KbxExceptionSummary from './KbxExceptionSummary.vue'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxSummaryBar from './KbxSummaryBar.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{
screen:KbxScreenDefinition
selectionCount?:number
can?:(permission:string)=>boolean
breadcrumb?:string
context?:KbxTemplateContext|null
contentState?:KbxAsyncState
refreshing?:boolean
summaryItems?:KbxSummaryItem[]
exceptionCounters?:KbxWorkQueueCounter[]
activeExceptionKey?:string|null
}>(), { context:null, contentState:'ready', refreshing:false, summaryItems:()=>[], exceptionCounters:()=>[], activeExceptionKey:null })
const emit=defineEmits<{ command:[string]; exceptionFilter:[string|null] }>()
</script>
<template>
<KbxScreenFrame expected-type="queue" template-code="T06" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-queue-page__search" data-kbx-surface="search"><slot name="search" /></div>
<section v-if="$slots.summary || summaryItems.length" class="kbx-queue-page__summary" data-kbx-surface="work-summary"><slot name="summary"><KbxSummaryBar :items="summaryItems" /></slot></section>
<section v-if="$slots.exceptions || exceptionCounters.length" class="kbx-queue-page__exceptions" data-kbx-surface="exception-summary"><slot name="exceptions"><KbxExceptionSummary :counters="exceptionCounters" :active-key="activeExceptionKey" @select="emit('exceptionFilter',$event)" /></slot></section>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<section v-if="$slots.contextual" class="kbx-queue-page__contextual" data-kbx-surface="bulk-action"><slot name="contextual" /></section>
<main class="kbx-queue-page__content" data-kbx-surface="queue/content"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')"><slot name="content" /></KbxTemplateStateBoundary></main>
<footer v-if="$slots.footer" class="kbx-queue-page__footer" data-kbx-surface="footer"><slot name="footer" /></footer>
<div v-if="$slots.detail" data-kbx-surface="detail"><slot name="detail" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-queue-page__summary,.kbx-queue-page__exceptions{border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);padding:var(--kbx-space-2)}.kbx-queue-page__summary :deep(.kbx-summary-bar){border-top:0}.kbx-queue-page__content{min-height:var(--kbx-content-min-height);flex:1}.kbx-queue-page__contextual{position:sticky;top:var(--kbx-command-bar-height);z-index:6}.kbx-queue-page__footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import type { KbxQuickFilterItem } from '@kbx/contracts'
defineProps<{items:KbxQuickFilterItem[];ariaLabel?:string}>();const emit=defineEmits<{ select:[string] }>()
</script>
<template><nav class="kbx-quick-filter" :aria-label="ariaLabel??'빠른 필터'"><button v-for="item in items" :key="item.key" type="button" :class="{active:item.active}" :data-tone="item.tone??'default'" :aria-pressed="item.active??false" @click="emit('select',item.key)"><span>{{item.label}}</span><strong>{{item.count.toLocaleString('ko-KR')}}</strong></button></nav></template>
<style scoped>.kbx-quick-filter{display:flex;align-items:stretch;min-height:var(--kbx-home-attention-height);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}.kbx-quick-filter button{min-width:6.875rem;display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);border:0;border-right:var(--kbx-border-width) solid var(--kbx-color-border);background:transparent;padding:0 var(--kbx-space-3);text-align:left}.kbx-quick-filter button:last-child{border-right:0}.kbx-quick-filter button:hover,.kbx-quick-filter button.active{background:var(--kbx-color-surface-hover)}.kbx-quick-filter span{font-size:var(--kbx-font-xs);color:var(--kbx-color-text-muted)}.kbx-quick-filter strong{font-size:var(--kbx-font-md)}.kbx-quick-filter button[data-tone="warning"] strong{color:var(--kbx-color-warning-text)}.kbx-quick-filter button[data-tone="danger"] strong{color:var(--kbx-color-danger)}</style>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import type { KbxFieldState } from '@kbx/contracts'
defineProps<{modelValue?:string|number|null;value:string|number;name:string;label:string;disabled?:boolean;state?:KbxFieldState}>()
const emit=defineEmits<{ 'update:modelValue':[string|number] }>()
const uid=`kbx-radio-${Math.random().toString(36).slice(2)}`
</script>
<template><label class="kbx-radio" :for="uid" :data-state="state??'default'"><input :id="uid" type="radio" :name="name" :value="value" :checked="modelValue===value" :disabled="disabled" @change="emit('update:modelValue',value)"><span>{{label}}</span></label></template>
<style scoped>.kbx-radio{min-height:var(--kbx-control-height);display:inline-flex;align-items:center;gap:var(--kbx-space-2);font-size:var(--kbx-font-md)}.kbx-radio input{width:var(--kbx-checkbox-size);height:var(--kbx-checkbox-size);margin:0}.kbx-radio[data-state="changed"] span{color:var(--kbx-color-primary)}.kbx-radio[data-state="warning"] span{color:var(--kbx-color-warning-text)}.kbx-radio[data-state="ai-suggested"] span{color:var(--kbx-color-ai-text)}</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { KbxAsyncState, KbxScreenDefinition, KbxReconcileSummary, KbxTemplateContext } from '@kbx/contracts'
import KbxScreenFrame from './KbxScreenFrame.vue'
import KbxTemplateContextBar from './KbxTemplateContextBar.vue'
import KbxTemplateStateBoundary from './KbxTemplateStateBoundary.vue'
withDefaults(defineProps<{ screen:KbxScreenDefinition; summary?:KbxReconcileSummary; selectionCount?:number; can?:(permission:string)=>boolean; breadcrumb?:string; context?:KbxTemplateContext|null; contentState?:KbxAsyncState; refreshing?:boolean }>(), { contentState:'ready', refreshing:false })
const emit=defineEmits<{ command:[string] }>()
</script>
<template>
<KbxScreenFrame expected-type="reconcile" template-code="T07" :screen="screen" :selection-count="selectionCount" :can="can" :breadcrumb="breadcrumb" :suppress-default-utility="Boolean($slots.utility)" @command="emit('command',$event)">
<template #utility><slot name="utility" /></template><template #notice><slot name="notice" /></template>
<div v-if="$slots.search" class="kbx-reconcile-search" data-kbx-surface="criteria/search"><slot name="search" /></div>
<div v-if="summary" class="kbx-reconcile-summary" aria-label="대사 요약" data-kbx-surface="summary"><span>전체 <strong>{{summary.totalCount.toLocaleString('ko-KR')}}</strong></span><span>정상 <strong>{{summary.matchedCount.toLocaleString('ko-KR')}}</strong></span><span class="mismatch">불일치 <strong>{{summary.mismatchCount.toLocaleString('ko-KR')}}</strong></span><span>확인중 <strong>{{summary.pendingCount.toLocaleString('ko-KR')}}</strong></span><span>해결 <strong>{{summary.resolvedCount.toLocaleString('ko-KR')}}</strong></span></div>
<section v-if="$slots.filters" class="kbx-reconcile-filters" data-kbx-surface="filters"><slot name="filters" /></section>
<div v-if="$slots.context || context" data-kbx-surface="context"><slot name="context"><KbxTemplateContextBar :context="context" /></slot></div>
<section v-if="$slots.resolution" class="kbx-reconcile-resolution" data-kbx-surface="resolution-action"><slot name="resolution" /></section>
<main class="kbx-reconcile-content" data-kbx-surface="comparison-grid"><KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing" idle-action-label="조회 F3" @idle-action="emit('command','search')" @retry="emit('command','search')"><slot name="content" /></KbxTemplateStateBoundary></main>
<section v-if="$slots.audit" class="kbx-reconcile-audit" data-kbx-surface="audit"><slot name="audit" /></section>
<footer v-if="$slots.footer" class="kbx-reconcile-footer"><slot name="footer" /></footer>
<div v-if="$slots.detail" data-kbx-surface="detail"><slot name="detail" /></div>
</KbxScreenFrame>
</template>
<style scoped>
.kbx-reconcile-summary{display:flex;flex-wrap:wrap;gap:var(--kbx-space-4);padding:var(--kbx-space-2) var(--kbx-space-3);border:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-sm)}.kbx-reconcile-summary strong{margin-left:var(--kbx-space-1);font-size:var(--kbx-font-md)}.kbx-reconcile-summary .mismatch strong{color:var(--kbx-color-danger)}.kbx-reconcile-resolution{position:sticky;top:var(--kbx-command-bar-height);z-index:6}.kbx-reconcile-content{min-height:var(--kbx-content-min-height);flex:1}.kbx-reconcile-audit{border-top:var(--kbx-border-width) solid var(--kbx-color-border);padding-top:var(--kbx-space-2)}.kbx-reconcile-footer{min-height:var(--kbx-control-sm);border-top:var(--kbx-border-width) solid var(--kbx-color-border)}
</style>

Some files were not shown because too many files have changed in this diff Show More